summaryrefslogtreecommitdiff
path: root/src/command.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/command.zig')
-rw-r--r--src/command.zig176
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));
+}