mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-30 08:24:51 -04:00
- attachCompleter now takes the CompletionSource: Agent no longer reaches through terminal.assist to configure what it just attached, and attach/configure can't be sequenced wrong. - history_paths flows through one channel: attach() loads the initial history from the same State field modeCallback swaps from, instead of setupRepl receiving a second copy that had to agree. - all_names + closestCommand move to SlashCommand.zig, the command registry - prompt_assist consumes it like everyone else and Terminal's closestCommand re-export disappears. - picker treats a tty that refuses raw mode as NotInteractive and degrades to the line prompt instead of leaking termios errnos to callers that can only blanket-catch. - kitty keyboard flags become named ansi.zig constants shared by Terminal.readLine and picker's RawTerminal. - skipWhitespace was std.mem.indexOfNonePos; single-use style consts inline into the style table; drop the dead CompletionSource default and doc-comment splice in Spinner.
138 lines
6.1 KiB
Zig
138 lines
6.1 KiB
Zig
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
|
//
|
|
// Francis Bouvier <francis@lightpanda.io>
|
|
// Pierre Tachoire <pierre@lightpanda.io>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
//! REPL-only meta slash commands (`/help`, `/quit`, `/verbosity`, `/effort`,
|
|
//! `/stream`, `/usage`, `/model`, `/provider`). Not tool slash commands — handled by
|
|
//! `Agent.handleMeta`, never reaching the recorder. Tool slash-command schema
|
|
//! primitives live in `lp.Schema`; import that directly.
|
|
|
|
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
const Command = lp.Command;
|
|
const Config = lp.Config;
|
|
|
|
/// Row format for the `/help` listing — `name` carries no leading `/`.
|
|
pub const Help = struct {
|
|
name: []const u8,
|
|
description: []const u8,
|
|
};
|
|
|
|
pub const MetaCommand = struct {
|
|
tag: Tag,
|
|
name: [:0]const u8,
|
|
/// Ghost-text fragment shown after the name + space. Empty when the command
|
|
/// takes no args (`/help`, `/quit`).
|
|
hint: []const u8,
|
|
/// Tab-completion candidates for the first positional arg.
|
|
values: []const []const u8,
|
|
/// Terse one-liner for the `/help` listing; longer per-command detail is
|
|
/// rendered by `Agent.printSlashHelp`.
|
|
description: []const u8,
|
|
|
|
/// Dispatched by `Agent.handleMeta` via an exhaustive switch, so a new meta
|
|
/// command is a compile error until it's wired up there too.
|
|
const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider };
|
|
};
|
|
|
|
const tagNames = Config.tagNames;
|
|
const tagHint = Config.tagHint;
|
|
|
|
pub const meta_commands = [_]MetaCommand{
|
|
.{ .tag = .help, .name = "help", .hint = "[command]", .values = &.{}, .description = "List commands, or show help for one" },
|
|
.{ .tag = .quit, .name = "quit", .hint = "", .values = &.{}, .description = "Exit the REPL" },
|
|
.{ .tag = .verbosity, .name = "verbosity", .hint = tagHint(Config.AgentVerbosity), .values = tagNames(Config.AgentVerbosity), .description = "Set agent verbosity" },
|
|
.{ .tag = .effort, .name = "effort", .hint = tagHint(Config.Effort), .values = tagNames(Config.Effort), .description = "Set per-turn reasoning effort" },
|
|
.{ .tag = .stream, .name = "stream", .hint = "[on|off]", .values = &.{ "on", "off" }, .description = "Toggle streaming of assistant text" },
|
|
.{ .tag = .usage, .name = "usage", .hint = "", .values = &.{}, .description = "Show token usage and cache stats for this session" },
|
|
.{ .tag = .clear, .name = "clear", .hint = "", .values = &.{}, .description = "Clear conversation history and usage (keeps page/cookies)" },
|
|
.{ .tag = .reset, .name = "reset", .hint = "", .values = &.{}, .description = "Reset conversation and browser session (drops page/cookies)" },
|
|
.{ .tag = .save, .name = "save", .hint = "[filename.js] [prompt]", .values = &.{}, .description = "Save this session to a file" },
|
|
.{ .tag = .load, .name = "load", .hint = "<path>", .values = &.{}, .description = "Load and run a script from disk" },
|
|
.{ .tag = .model, .name = "model", .hint = "[name]", .values = &.{}, .description = "Change the model" },
|
|
.{ .tag = .provider, .name = "provider", .hint = "[name|null]", .values = &.{}, .description = "Change the provider, or 'null' to disable the LLM" },
|
|
};
|
|
|
|
/// Derived from `Command.LlmCommand` — name and description both come from the
|
|
/// enum, so a new trigger there surfaces here automatically.
|
|
pub const llm_commands = blk: {
|
|
const values = std.enums.values(Command.LlmCommand);
|
|
var rows: [values.len]Help = undefined;
|
|
for (values, &rows) |lc, *row| row.* = .{ .name = @tagName(lc), .description = lc.description() };
|
|
break :blk rows;
|
|
};
|
|
|
|
pub fn findMeta(name: []const u8) ?*const MetaCommand {
|
|
for (&meta_commands) |*m| {
|
|
if (std.ascii.eqlIgnoreCase(m.name, name)) return m;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const browser_tools = lp.tools;
|
|
const llm_values = std.enums.values(Command.LlmCommand);
|
|
|
|
/// Every slash-invocable name: browser tools, LLM triggers, meta commands.
|
|
pub const all_names: [browser_tools.names.len + meta_commands.len + llm_values.len][]const u8 = blk: {
|
|
var arr: [browser_tools.names.len + meta_commands.len + llm_values.len][]const u8 = undefined;
|
|
var idx: usize = 0;
|
|
for (browser_tools.names) |n| {
|
|
arr[idx] = n;
|
|
idx += 1;
|
|
}
|
|
for (llm_values) |lc| {
|
|
arr[idx] = @tagName(lc);
|
|
idx += 1;
|
|
}
|
|
for (meta_commands) |m| {
|
|
arr[idx] = m.name;
|
|
idx += 1;
|
|
}
|
|
break :blk arr;
|
|
};
|
|
|
|
/// Closest command name within two edits, or null — for "did you mean?" on typos.
|
|
pub fn closestCommand(name: []const u8) ?[]const u8 {
|
|
var best: ?[]const u8 = null;
|
|
var best_dist: usize = std.math.maxInt(usize);
|
|
for (all_names) |cand| {
|
|
const dist = editDistance(name, cand);
|
|
if (dist < best_dist) {
|
|
best_dist = dist;
|
|
best = cand;
|
|
}
|
|
}
|
|
return if (best_dist <= 2) best else null;
|
|
}
|
|
|
|
/// Case-insensitive Levenshtein distance. Returns `maxInt` for inputs longer
|
|
/// than the table (no slash command is that long).
|
|
fn editDistance(a: []const u8, b: []const u8) usize {
|
|
const max = 32;
|
|
if (a.len >= max or b.len >= max) return std.math.maxInt(usize);
|
|
var dp: [max][max]usize = undefined;
|
|
for (0..a.len + 1) |i| dp[i][0] = i;
|
|
for (0..b.len + 1) |j| dp[0][j] = j;
|
|
for (a, 1..) |ca, i| {
|
|
for (b, 1..) |cb, j| {
|
|
const cost: usize = if (std.ascii.toLower(ca) == std.ascii.toLower(cb)) 0 else 1;
|
|
dp[i][j] = @min(@min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
|
|
}
|
|
}
|
|
return dp[a.len][b.len];
|
|
}
|