summaryrefslogtreecommitdiff
path: root/src/command.zig
blob: 588490c650e7eab9ab818fcc42048ebefb361ad6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
//! 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,

    /// The active agent (a copyable public handle). Commands reach the
    /// conversation via `agent.conversation` and compact via
    /// `agent.compact(...)`.
    agent: *panto.Agent,

    /// 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));
}