From e962ba9575c3b621983598a0a93a00a1dc5b31c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 11 May 2026 14:41:11 +0200 Subject: [PATCH] refactor: simplify terminal prompt and enable brace insertion - Simplify prompt handling by relying on default markers. - Enable brace insertion in the terminal. - Optimize `lpEnvNames` by pre-allocating capacity. - Clean up and trim verbose comments across `Terminal.zig`. --- build.zig.zon | 8 ++-- src/agent/Agent.zig | 2 +- src/agent/Terminal.zig | 85 ++++++++++-------------------------------- src/browser/tools.zig | 3 +- 4 files changed, 26 insertions(+), 72 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index d0509464..5e4c46d3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -34,14 +34,14 @@ .url = "https://github.com/allyourcodebase/sqlite3/archive/8f840560eae88ab66668c6827c64ffbd0d74ef37.tar.gz", .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, - .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#fb7d356617dcf7559c2d47d56b73d9b082f81b9b", - .hash = "zenai-0.0.0-iOY_VOebAwA19L7IyQKx8CJRDbNwf75jN_nJ_F3OFD8m", - }, .libidn2 = .{ .url = "https://ftp.gnu.org/gnu/libidn/libidn2-2.3.8.tar.gz", .hash = "N-V-__8AABGOuAC_dhAN07kfoP4dycCFi8Bka4O-tuhriNH8", }, + .zenai = .{ + .url = "git+https://github.com/lightpanda-io/zenai.git#fb7d356617dcf7559c2d47d56b73d9b082f81b9b", + .hash = "zenai-0.0.0-iOY_VOebAwA19L7IyQKx8CJRDbNwf75jN_nJ_F3OFD8m", + }, .isocline = .{ .url = "https://github.com/daanx/isocline/archive/refs/tags/v1.1.0.tar.gz", .hash = "N-V-__8AAAO9EgA-gjVR9Uf87fpOT--TBHVEvafXa3yRa0HY", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index c447019e..52d081b9 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -300,7 +300,7 @@ fn runRepl(self: *Self) void { } repl: while (true) { - const line = self.terminal.readLine("> ") orelse break; + const line = self.terminal.readLine("") orelse break; defer self.terminal.freeLine(line); if (line.len == 0) continue; diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 01b2b43f..43876c5a 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -71,10 +71,9 @@ const all_slash_names: [browser_tools.tool_defs.len + meta_slash_commands.len][] break :blk names; }; -/// Stores the schemas on the Terminal and (re-)registers isocline's -/// completer with `self` as user-data so the callback can reach them via -/// `ic_completion_arg`. Called from Agent.zig after the Terminal is in its -/// final memory location. +/// Registers isocline's completer with `self` as user-data, so the C +/// callback can reach `slash_schemas` via `ic_completion_arg`. Must run +/// after the Terminal is in its final memory location. pub fn setSlashSchemas(self: *Self, schemas: []const SlashCommand.SchemaInfo) void { self.slash_schemas = schemas; c.ic_set_default_completer(&completionCallback, self); @@ -84,21 +83,10 @@ pub fn init(allocator: std.mem.Allocator, history_path: ?[:0]const u8, verbosity _ = c.ic_enable_multiline(true); _ = c.ic_enable_hint(true); _ = c.ic_enable_inline_help(true); - // Default is 400ms; match linenoise's instant ghost-suffix behavior so - // users see the inline preview as they type without a noticeable pause. + // Match linenoise's instant ghost behavior; default is 400 ms. _ = c.ic_set_hint_delay(0); - // Disable automatic brace/quote insertion — selectors quoted with ' or " - // are common in PandaScript and auto-inserting closers gets in the user's - // way more than it helps. - _ = c.ic_enable_brace_insertion(false); - // Clear isocline's default `> ` prompt marker so the prompt text we pass - // to ic_readline renders verbatim; the agent already supplies its own - // `> ` prefix. - c.ic_set_prompt_marker("", ""); - // PandaScript syntax highlighting. Names are namespaced `ps-*` so users - // (or a future theme system) can override via `ic_style_def` without - // colliding with isocline's built-in `ic-*` styles. Bold/underline are - // intentionally restrained — the prompt is meant to read, not glow. + _ = c.ic_enable_brace_insertion(true); + // `ps-*` namespace avoids colliding with isocline's built-in `ic-*` styles. c.ic_style_def("ps-cmd", "ansi-cyan bold"); c.ic_style_def("ps-slash", "ansi-magenta bold"); c.ic_style_def("ps-string", "ansi-green"); @@ -110,9 +98,7 @@ pub fn init(allocator: std.mem.Allocator, history_path: ?[:0]const u8, verbosity _ = c.ic_enable_highlight(true); c.ic_set_default_highlighter(&highlighterCallback, null); if (history_path) |path| { - // -1 → default cap (200 entries). Passing a filename makes isocline - // load existing entries and auto-persist additions. - c.ic_set_history(path.ptr, -1); + c.ic_set_history(path.ptr, -1); // -1 → 200-entry default cap } const stderr_is_tty = std.posix.isatty(std.posix.STDERR_FILENO); return .{ @@ -176,13 +162,8 @@ fn formatBulletLine(arena: std.mem.Allocator, name: []const u8, args: []const u8 return aw.written(); } -// Bound on the largest single completion text we synthesize. The longest -// real case is a multi-slot schema hint glued onto a 64-char input. const completion_buf_len = 512; -// Synthesizes a completion that, when accepted, replaces the user's entire -// current input (`input`) with `prefix ++ name ++ suffix`. The inline hint -// shown before Tab is just the trailing portion past `input.len`. fn addPrefixedCompletion( cenv: ?*c.ic_completion_env_t, buf: *[completion_buf_len:0]u8, @@ -260,8 +241,6 @@ fn analyzeBody(schema: *const SlashCommand.SchemaInfo, body: []const u8, ends_ws const help_arg_prefix = "/help "; -// Returns the trailing argument when `input` is `/help ` with no -// further whitespace; null otherwise (e.g. `/help foo bar`). fn parseHelpArgPrefix(input: []const u8) ?[]const u8 { if (!std.ascii.startsWithIgnoreCase(input, help_arg_prefix)) return null; const arg = std.mem.trimLeft(u8, input[help_arg_prefix.len..], " "); @@ -290,11 +269,7 @@ fn addPartialKeyCompletions( } } -// Offers `$LP_*` completions when the user is mid-typing a `$VAR` token. -// Triggers wherever a `$` appears with only name characters following it, so -// it works in PandaScript args (`TYPE '#u' $LP_`), slash values -// (`/click value=$L`), and bare prefixes (`$L`). Names come from the same -// source as the `getEnv` tool — `std.os.environ` filtered to LP_*. +// Completes `$LP_*` against the live process environment. fn addEnvVarCompletions( cenv: ?*c.ic_completion_env_t, buf: *[completion_buf_len:0]u8, @@ -306,9 +281,7 @@ fn addEnvVarCompletions( if (!std.ascii.isAlphanumeric(ch) and ch != '_') return; } - // Stack-only scratch for the env-name list. 16 KiB holds ~1000 names' - // worth of pointer metadata (names themselves point into std.os.environ - // and aren't copied) — far more than any realistic environment. + // Names are slices into std.os.environ; only pointer metadata is allocated. var stack: [16 * 1024]u8 = undefined; var fba: std.heap.FixedBufferAllocator = .init(&stack); const names = browser_tools.lpEnvNames(fba.allocator()) catch return; @@ -326,12 +299,9 @@ fn completionCallback(cenv: ?*c.ic_completion_env_t, prefix: [*c]const u8) callc const self_ptr = c.ic_completion_arg(cenv) orelse return; const self: *Self = @ptrCast(@alignCast(self_ptr)); - // Per-call scratch buffer for synthesized completion strings. Isocline - // copies the string internally so reuse across candidates is fine. var buf: [completion_buf_len:0]u8 = undefined; - // `/help ` — the arg is itself a tool name, not a value, so env-var - // completion would be confusing here. Short-circuit. + // `/help `: arg is a tool name, not a value — skip env-var fallthrough. if (parseHelpArgPrefix(input)) |partial| { for (all_slash_names) |name| addPrefixedCompletion(cenv, &buf, input, help_arg_prefix, name, "", partial); return; @@ -347,17 +317,15 @@ fn completionCallback(cenv: ?*c.ic_completion_env_t, prefix: [*c]const u8) callc addPartialKeyCompletions(cenv, input, parts.rest, schema, &buf); } } - // Fall through so `value=$LP_` etc. picks up env completions. + // Fall through so `value=$LP_` picks up env completions. } else { const partial = input[1..]; for (all_slash_names) |name| addPrefixedCompletion(cenv, &buf, input, "/", name, "", partial); return; } } else if (!has_space) { - // Case-insensitive on the completion side so Tab also rewrites - // mistyped lowercase (`goto` → `GOTO`). The highlighter stays - // case-sensitive, so a lowercase-typed line reads as natural - // language until the user accepts the completion. + // Case-insensitive here so Tab also rewrites mistyped lowercase + // (`goto` → `GOTO`); the highlighter stays case-sensitive. for (commands) |cmd| { if (std.ascii.startsWithIgnoreCase(cmd.name, input)) { const text = std.fmt.bufPrintZ(&buf, "{s}", .{cmd.name}) catch continue; @@ -369,9 +337,7 @@ fn completionCallback(cenv: ?*c.ic_completion_env_t, prefix: [*c]const u8) callc addEnvVarCompletions(cenv, &buf, input); } -// PandaScript syntax highlighter. Invoked by isocline on every input change; -// keep it cheap. The `pos` and `count` passed to `ic_highlight` are byte -// offsets/lengths into `input`, not UTF-8 code points — fine here because we +// Byte offsets to ic_highlight are not UTF-8 code points; safe because we // only tokenize on ASCII boundaries (whitespace, quotes, `=`, `$`). fn highlighterCallback(henv: ?*c.ic_highlight_env_t, input: [*c]const u8, _: ?*anyopaque) callconv(.c) void { const text = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(input)), 0); @@ -381,21 +347,15 @@ fn highlighterCallback(henv: ?*c.ic_highlight_env_t, input: [*c]const u8, _: ?*a while (i < text.len and std.ascii.isWhitespace(text[i])) i += 1; if (i >= text.len) return; - // First word: either `/slash` form or a bare PandaScript command name. - // Unknown leading tokens get highlighted as errors so typos are visible - // before the user hits Enter. const cmd_start = i; while (i < text.len and !std.ascii.isWhitespace(text[i])) i += 1; const cmd = text[cmd_start..i]; if (cmd.len > 0 and cmd[0] == '/') { - const name = cmd[1..]; - const style = if (isKnownSlashName(name)) "ps-slash" else "ps-err"; + const style = if (isKnownSlashName(cmd[1..])) "ps-slash" else "ps-err"; c.ic_highlight(henv, @intCast(cmd_start), @intCast(cmd.len), style.ptr); highlightSlashArgs(henv, text, i); } else { - // PandaScript commands are ALL CAPS. Known → keyword color. ALL CAPS - // but unknown → red (likely typo). Anything else → no highlight, - // it's a natural-language query for the LLM. + // ALL CAPS but unknown → typo (red); lowercase/mixed → natural language (unstyled). const style: ?[*:0]const u8 = if (isKnownCommand(cmd)) "ps-cmd" else if (isAllUpper(cmd)) @@ -429,9 +389,6 @@ fn isKnownSlashName(name: []const u8) bool { return false; } -// Color a non-quoted token based on its leading character: `$` → variable, -// `http(s)://` → URL, digits → number. Anything else falls through with no -// highlight (lets the terminal's default foreground show through). fn highlightBareToken(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize, end: usize) void { if (start >= end) return; const tok = text[start..end]; @@ -453,9 +410,7 @@ fn highlightBareToken(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usi } } -// Consume a quoted token (single or double) at `start`, returning the index -// just past the closing quote. Handles backslash escapes minimally — enough -// not to confuse `\'` inside a single-quoted string. +// Backslash escapes are recognized just enough to skip `\'` inside a quoted string. fn scanQuoted(text: []const u8, start: usize) usize { if (start >= text.len) return start; const quote = text[start]; @@ -495,7 +450,7 @@ fn highlightSlashArgs(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usi const key_end = i; if (i < text.len and text[i] == '=') { c.ic_highlight(henv, @intCast(tok_start), @intCast(key_end - tok_start), "ps-key".ptr); - i += 1; // consume '=' + i += 1; const val_start = i; if (i < text.len and (text[i] == '\'' or text[i] == '"')) { i = scanQuoted(text, i); @@ -505,13 +460,11 @@ fn highlightSlashArgs(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usi highlightBareToken(henv, text, val_start, i); } } - // bare positional (no `=`) — leave unstyled. } } pub fn readLine(_: *Self, prompt: [*:0]const u8) ?[]const u8 { - // Isocline auto-adds the returned line to history and auto-persists when - // a history file was set via `ic_set_history`. + // Isocline auto-appends the line to its (optionally-persisted) history. const line = c.ic_readline(prompt) orelse return null; return std.mem.sliceTo(line, 0); } diff --git a/src/browser/tools.zig b/src/browser/tools.zig index acead669..585e85ac 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -942,12 +942,13 @@ fn listLpEnvNames(arena: std.mem.Allocator) ToolError![]const u8 { /// source as the `getEnv` tool (no-name variant), just unformatted. pub fn lpEnvNames(arena: std.mem.Allocator) error{OutOfMemory}![]const []const u8 { var names: std.ArrayList([]const u8) = .empty; + try names.ensureTotalCapacity(arena, std.os.environ.len); for (std.os.environ) |entry| { const line = std.mem.span(entry); const eq_idx = std.mem.indexOfScalar(u8, line, '=') orelse continue; const name = line[0..eq_idx]; if (!std.ascii.startsWithIgnoreCase(name, "LP_")) continue; - try names.append(arena, name); + names.appendAssumeCapacity(name); } std.mem.sort([]const u8, names.items, {}, lpNameLessThan); return names.items;