summarizer/ConsoleRenderer.cs
Clinton Billedeaux cea352b576
Add local document summarization and custom prompt mode
Summarize local PDF, Word, and plain text files via a new DocumentService
(PdfPig + DocumentFormat.OpenXml), and add a Custom summary mode that uses
the user's own instructions as the system prompt. Update the console UI,
transcript saving, and README to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:56:57 -05:00

220 lines
8.9 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Spectre.Console;
using YoutubeSummarizer.Models;
namespace YoutubeSummarizer.Services;
/// <summary>
/// Handles all console output formatting.
/// 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.
/// </summary>
public static class ConsoleRenderer
{
/// <summary>
/// Sets the terminal window/tab title. Silently no-ops on terminals that
/// don't support the escape sequence.
/// </summary>
public static void SetTitle(string title)
{
try { Console.Title = title; }
catch { /* unsupported terminal — ignore */ }
}
/// <summary>Prints the application banner on startup.</summary>
public static void PrintBanner()
{
SetTitle("Summarize");
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();
}
/// <summary>
/// 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.
/// </summary>
private static bool Interactive => AnsiConsole.Profile.Capabilities.Interactive;
/// <summary>Prompts the user for a URL and reads input.</summary>
public static string PromptForUrl()
{
// Revert the title while waiting for the next video.
SetTitle("Summarize");
const string label = "[bold]Enter a YouTube URL or a local file path[/] [grey](or 'q' to quit)[/]";
if (!Interactive)
{
AnsiConsole.Markup($"{label}: ");
return Console.ReadLine()?.Trim() ?? string.Empty;
}
var input = AnsiConsole.Prompt(new TextPrompt<string>($"{label}:").AllowEmpty());
return input.Trim();
}
/// <summary>
/// Asks the user whether they want to save the transcript to a text file.
/// Defaults to <c>true</c> — pressing Enter accepts.
/// </summary>
public static bool PromptSaveTranscript()
{
if (!Interactive) return true;
return AnsiConsole.Confirm("[bold]Save transcript to file?[/]", defaultValue: true);
}
/// <summary>Prints a success message with the saved file path.</summary>
public static void PrintFileSaved(string filePath)
{
AnsiConsole.MarkupLine($"[green]✓[/] Transcript saved to: [blue]{Markup.Escape(filePath)}[/]");
AnsiConsole.WriteLine();
}
/// <summary>
/// Prompts the user to choose a summary mode via an arrow-key selection list.
/// <see cref="SummaryMode.Standard"/> is listed first so it is the highlighted
/// default — pressing Enter accepts it.
/// </summary>
public static SummaryMode PromptSummaryMode()
{
if (!Interactive) return SummaryMode.Standard;
return AnsiConsole.Prompt(
new SelectionPrompt<SummaryMode>()
.Title("[bold]Choose summary mode:[/]")
.AddChoices(SummaryMode.Standard, SummaryMode.PersonalFilter, SummaryMode.Custom)
.UseConverter(m => m switch
{
SummaryMode.Standard => "Standard detailed bullet-point summary",
SummaryMode.PersonalFilter => "Personal Filter relevance verdict (ACT / MONITOR / IGNORE)",
SummaryMode.Custom => "Custom write your own instructions",
_ => m.ToString()
}));
}
/// <summary>
/// Reads multi-line custom summarization instructions from the user,
/// terminated by a blank line. Spectre has no built-in multi-line text
/// prompt, so this reads raw lines directly (still styled with a Spectre
/// header) and joins them with newlines.
/// </summary>
public static string PromptCustomInstructions()
{
AnsiConsole.MarkupLine("[bold]Enter your custom instructions[/] [grey](finish with an empty line):[/]");
var lines = new List<string>();
string? line;
while (!string.IsNullOrEmpty(line = Console.ReadLine()))
{
lines.Add(line);
}
return string.Join('\n', lines).Trim();
}
/// <summary>Prints a dim "working" indicator before an async step.</summary>
public static void PrintWorking(string message)
{
AnsiConsole.MarkupLine($"[grey]→ {Markup.Escape(message)}...[/]");
}
/// <summary>
/// Renders the full summary result to the console in a structured,
/// readable format. Includes metadata header, quality warning, and
/// the summary body.
/// </summary>
public static void PrintSummary(VideoSummary summary, bool showTranscriptSource)
{
AnsiConsole.WriteLine();
var isDocument = summary.TranscriptSource == TranscriptSource.LocalDocument;
// ── Metadata header ──────────────────────────────────────────────────
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(isDocument ? "[grey]Type[/]" : "[grey]Channel[/]", Markup.Escape(summary.Metadata.ChannelTitle));
grid.AddRow(
isDocument ? "[grey]Modified[/]" : "[grey]Published[/]",
Markup.Escape(summary.Metadata.PublishedAt.ToString("MMMM d, yyyy")));
if (summary.Metadata.Duration is not null)
grid.AddRow("[grey]Duration[/]", Markup.Escape(summary.Metadata.FormattedDuration));
grid.AddRow(
isDocument ? "[grey]Location[/]" : "[grey]URL[/]",
isDocument
? Markup.Escape(summary.Metadata.SourcePath ?? summary.Metadata.Title)
: $"https://youtu.be/{Markup.Escape(summary.Metadata.VideoId)}");
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"),
TranscriptSource.LocalDocument => ("✓ Local document", "green"),
_ => ("? Unknown", "grey")
};
grid.AddRow("[grey]Transcript[/]", $"[{color}]{Markup.Escape(badge)}[/]");
}
grid.AddRow("[grey]Model[/]", Markup.Escape(summary.ModelUsed));
grid.AddRow("[grey]Generated[/]", Markup.Escape(summary.GeneratedAt.ToString("yyyy-MM-dd HH:mm")) + " UTC");
AnsiConsole.Write(grid);
AnsiConsole.WriteLine();
// ── Quality warning ──────────────────────────────────────────────────
if (summary.QualityWarning is not null)
{
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 ─────────────────────────────────────────────────────
AnsiConsole.Write(
new Panel(Markup.Escape(summary.SummaryText))
{
Header = new PanelHeader("Summary"),
Border = BoxBorder.Rounded,
Padding = new Padding(2, 1, 2, 1)
}.Expand());
AnsiConsole.WriteLine();
}
/// <summary>Prints a styled error message.</summary>
public static void PrintError(string message)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[red]✗ Error:[/] {Markup.Escape(message)}");
AnsiConsole.WriteLine();
}
/// <summary>Prints a styled warning (non-fatal).</summary>
public static void PrintWarning(string message)
{
AnsiConsole.MarkupLine($"[yellow]⚠ {Markup.Escape(message)}[/]");
}
}