noz-cli/NozCli/Repl/ReplInput.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

167 lines
6.6 KiB
C#

using Spectre.Console;
namespace NozCli.Repl;
/// Minimal readline with history + Tab-completion.
public class ReplInput
{
private readonly List<string> _history = [];
private int _histIdx = -1;
private readonly CommandRegistry _registry;
private ReplContext? _ctx;
public ReplInput(CommandRegistry registry) => _registry = registry;
public void SetContext(ReplContext ctx) => _ctx = ctx;
public string? Read(string prompt)
{
AnsiConsole.Markup(prompt);
var buffer = new List<char>();
int cursor = 0;
string? tab = null; // last Tab suggestion
int tabIdx = 0;
while (true)
{
var key = Console.ReadKey(intercept: true);
// ── Enter ─────────────────────────────────────────────────
if (key.Key == ConsoleKey.Enter)
{
Console.WriteLine();
var result = new string(buffer.ToArray());
if (!string.IsNullOrWhiteSpace(result))
{
_history.Add(result);
_histIdx = -1;
}
return result;
}
// ── Ctrl-C / Ctrl-D ───────────────────────────────────────
if (key.Key == ConsoleKey.C && key.Modifiers.HasFlag(ConsoleModifiers.Control))
{
Console.WriteLine();
return null;
}
// ── Backspace ─────────────────────────────────────────────
if (key.Key == ConsoleKey.Backspace)
{
if (cursor > 0)
{
buffer.RemoveAt(cursor - 1);
cursor--;
RedrawLine(prompt, buffer, cursor);
}
tab = null;
continue;
}
// ── Arrow up/down — history ───────────────────────────────
if (key.Key == ConsoleKey.UpArrow && _history.Count > 0)
{
_histIdx = Math.Clamp(_histIdx < 0 ? _history.Count - 1 : _histIdx - 1, 0, _history.Count - 1);
buffer = [.. _history[_histIdx]];
cursor = buffer.Count;
RedrawLine(prompt, buffer, cursor);
continue;
}
if (key.Key == ConsoleKey.DownArrow)
{
if (_histIdx >= 0 && _histIdx < _history.Count - 1)
{
_histIdx++;
buffer = [.. _history[_histIdx]];
}
else { _histIdx = -1; buffer.Clear(); }
cursor = buffer.Count;
RedrawLine(prompt, buffer, cursor);
continue;
}
// ── Arrow left/right — cursor move ────────────────────────
if (key.Key == ConsoleKey.LeftArrow && cursor > 0) { cursor--; MoveCursorTo(prompt, cursor); continue; }
if (key.Key == ConsoleKey.RightArrow && cursor < buffer.Count) { cursor++; MoveCursorTo(prompt, cursor); continue; }
// ── Tab — autocomplete ────────────────────────────────────
if (key.Key == ConsoleKey.Tab)
{
var suggestions = GetSuggestions(new string(buffer.ToArray()));
if (suggestions.Length == 0) continue;
if (tab is null) tabIdx = 0;
else tabIdx = (tabIdx + 1) % suggestions.Length;
tab = suggestions[tabIdx];
// Replace word after last space with suggestion
var parts = new string(buffer.ToArray()).Split(' ');
parts[^1] = tab;
var completed = string.Join(' ', parts);
buffer = [.. completed];
cursor = buffer.Count;
RedrawLine(prompt, buffer, cursor);
continue;
}
tab = null;
// ── Regular character ─────────────────────────────────────
if (!char.IsControl(key.KeyChar))
{
buffer.Insert(cursor, key.KeyChar);
cursor++;
RedrawLine(prompt, buffer, cursor);
}
}
}
private static void RedrawLine(string prompt, List<char> buffer, int cursor)
{
var col = Markup.Remove(prompt).Length;
Console.CursorLeft = 0;
Console.Write(new string(' ', Console.WindowWidth - 1));
Console.CursorLeft = 0;
AnsiConsole.Markup(prompt);
Console.Write(new string(buffer.ToArray()));
Console.CursorLeft = col + cursor;
}
private static void MoveCursorTo(string prompt, int cursor) =>
Console.CursorLeft = Markup.Remove(prompt).Length + cursor;
private string[] GetSuggestions(string input)
{
if (_ctx is null) return [];
var parts = input.TrimStart().Split(' ', StringSplitOptions.RemoveEmptyEntries);
var partial = parts.Length == 0 ? "" : parts[^1];
if (parts.Length <= 1)
return _registry.Names
.Where(n => n.StartsWith(partial, StringComparison.OrdinalIgnoreCase))
.Order().ToArray();
var cmd = parts[0].ToLowerInvariant();
var folders = _ctx.FoldersInView().Select(f => f + "/");
// cd only navigates into folders, never note slugs
if (cmd is "cd")
return folders
.Where(s => s.StartsWith(partial, StringComparison.OrdinalIgnoreCase))
.Distinct().Order().ToArray();
// For note commands, suggest notes visible at current depth (relative names)
var depth = _ctx.CurrentSubPath is null ? 0 : _ctx.CurrentSubPath.Split('/').Length;
var notes = _ctx.NotesInView()
.Where(n => _ctx.SlugRelative(n.Slug).Split('/').Length == depth + 1)
.Select(n => _ctx.SlugRelative(n.Slug).Split('/')[depth]);
return notes.Concat(folders)
.Where(s => s.StartsWith(partial, StringComparison.OrdinalIgnoreCase))
.Distinct().Order().ToArray();
}
}