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 [/] 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(); // Orbital logo — nucleus (accent) + tilted orbit ring AnsiConsole.MarkupLine(" [dim]· ─ ·[/]"); AnsiConsole.MarkupLine(" [dim]·[/] [dim]·[/]"); AnsiConsole.MarkupLine(" [dim]·[/] [bold dodgerblue2]◉[/] [dim]·[/] [bold cornflowerblue]noz[/]"); AnsiConsole.MarkupLine(" [dim]·[/] [dim]·[/]"); AnsiConsole.MarkupLine(" [dim]· ─ ·[/]"); AnsiConsole.WriteLine(); 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 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}[/] "); }