summaryrefslogtreecommitdiff
path: root/src/builtin_commands.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-04 09:50:12 -0600
committert <t@tjp.lol>2026-07-05 16:23:49 -0600
commitf759f149377942c4e04802c45162cda1c9bfb2b3 (patch)
tree397dd2fc35839d8fcbf6a5c237bee363c6ed3c07 /src/builtin_commands.zig
parent1ed07e2e4473b91c669c062bbfef6bb499f7d2b7 (diff)
big cli/tui gaps project
Diffstat (limited to 'src/builtin_commands.zig')
-rw-r--r--src/builtin_commands.zig169
1 files changed, 169 insertions, 0 deletions
diff --git a/src/builtin_commands.zig b/src/builtin_commands.zig
new file mode 100644
index 0000000..8f9f851
--- /dev/null
+++ b/src/builtin_commands.zig
@@ -0,0 +1,169 @@
+//! Builtin TUI slash commands: `/help`, `/quit`, `/model`, `/reasoning`,
+//! `/new`. Registered by `main.zig` alongside `/compact`
+//! (`compaction.zig`). These steer the live TUI, so their handlers cast
+//! `Context.tui` back to the `*tui_app.App` installed at bring-up; output
+//! goes through `ctx.stdout` (the transcript capture buffer).
+
+const std = @import("std");
+const panto = @import("panto");
+const command = @import("command.zig");
+const tui_app = @import("tui_app.zig");
+const selectors_mod = @import("tui_selectors.zig");
+
+pub fn register(registry: *command.Registry) !void {
+ try registry.register(.{
+ .name = "help",
+ .description = "List commands and key bindings.",
+ .run = runHelp,
+ });
+ try registry.register(.{
+ .name = "quit",
+ .description = "Exit panto.",
+ .run = runQuit,
+ });
+ try registry.register(.{
+ .name = "model",
+ .description = "Open the model selector, or switch directly: /model <provider:alias>.",
+ .run = runModel,
+ });
+ try registry.register(.{
+ .name = "reasoning",
+ .description = "Open the reasoning selector, or set directly: /reasoning <level>.",
+ .run = runReasoning,
+ });
+ try registry.register(.{
+ .name = "new",
+ .description = "Start a fresh session in place (new agent, extensions reloaded).",
+ .run = runNew,
+ });
+ try registry.register(.{
+ .name = "resume",
+ .description = "Switch sessions: /resume opens a picker, /resume <id> resolves a prefix.",
+ .run = runResume,
+ });
+ try registry.register(.{
+ .name = "status",
+ .description = "Show provider, model, reasoning, session, and context usage.",
+ .run = runStatus,
+ });
+}
+
+fn theApp(ctx: *command.Context) !*tui_app.App {
+ const ptr = ctx.tui orelse return error.NoTui;
+ return @ptrCast(@alignCast(ptr));
+}
+
+/// `/help` — the command list straight from the registry (so it can't rot)
+/// plus the key bindings table maintained next to the chord dispatch.
+fn runHelp(_: []const u8, ctx: *command.Context) anyerror!void {
+ const reg = ctx.registry orelse return error.NoRegistry;
+ var width: usize = 0;
+ for (reg.list()) |cmd| width = @max(width, cmd.name.len);
+
+ try ctx.stdout.writeAll("commands:\n");
+ for (reg.list()) |cmd| {
+ try ctx.stdout.print(" /{[n]s:<[w]}{[d]s}\n", .{ .n = cmd.name, .w = width + 2, .d = cmd.description });
+ }
+
+ width = 0;
+ for (tui_app.key_bindings) |kb| width = @max(width, kb.chord.len);
+ try ctx.stdout.writeAll("\nkeys:\n");
+ for (tui_app.key_bindings) |kb| {
+ try ctx.stdout.print(" {[c]s:<[w]}{[d]s}\n", .{ .c = kb.chord, .w = width + 2, .d = kb.desc });
+ }
+}
+
+/// `/quit` — the dispatch site in `tui_app.handleSubmittedLine` re-raises
+/// `error.UserExit`.
+fn runQuit(_: []const u8, _: *command.Context) anyerror!void {
+ return error.UserExit;
+}
+
+fn runModel(args: []const u8, ctx: *command.Context) anyerror!void {
+ const app = try theApp(ctx);
+ const ctrl = app.selectors orelse return error.NoSelectors;
+ if (args.len == 0) return ctrl.openModel();
+ ctrl.setModelByName(args) catch |err| switch (err) {
+ error.UnknownModel => try ctx.stdout.print(
+ "unknown model '{s}' (/model with no argument lists them)\n",
+ .{args},
+ ),
+ error.AmbiguousModel => try ctx.stdout.print(
+ "ambiguous alias '{s}': use provider:alias\n",
+ .{args},
+ ),
+ else => return err,
+ };
+}
+
+fn runReasoning(args: []const u8, ctx: *command.Context) anyerror!void {
+ const app = try theApp(ctx);
+ const ctrl = app.selectors orelse return error.NoSelectors;
+ if (args.len == 0) return ctrl.openReasoning();
+ ctrl.setReasoningByLabel(args) catch |err| switch (err) {
+ error.UnknownReasoning => {
+ try ctx.stdout.print("unknown reasoning level '{s}'; available:", .{args});
+ for (selectors_mod.ReasoningOption.forStyle(ctrl.live.provider.style())) |opt| {
+ try ctx.stdout.print(" {s}", .{opt.label});
+ }
+ try ctx.stdout.writeAll("\n");
+ },
+ else => return err,
+ };
+}
+
+fn runResume(args: []const u8, ctx: *command.Context) anyerror!void {
+ const app = try theApp(ctx);
+ const ctrl = app.selectors orelse return error.NoSelectors;
+ if (args.len == 0) return ctrl.openSessions();
+
+ const store = ctx.session_store;
+ const maybe = store.resolve(args) catch |err| switch (err) {
+ error.AmbiguousSessionId => return printAmbiguousSessions(store, args, ctx.stdout),
+ else => return err,
+ };
+ const session = maybe orelse {
+ try ctx.stdout.print("no session matching '{s}'\n", .{args});
+ return;
+ };
+ try ctrl.resumeTo(session);
+}
+
+/// Print the session ids matching `prefix` (shared by `/resume` and the
+/// `--resume` path in `main.zig` for ambiguity reporting).
+pub fn printAmbiguousSessions(store: panto.SessionStore, prefix: []const u8, out: *std.Io.Writer) !void {
+ try out.print("'{s}' matches multiple sessions:\n", .{prefix});
+ const infos = try store.list();
+ defer store.freeSessionInfos(infos);
+ for (infos) |info| {
+ if (!std.mem.startsWith(u8, info.id, prefix)) continue;
+ try out.print(" {s}\n", .{info.id});
+ }
+}
+
+fn runStatus(_: []const u8, ctx: *command.Context) anyerror!void {
+ const app = try theApp(ctx);
+ const ctrl = app.selectors orelse return error.NoSelectors;
+ try ctx.stdout.print("model: {s}\n", .{ctrl.model_label});
+ const r = selectors_mod.ReasoningOption.activeLabel(ctrl.live.provider);
+ try ctx.stdout.print("reasoning: {s}\n", .{if (r.len != 0) r else "default"});
+ try ctx.stdout.print("session: {s}\n", .{ctx.agent.sessionId()});
+ try ctx.stdout.print("messages: {d}\n", .{ctx.agent.conversation.messages.items.len});
+ if (app.footer.context_window) |w| {
+ try ctx.stdout.print("context: {d} / {d} tokens\n", .{ app.footer.context_tokens orelse 0, w });
+ } else if (app.footer.context_tokens) |t| {
+ try ctx.stdout.print("context: {d} tokens\n", .{t});
+ }
+}
+
+/// `/new` — fresh session in place, boot-equivalent: mint a session from
+/// the store and rebuild the whole session world (fresh Agent, fresh Lua
+/// runtime; the system prompt is re-derived from the prompt layers +
+/// extension activation, exactly as a relaunch would). The rebuild hook
+/// repoints `ctx.agent`, so the id printed below is the fresh session's.
+fn runNew(_: []const u8, ctx: *command.Context) anyerror!void {
+ const app = try theApp(ctx);
+ try app.rebuildSession(ctx.session_store.create(), null);
+ const sid = ctx.agent.sessionId();
+ try ctx.stdout.print("[new session {s}]\n", .{sid[0..@min(8, sid.len)]});
+}