noz-cli/NozCli/Repl/ReplSession.cs
kryptomrx 47cc904609 feat(repl): Visual banner, JSON note cache, background refresh, Ctrl+C handling
- Startup banner: FigletText + capabilities panel + note stats with status dots
- JSON cache at ~/.cache/noz/notes.json for instant startup
- Cache-first loading: show REPL immediately, refresh notes in background
- Timestamp in stats line (e.g. 'vor 5 Min.') for data freshness feedback
- Ctrl+C in idle exits REPL gracefully; during command cancels only the request
- Per-command CancellationTokenSource so cancel does not kill the session
- Tab completion: command-aware (cd only suggests folders)
2026-05-16 23:20:39 +02:00

183 lines
6.4 KiB
C#

using NozCli.Core.Client;
using NozCli.Core.Config;
using NozCli.Core.Models;
using Spectre.Console;
namespace NozCli.Repl;
public static class ReplSession
{
public static async Task RunAsync(LocalConfig config)
{
using var api = new NozApiClient(config);
using var cts = new CancellationTokenSource();
// isIdle = true → Ctrl+C exits the REPL gracefully
// isIdle = false → Ctrl+C cancels only the running command
var isIdle = false;
CancellationTokenSource? currentCmdCts = null;
Console.CancelKeyPress += (_, e) =>
{
if (isIdle)
{
AnsiConsole.MarkupLine("\n[dim]Bye.[/]");
e.Cancel = false; // let the process exit
}
else
{
e.Cancel = true; // keep process alive
currentCmdCts?.Cancel();
}
};
ServerCapabilities caps = default!;
await AnsiConsole.Status().StartAsync("[dim]connecting…[/]", async _ =>
caps = await api.GetConfigAsync(cts.Token));
RenderBanner(caps, config.ServerUrl);
var ctx = new ReplContext(api, config);
var registry = new CommandRegistry();
var input = new ReplInput(registry);
// Cache-first startup: show REPL immediately, refresh in background
var cached = NotesCache.TryLoad();
if (cached is not null)
{
ctx.NoteCache = cached.Notes;
RenderNoteStats(ctx.NoteCache, cached.LastSynced);
}
else
{
await AnsiConsole.Status().StartAsync("[dim]loading notes…[/]", async _ =>
{
ctx.NoteCache = await api.GetNotesAsync(cts.Token);
await NotesCache.SaveAsync(ctx.NoteCache);
});
RenderNoteStats(ctx.NoteCache, null);
}
input.SetContext(ctx);
AnsiConsole.MarkupLine("[dim]type [white]help[/] to see commands · [white]exit[/] to quit[/]\n");
// Background refresh — always fetch fresh data after REPL is ready
_ = Task.Run(async () =>
{
try
{
var fresh = await api.GetNotesAsync(cts.Token);
var diff = fresh.Count - ctx.NoteCache.Count;
ctx.NoteCache = fresh;
await NotesCache.SaveAsync(fresh);
if (diff != 0)
{
var sign = diff > 0 ? $"+{diff}" : $"{diff}";
AnsiConsole.MarkupLine($"\n[dim]↻ notes refreshed ({sign})[/]");
}
}
catch { /* background refresh failure is silent */ }
}, cts.Token);
while (ctx.Running)
{
using var cmdCts = new CancellationTokenSource();
currentCmdCts = cmdCts;
var prompt = $"[bold cyan]{ctx.CurrentPath}[/] [dim]»[/] ";
isIdle = true;
var line = input.Read(prompt);
isIdle = false;
if (line is null) { AnsiConsole.MarkupLine("[dim]Bye.[/]"); break; }
var parts = line.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) continue;
var name = parts[0];
var args = parts[1..];
if (name is "exit" or "quit" or "q")
{
AnsiConsole.MarkupLine("[dim]Bye.[/]");
break;
}
var cmd = registry.Find(name);
if (cmd is null)
{
AnsiConsole.MarkupLine($"[red]Unknown command:[/] {name} [dim](type 'help')[/]");
continue;
}
try
{
await cmd.ExecuteAsync(args, ctx, cmdCts.Token);
}
catch (OperationCanceledException)
{
AnsiConsole.MarkupLine("[yellow]Cancelled.[/]");
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
AnsiConsole.MarkupLine("[red]Unauthorized.[/] Run [cyan]noz init <url> <token>[/] to update your token.");
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
AnsiConsole.MarkupLine("[red]Forbidden.[/] This operation is disabled on the server (check noosphere.toml).");
}
catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Server error:[/] {ex.Message}"); }
catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); }
}
}
private static void RenderBanner(ServerCapabilities caps, string serverUrl)
{
AnsiConsole.WriteLine();
AnsiConsole.Write(new FigletText("noz")
.LeftJustified()
.Color(new Color(99, 179, 237))); // sky-400
var host = Uri.TryCreate(serverUrl, UriKind.Absolute, out var u) ? u.Host : serverUrl;
var ver = VersionInfo.Full;
var capGrid = new Grid().AddColumns(3);
capGrid.AddRow(
Cap("push", caps.AllowPush),
Cap("delete", caps.AllowDelete),
Cap("vault", caps.AllowVault)
);
var panel = new Panel(capGrid)
{
Header = new PanelHeader($"[dim] {host} · v{ver} [/]"),
Border = BoxBorder.Rounded,
BorderStyle = new Style(new Color(80, 80, 100)),
Padding = new Padding(1, 0),
};
AnsiConsole.Write(panel);
}
private static void RenderNoteStats(List<NoteInfo> notes, DateTimeOffset? lastSynced)
{
var seed = notes.Count(n => n.Status == "seed");
var budding = notes.Count(n => n.Status == "budding");
var evergreen = notes.Count(n => n.Status == "evergreen");
var age = lastSynced is null ? "" : $" · [dim grey]{NotesCache.TimeAgo(lastSynced.Value)}[/]";
var rule = new Rule($"[dim] {notes.Count} notes · [yellow]◦ {seed}[/] [magenta]◈ {budding}[/] [cyan]◉ {evergreen}[/]{age} [/]")
{
Style = new Style(new Color(60, 60, 80)),
Justification = Justify.Left,
};
AnsiConsole.Write(rule);
AnsiConsole.WriteLine();
}
private static Markup Cap(string label, bool on) =>
new(on
? $"[green]✓[/] [dim]{label}[/] "
: $"[dim red]✗[/] [dim]{label}[/] ");
}