diff options
| author | t <t@tjp.lol> | 2026-06-02 15:00:44 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-02 16:37:32 -0600 |
| commit | 8b88b886346460b1ab29edb03ad85bc3bb565a45 (patch) | |
| tree | 13b9ab56061a722641389b5fc8ac1113d09b66e5 /src/command.zig | |
| parent | 7268c0b8e8bcf4b325581fabe7476a394f167738 (diff) | |
compaction
Diffstat (limited to 'src/command.zig')
| -rw-r--r-- | src/command.zig | 176 |
1 files changed, 176 insertions, 0 deletions
diff --git a/src/command.zig b/src/command.zig new file mode 100644 index 0000000..f10592f --- /dev/null +++ b/src/command.zig @@ -0,0 +1,176 @@ +//! Slash-command framework for the panto CLI. +//! +//! The REPL treats any input line beginning with `/` as a *slash +//! command* rather than a prompt for the model. This module provides the +//! structure panto uses to understand those commands: a `Registry` of +//! `Command`s, lookup by name, and a `list()` accessor (useful later for +//! tab-completion). Command handlers are plain functions that *do stuff* +//! directly — they print to the REPL's writer, mutate the conversation, +//! persist to the session, etc. — and return `!void`. +//! +//! Dispatch contract (see `Registry.dispatch`): +//! - The caller only invokes `dispatch` for lines starting with `/`. +//! - `dispatch` splits the leading word (sans `/`) as the command name +//! and passes the remainder (trimmed) as `args`. +//! - An unknown command yields `error.CommandNotFound`; the REPL turns +//! that into a user-facing error message. Slash lines never reach the +//! model regardless. +//! +//! Builtin commands (e.g. `/compact`) register themselves from their own +//! modules; future Lua extensions will append commands here too via a +//! `panto.register_command` bridge. + +const std = @import("std"); +const panto = @import("panto"); + +/// The mutable REPL state a command handler may touch. Bundles pointers +/// so handler signatures stay stable as the REPL grows. All pointers are +/// borrowed for the duration of the call; handlers must not retain them. +pub const Context = struct { + allocator: std.mem.Allocator, + + /// In-memory conversation the agent is driving. + conv: *panto.conversation.Conversation, + /// The active agent (model/provider/tooling snapshot). + agent: *panto.agent.Agent, + /// Session log to persist any changes a command makes. + session_mgr: *panto.session_manager.SessionManager, + + /// REPL output writer and its backing file writer (for flushing). + stdout: *std.Io.Writer, + stdout_file: *std.Io.File.Writer, + + /// Resolved compaction system prompt (owned elsewhere; borrowed here). + compaction_prompt: []const u8, + + /// Banner identity strings used when stamping persisted entries. + provider_name: []const u8, + model_name: []const u8, +}; + +/// A single slash command. +pub const Command = struct { + /// Command name *without* the leading `/` (e.g. `"compact"`). + name: []const u8, + /// One-line description, shown in listings/completion. + description: []const u8, + /// Handler. `args` is the remainder of the line after the command + /// name, trimmed of surrounding whitespace (empty string if none). + run: *const fn (args: []const u8, ctx: *Context) anyerror!void, +}; + +pub const Error = error{ + /// The line's command word matched no registered command. + CommandNotFound, +}; + +/// A registry of slash commands. Owns its backing list; call `deinit`. +pub const Registry = struct { + allocator: std.mem.Allocator, + commands: std.ArrayList(Command) = .empty, + + pub fn init(allocator: std.mem.Allocator) Registry { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *Registry) void { + self.commands.deinit(self.allocator); + } + + /// Register a command. Returns `error.DuplicateCommand` if a command + /// with the same name is already present. + pub fn register(self: *Registry, command: Command) !void { + if (self.find(command.name) != null) return error.DuplicateCommand; + try self.commands.append(self.allocator, command); + } + + /// Find a command by name (without leading `/`). Null if absent. + pub fn find(self: *const Registry, name: []const u8) ?*const Command { + for (self.commands.items) |*cmd| { + if (std.mem.eql(u8, cmd.name, name)) return cmd; + } + return null; + } + + /// All registered commands, in registration order. Useful for + /// listing and (later) completion. + pub fn list(self: *const Registry) []const Command { + return self.commands.items; + } + + /// Parse and run a slash-command line. `line` must begin with `/`. + /// + /// Splits the first whitespace-delimited word (after the `/`) as the + /// command name and passes the trimmed remainder as `args`. Returns + /// `error.CommandNotFound` if no command matches; otherwise returns + /// whatever the handler returns. + pub fn dispatch(self: *const Registry, line: []const u8, ctx: *Context) !void { + std.debug.assert(line.len > 0 and line[0] == '/'); + const body = line[1..]; + + // Split into name + rest on the first run of whitespace. + const name_end = std.mem.indexOfAny(u8, body, " \t") orelse body.len; + const name = body[0..name_end]; + const args = std.mem.trim(u8, body[name_end..], " \t"); + + const cmd = self.find(name) orelse return Error.CommandNotFound; + try cmd.run(args, ctx); + } +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +var test_calls: usize = 0; +var test_last_args: []const u8 = ""; + +fn testHandler(args: []const u8, ctx: *Context) anyerror!void { + _ = ctx; + test_calls += 1; + test_last_args = args; +} + +test "register, find, list" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + + try reg.register(.{ .name = "compact", .description = "d", .run = testHandler }); + try reg.register(.{ .name = "help", .description = "d", .run = testHandler }); + + try testing.expect(reg.find("compact") != null); + try testing.expect(reg.find("nope") == null); + try testing.expectEqual(@as(usize, 2), reg.list().len); + + try testing.expectError(error.DuplicateCommand, reg.register(.{ + .name = "compact", + .description = "d", + .run = testHandler, + })); +} + +test "dispatch splits name and args" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + try reg.register(.{ .name = "compact", .description = "d", .run = testHandler }); + + var ctx: Context = undefined; + + test_calls = 0; + try reg.dispatch("/compact", &ctx); + try testing.expectEqual(@as(usize, 1), test_calls); + try testing.expectEqualStrings("", test_last_args); + + try reg.dispatch("/compact extra words ", &ctx); + try testing.expectEqual(@as(usize, 2), test_calls); + try testing.expectEqualStrings("extra words", test_last_args); +} + +test "dispatch on unknown command yields CommandNotFound" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + var ctx: Context = undefined; + try testing.expectError(Error.CommandNotFound, reg.dispatch("/nope", &ctx)); +} |
