summaryrefslogtreecommitdiff
path: root/src/command.zig
blob: f10592f3bfea86074a986d1f2f9cf5b18eec26b0 (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
//! 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));
}