//! 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. Lua extensions append commands here too: a script's call to //! `panto.register_command { name, description, handler }` is harvested by //! `lua_runtime.zig`, and `main.zig` registers each via `registerLua`. //! Such commands carry a `lua_ref`; `dispatch` routes them back into the //! Lua runtime instead of calling a native `run` function. 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, /// The long-lived Lua runtime, if extensions are active. Used by /// Lua-defined commands (see `lua_command.zig`) to invoke their /// handler. `null` when no extensions loaded; Lua commands are only /// registered when it is non-null, so handlers may assume it is set. lua_rt: ?*anyopaque = null, }; /// 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, /// For commands backed by a Lua handler, the `luaL_ref` of that /// handler in the runtime's registry. `null` for native Zig /// commands. The shared Lua thunk reads this to know which handler /// to invoke (via `ctx.lua_rt`). lua_ref: ?c_int = null, }; pub const Error = error{ /// The line's command word matched no registered command. CommandNotFound, }; /// Placeholder `run` for Lua-backed commands. Dispatch intercepts them /// via `Command.lua_ref` before `run` is ever called, so reaching here /// indicates a wiring bug. fn luaPlaceholderRun(_: []const u8, _: *Context) anyerror!void { return error.LuaCommandMisrouted; } /// 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); } /// Register a Lua-backed command. `lua_ref` is the handler's registry /// ref in the active `LuaRuntime`; dispatch routes to it via /// `ctx.lua_rt`. The `run` field is a never-called placeholder. pub fn registerLua( self: *Registry, name: []const u8, description: []const u8, lua_ref: c_int, ) !void { try self.register(.{ .name = name, .description = description, .run = luaPlaceholderRun, .lua_ref = lua_ref, }); } /// 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; if (cmd.lua_ref) |ref| { try runLuaCommand(ref, args, ctx); return; } try cmd.run(args, ctx); } }; /// Invoke a Lua-backed command. Resolves `ctx.lua_rt` back to a /// `LuaRuntime`, runs the handler synchronously, and prints any Lua /// error to the REPL writer (the handler itself is responsible for /// normal output). Kept out of `lua_runtime.zig` so that module stays /// free of REPL/`Context` concerns; the runtime only exposes /// `runCommand`. fn runLuaCommand(ref: c_int, args: []const u8, ctx: *Context) !void { const lua_runtime = @import("lua_runtime.zig"); const rt: *lua_runtime.LuaRuntime = @ptrCast(@alignCast(ctx.lua_rt.?)); var err_buf: [4096]u8 = undefined; var err_len: usize = 0; rt.runCommand(ref, args, &err_buf, &err_len) catch |err| switch (err) { error.LuaHandlerCrashed => { try ctx.stdout.print( "\n[command error: {s}]\n> ", .{err_buf[0..err_len]}, ); try ctx.stdout_file.flush(); }, else => return err, }; } // --------------------------------------------------------------------------- // 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 "registerLua records name, description, and ref; collides with builtins" { var reg = Registry.init(testing.allocator); defer reg.deinit(); try reg.register(.{ .name = "compact", .description = "d", .run = testHandler }); try reg.registerLua("hello", "Lua greeting", 7); const cmd = reg.find("hello").?; try testing.expectEqualStrings("Lua greeting", cmd.description); try testing.expectEqual(@as(?c_int, 7), cmd.lua_ref); // Native command has no lua_ref. try testing.expectEqual(@as(?c_int, null), reg.find("compact").?.lua_ref); // Name collision with a builtin is rejected. try testing.expectError(error.DuplicateCommand, reg.registerLua("compact", "x", 1)); } 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)); }