From 7623ece18097422f3de3ad9f6422eb4e2a717037 Mon Sep 17 00:00:00 2001 From: null3FF3KT Date: Fri, 28 Aug 2026 22:23:56 -0500 Subject: [PATCH] change model and UI library to Spectre.Console. --- ConsoleRenderer.cs | 205 +++++++++++++++++---------------------- Program.cs | 37 ++++--- SummarizerService.cs | 40 ++++---- YoutubeSummarizer.csproj | 1 + 4 files changed, 127 insertions(+), 156 deletions(-) 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 -- 2.43.7