diff --git a/ConsoleRenderer.cs b/ConsoleRenderer.cs
index 42fa45d..5a46b6a 100644
--- a/ConsoleRenderer.cs
+++ b/ConsoleRenderer.cs
@@ -1,3 +1,4 @@
+using Spectre.Console;
using YoutubeSummarizer.Models;
namespace YoutubeSummarizer.Services;
@@ -7,75 +8,88 @@ namespace YoutubeSummarizer.Services;
/// Keeping display logic separate from business logic makes it easy to
/// later add output modes (JSON, Markdown file, HTML report) without
/// touching the service layer.
+///
+/// Rendering is delegated to Spectre.Console, which handles colour,
+/// word-wrap to the current terminal width, and graceful degradation
+/// when output is redirected to a file or a non-ANSI terminal.
///
public static class ConsoleRenderer
{
- // ANSI color codes. These render correctly in most Linux terminals.
- // If you pipe output to a file, the escape codes will appear as-is —
- // run with --no-color if that's a concern (not implemented here, left
- // as an exercise).
- private const string Reset = "\x1b[0m";
- private const string Bold = "\x1b[1m";
- private const string Cyan = "\x1b[36m";
- private const string Yellow = "\x1b[33m";
- private const string Green = "\x1b[32m";
- private const string Red = "\x1b[31m";
- private const string Dim = "\x1b[2m";
-
/// Prints the application banner on startup.
public static void PrintBanner()
{
- Console.WriteLine();
- Console.WriteLine($"{Bold}{Cyan}╔════════════════════════════════════════╗{Reset}");
- Console.WriteLine($"{Bold}{Cyan}║ YouTube Video Summarizer ║{Reset}");
- Console.WriteLine($"{Bold}{Cyan}╚════════════════════════════════════════╝{Reset}");
- Console.WriteLine();
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new FigletText("YT Summary").Centered().Color(Color.Red));
+ AnsiConsole.Write(
+ new Rule("[grey]YouTube Video Summarizer[/]")
+ .RuleStyle("grey")
+ .Centered());
+ AnsiConsole.WriteLine();
}
+ ///
+ /// True when the terminal can drive Spectre's interactive prompts. When output
+ /// is redirected or piped we fall back to plain reads / default answers so the
+ /// app still works in scripts and CI.
+ ///
+ private static bool Interactive => AnsiConsole.Profile.Capabilities.Interactive;
+
/// Prompts the user for a URL and reads input.
public static string PromptForUrl()
{
- Console.Write($"{Bold}Enter YouTube URL (or 'q' to quit):{Reset} ");
- return Console.ReadLine()?.Trim() ?? string.Empty;
+ if (!Interactive)
+ {
+ AnsiConsole.Markup("[bold]Enter YouTube URL[/] [grey](or 'q' to quit)[/]: ");
+ return Console.ReadLine()?.Trim() ?? string.Empty;
+ }
+
+ var input = AnsiConsole.Prompt(
+ new TextPrompt("[bold]Enter YouTube URL[/] [grey](or 'q' to quit)[/]:")
+ .AllowEmpty());
+ return input.Trim();
}
///
/// Asks the user whether they want to save the transcript to a text file.
- /// Returns true if the user answers yes.
+ /// Defaults to true — pressing Enter accepts.
///
public static bool PromptSaveTranscript()
{
- Console.Write($"{Bold}Save transcript to file? (y/n):{Reset} ");
- var answer = Console.ReadLine()?.Trim() ?? string.Empty;
- return answer.Equals("y", StringComparison.OrdinalIgnoreCase)
- || answer.Equals("yes", StringComparison.OrdinalIgnoreCase);
+ if (!Interactive) return true;
+ return AnsiConsole.Confirm("[bold]Save transcript to file?[/]", defaultValue: true);
}
/// Prints a success message with the saved file path.
public static void PrintFileSaved(string filePath)
{
- Console.WriteLine($" {Green}✓ Transcript saved to:{Reset} {filePath}");
- Console.WriteLine();
+ AnsiConsole.MarkupLine($"[green]✓[/] Transcript saved to: [blue]{Markup.Escape(filePath)}[/]");
+ AnsiConsole.WriteLine();
}
///
- /// Prompts the user to choose a summary mode.
- /// Returns the selected .
+ /// Prompts the user to choose a summary mode via an arrow-key selection list.
+ /// is listed first so it is the highlighted
+ /// default — pressing Enter accepts it.
///
public static SummaryMode PromptSummaryMode()
{
- Console.WriteLine($" {Dim}Summary modes:{Reset}");
- Console.WriteLine($" {Bold}1{Reset} – Standard (detailed bullet-point summary)");
- Console.WriteLine($" {Bold}2{Reset} – Personal Filter (relevance verdict: ACT / MONITOR / IGNORE)");
- Console.Write($"{Bold}Choose summary mode [1]:{Reset} ");
- var choice = Console.ReadLine()?.Trim() ?? string.Empty;
- return choice == "2" ? SummaryMode.PersonalFilter : SummaryMode.Standard;
+ if (!Interactive) return SummaryMode.Standard;
+ return AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("[bold]Choose summary mode:[/]")
+ .AddChoices(SummaryMode.Standard, SummaryMode.PersonalFilter)
+ .UseConverter(m => m switch
+ {
+ SummaryMode.Standard => "Standard – detailed bullet-point summary",
+ SummaryMode.PersonalFilter => "Personal Filter – relevance verdict (ACT / MONITOR / IGNORE)",
+ _ => m.ToString()
+ }));
}
- /// Displays a spinner-style "working" indicator while async work runs.
+ /// Prints a dim "working" indicator before an async step.
public static void PrintWorking(string message)
{
- Console.WriteLine($" {Dim}→ {message}...{Reset}");
+ AnsiConsole.MarkupLine($"[grey]→ {Markup.Escape(message)}...[/]");
}
///
@@ -85,113 +99,74 @@ public static class ConsoleRenderer
///
public static void PrintSummary(VideoSummary summary, bool showTranscriptSource)
{
- Console.WriteLine();
- PrintDivider();
+ AnsiConsole.WriteLine();
// ── Metadata header ──────────────────────────────────────────────────
- Console.WriteLine($"{Bold}{Green} {summary.Metadata.Title}{Reset}");
- Console.WriteLine($" {Dim}Channel:{Reset} {summary.Metadata.ChannelTitle}");
- Console.WriteLine($" {Dim}Published:{Reset} {summary.Metadata.PublishedAt:MMMM d, yyyy}");
- Console.WriteLine($" {Dim}Duration:{Reset} {summary.Metadata.FormattedDuration}");
- Console.WriteLine($" {Dim}URL:{Reset} https://youtu.be/{summary.Metadata.VideoId}");
+ var grid = new Grid();
+ grid.AddColumn(new GridColumn().PadRight(2));
+ grid.AddColumn();
+
+ grid.AddRow("[bold green]Title[/]", $"[bold]{Markup.Escape(summary.Metadata.Title)}[/]");
+ grid.AddRow("[grey]Channel[/]", Markup.Escape(summary.Metadata.ChannelTitle));
+ grid.AddRow("[grey]Published[/]", Markup.Escape(summary.Metadata.PublishedAt.ToString("MMMM d, yyyy")));
+ grid.AddRow("[grey]Duration[/]", Markup.Escape(summary.Metadata.FormattedDuration));
+ grid.AddRow("[grey]URL[/]", $"https://youtu.be/{Markup.Escape(summary.Metadata.VideoId)}");
- // ── Transcript source badge ──────────────────────────────────────────
if (showTranscriptSource)
{
var (badge, color) = summary.TranscriptSource switch
{
- TranscriptSource.OwnerPublished => ("✓ Owner-published captions", Green),
- TranscriptSource.CommunityContributed=> ("✓ Community captions", Green),
- TranscriptSource.AutoGenerated => ("~ Auto-generated (ASR)", Yellow),
- TranscriptSource.MetadataOnly => ("✗ Metadata only", Red),
- _ => ("? Unknown", Dim)
+ TranscriptSource.OwnerPublished => ("✓ Owner-published captions", "green"),
+ TranscriptSource.CommunityContributed => ("✓ Community captions", "green"),
+ TranscriptSource.AutoGenerated => ("~ Auto-generated (ASR)", "yellow"),
+ TranscriptSource.MetadataOnly => ("✗ Metadata only", "red"),
+ _ => ("? Unknown", "grey")
};
- Console.WriteLine($" {Dim}Transcript:{Reset} {color}{badge}{Reset}");
+ grid.AddRow("[grey]Transcript[/]", $"[{color}]{Markup.Escape(badge)}[/]");
}
- Console.WriteLine($" {Dim}Model:{Reset} {summary.ModelUsed}");
- Console.WriteLine($" {Dim}Generated:{Reset} {summary.GeneratedAt:yyyy-MM-dd HH:mm} UTC");
+ grid.AddRow("[grey]Model[/]", Markup.Escape(summary.ModelUsed));
+ grid.AddRow("[grey]Generated[/]", Markup.Escape(summary.GeneratedAt.ToString("yyyy-MM-dd HH:mm")) + " UTC");
- PrintDivider();
+ AnsiConsole.Write(grid);
+ AnsiConsole.WriteLine();
// ── Quality warning ──────────────────────────────────────────────────
if (summary.QualityWarning is not null)
{
- Console.WriteLine();
- Console.WriteLine($" {Yellow}{summary.QualityWarning}{Reset}");
+ AnsiConsole.Write(
+ new Panel($"[yellow]{Markup.Escape(summary.QualityWarning)}[/]")
+ {
+ Header = new PanelHeader("Quality warning"),
+ Border = BoxBorder.Rounded,
+ BorderStyle = new Style(Color.Yellow)
+ });
+ AnsiConsole.WriteLine();
}
// ── Summary body ─────────────────────────────────────────────────────
- Console.WriteLine();
- Console.WriteLine($"{Bold} SUMMARY{Reset}");
- Console.WriteLine();
+ AnsiConsole.Write(
+ new Panel(Markup.Escape(summary.SummaryText))
+ {
+ Header = new PanelHeader("Summary"),
+ Border = BoxBorder.Rounded,
+ Padding = new Padding(2, 1, 2, 1)
+ }.Expand());
- // Word-wrap the summary body at 80 characters so it's readable in
- // standard terminal widths without horizontal scrolling.
- foreach (var line in WordWrap(summary.SummaryText, maxWidth: 78))
- {
- Console.WriteLine($" {line}");
- }
-
- Console.WriteLine();
- PrintDivider();
- Console.WriteLine();
+ AnsiConsole.WriteLine();
}
/// Prints a styled error message.
public static void PrintError(string message)
{
- Console.WriteLine();
- Console.WriteLine($" {Red}✗ Error: {message}{Reset}");
- Console.WriteLine();
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine($"[red]✗ Error:[/] {Markup.Escape(message)}");
+ AnsiConsole.WriteLine();
}
/// Prints a styled warning (non-fatal).
public static void PrintWarning(string message)
{
- Console.WriteLine($" {Yellow}⚠ {message}{Reset}");
- }
-
- // ─────────────────────────────────────────────────────────────────────────
- // Private helpers
- // ─────────────────────────────────────────────────────────────────────────
-
- private static void PrintDivider()
- {
- Console.WriteLine($" {Dim}{"─".PadRight(74, '─')}{Reset}");
- }
-
- ///
- /// Splits text into lines no wider than characters,
- /// breaking only at word boundaries. Respects existing newlines in the input.
- ///
- private static IEnumerable WordWrap(string text, int maxWidth)
- {
- foreach (var paragraph in text.Split('\n'))
- {
- if (string.IsNullOrWhiteSpace(paragraph))
- {
- yield return string.Empty;
- continue;
- }
-
- var words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries);
- var current = new System.Text.StringBuilder();
-
- foreach (var word in words)
- {
- if (current.Length + word.Length + 1 > maxWidth)
- {
- yield return current.ToString();
- current.Clear();
- }
-
- if (current.Length > 0) current.Append(' ');
- current.Append(word);
- }
-
- if (current.Length > 0)
- yield return current.ToString();
- }
+ AnsiConsole.MarkupLine($"[yellow]⚠ {Markup.Escape(message)}[/]");
}
}
diff --git a/Program.cs b/Program.cs
index 84782df..fe0c7d3 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Spectre.Console;
using YoutubeSummarizer.Configuration;
using YoutubeSummarizer.Models;
using YoutubeSummarizer.Services;
@@ -51,7 +52,6 @@ var serviceProvider = services.BuildServiceProvider();
// ═════════════════════════════════════════════════════════════════════════════
// Main loop
// ═════════════════════════════════════════════════════════════════════════════
-
ConsoleRenderer.PrintBanner();
// Handle Ctrl+C gracefully so any in-progress API call can finish or cancel.
@@ -60,7 +60,7 @@ Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true; // prevent immediate termination
cts.Cancel();
- Console.WriteLine("\n Cancellation requested. Finishing current operation...");
+ AnsiConsole.MarkupLine("\n[yellow]Cancellation requested. Finishing current operation...[/]");
};
while (!cts.Token.IsCancellationRequested)
@@ -88,7 +88,7 @@ while (!cts.Token.IsCancellationRequested)
await ProcessVideoAsync(videoId, serviceProvider, appSettings.Summarizer, saveTranscript, summaryMode, cts.Token);
}
-Console.WriteLine(" Goodbye!");
+AnsiConsole.MarkupLine("[grey]Goodbye![/]");
// ═════════════════════════════════════════════════════════════════════════════
// Video processing pipeline
@@ -125,7 +125,7 @@ static async Task ProcessVideoAsync(
return;
}
- Console.WriteLine($" {metadata.Title}");
+ AnsiConsole.MarkupLine($" [bold]{Markup.Escape(metadata.Title)}[/]");
// ── Step 2: Transcript ────────────────────────────────────────────
ConsoleRenderer.PrintWorking("Fetching transcript");
@@ -134,15 +134,15 @@ static async Task ProcessVideoAsync(
// Optionally show raw transcript for debugging / inspection
if (summarizerSettings.ShowTranscript)
{
- Console.WriteLine();
- Console.WriteLine(" ─── RAW TRANSCRIPT ───");
- Console.WriteLine(transcript.Text);
- Console.WriteLine(" ─── END TRANSCRIPT ───");
- Console.WriteLine();
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Rule("RAW TRANSCRIPT").RuleStyle("grey"));
+ AnsiConsole.WriteLine(transcript.Text);
+ AnsiConsole.Write(new Rule("END TRANSCRIPT").RuleStyle("grey"));
+ AnsiConsole.WriteLine();
}
- Console.WriteLine(
- $" Transcript: {transcript.Source} | {transcript.WordCount:N0} words");
+ AnsiConsole.MarkupLine(
+ $" [grey]Transcript:[/] {transcript.Source} | {transcript.WordCount:N0} words");
// ── Step 2.5: Save transcript to file (if requested) ─────────────
// (moved after summarization so we can include the summary)
@@ -190,9 +190,10 @@ static async Task ProcessVideoAsync(
{
ConsoleRenderer.PrintError(ex.Message);
- // Print the stack trace in dim text for debugging without overwhelming
- // normal users who will rarely see this path.
- Console.WriteLine($"\x1b[2m{ex}\x1b[0m");
+ // Print the stack trace for debugging without overwhelming normal users
+ // who will rarely see this path.
+ AnsiConsole.WriteException(ex,
+ ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes);
}
}
@@ -217,11 +218,9 @@ static void ValidateSettings(AppSettings settings)
if (errors.Count > 0)
{
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine("\nConfiguration errors:");
- errors.ForEach(e => Console.WriteLine($" ✗ {e}"));
- Console.ResetColor();
- Console.WriteLine("\nCopy appsettings.example.json → appsettings.json and fill in your keys.\n");
+ AnsiConsole.MarkupLine("\n[red]Configuration errors:[/]");
+ errors.ForEach(e => AnsiConsole.MarkupLine($" [red]✗[/] {Markup.Escape(e)}"));
+ AnsiConsole.MarkupLine("\n[grey]Copy appsettings.example.json → appsettings.json and fill in your keys.[/]\n");
Environment.Exit(1);
}
}
diff --git a/SummarizerService.cs b/SummarizerService.cs
index 0177e75..3a92254 100644
--- a/SummarizerService.cs
+++ b/SummarizerService.cs
@@ -1,5 +1,6 @@
using OpenAI;
using OpenAI.Chat;
+using Spectre.Console;
using YoutubeSummarizer.Configuration;
using YoutubeSummarizer.Models;
@@ -189,14 +190,14 @@ public sealed class SummarizerService
var words = transcriptText.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var chunks = SplitIntoChunks(words, _summarizerSettings.ChunkWordLimit, overlapWords: 200);
- Console.WriteLine($"\n [Chunking] Transcript split into {chunks.Count} chunks for processing...");
+ AnsiConsole.MarkupLine($"[grey]Transcript split into {chunks.Count} chunks for processing…[/]");
// Map phase: summarize each chunk in sequence
// (Parallel would be faster but could hit rate limits — sequential is safer)
var chunkSummaries = new List(chunks.Count);
for (int i = 0; i < chunks.Count; i++)
{
- Console.Write($" [Chunk {i + 1}/{chunks.Count}] Summarizing");
+ AnsiConsole.MarkupLine($"[grey][[Chunk {i + 1}/{chunks.Count}]] summarizing…[/]");
var chunkText = string.Join(" ", chunks[i]);
var prompt = $"This is segment {i + 1} of {chunks.Count} from the video \"{metadata.Title}\":\n\n{chunkText}";
var summary = await CallChatCompletionAsync(chunkSystemPrompt, prompt, ct);
@@ -204,7 +205,7 @@ public sealed class SummarizerService
}
// Reduce phase: combine all chunk summaries into one coherent summary
- Console.Write(" [Combine] Merging chunk summaries into final summary");
+ AnsiConsole.MarkupLine("[grey][[Combine]] merging chunk summaries into final summary…[/]");
var combinedInput = string.Join("\n\n---\n\n",
chunkSummaries.Select((s, i) => $"Segment {i + 1} summary:\n{s}"));
@@ -243,34 +244,29 @@ public sealed class SummarizerService
try
{
- var streamingUpdates = _chatClient.CompleteChatStreamingAsync(messages, options, ct);
-
- await foreach (var update in streamingUpdates)
- {
- foreach (var part in update.ContentUpdate)
+ await AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .StartAsync("Waiting for model…", async ctx =>
{
- if (!string.IsNullOrEmpty(part.Text))
+ var streamingUpdates = _chatClient.CompleteChatStreamingAsync(messages, options, ct);
+
+ await foreach (var update in streamingUpdates)
{
- if (fullContent.Length == 0)
+ foreach (var part in update.ContentUpdate)
{
- // First token received!
- Console.Write(" (working)");
+ if (string.IsNullOrEmpty(part.Text)) continue;
+
+ fullContent.Append(part.Text);
+ ctx.Status(
+ $"Generating… {fullContent.Length:N0} chars ({sw.Elapsed.TotalSeconds:F0}s)");
}
-
- fullContent.Append(part.Text);
-
- // Show progress: print a dot every ~50 characters of output
- // or just periodically. For now, let's just do a dot every update
- // to show it's alive.
- if (fullContent.Length % 20 == 0) Console.Write(".");
}
- }
- }
+ });
}
finally
{
sw.Stop();
- Console.WriteLine($" Done! ({sw.Elapsed.TotalSeconds:F1}s)");
+ AnsiConsole.MarkupLine($"[green]✓[/] Done in {sw.Elapsed.TotalSeconds:F1}s");
}
return fullContent.ToString();
diff --git a/YoutubeSummarizer.csproj b/YoutubeSummarizer.csproj
index 1a34344..01e9738 100644
--- a/YoutubeSummarizer.csproj
+++ b/YoutubeSummarizer.csproj
@@ -31,6 +31,7 @@
+
\ No newline at end of file