using Spectre.Console; namespace NozCli.Repl; /// Minimal readline with history + Tab-completion. public class ReplInput { private readonly List _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(); 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 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(); } }