From 51bbb62ce5d82c460c75adfc03813e84ce5183d3 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 3 Jul 2026 13:01:25 -0600 Subject: support pending submissions on an Agent object --- src/tui_app.zig | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/tui_app.zig b/src/tui_app.zig index d097225..98b94be 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -2008,6 +2008,12 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp if (captured.len != 0) { _ = try app.spawnStatus(captured); } + // Drain any user message the command queued via + // `panto.ext.agent:submit` and drive it as a native turn. A loop, + // not an if: a handler firing during that turn may queue another. + while (try opts.agent.takeSubmission()) |blocks| { + try runSubmission(app, term, opts, blocks); + } try app.renderNow(); return; } @@ -2038,6 +2044,56 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp try app.renderNow(); } +/// Drive one turn from user-message blocks queued by an extension +/// (`panto.ext.agent:submit`). Mirrors the typed-line path: echo the +/// message (fires `user_message`), honor a text override, resolve auth, +/// drive the turn natively. Owns `queued` (allocated by `takeSubmission` +/// with the conversation allocator): `run` adopts the block contents, the +/// slice is freed here. +fn runSubmission(app: *App, term: *Terminal, opts: RunOptions, queued: []panto.ContentBlock) !void { + const alloc = opts.agent.conversation.allocator; + var blocks = queued; + defer alloc.free(blocks); + + // Echo the concatenated text blocks (a message with no text at all + // echoes as a placeholder). + var echo: std.ArrayList(u8) = .empty; + defer echo.deinit(app.alloc); + for (blocks) |b| switch (b) { + .Text => |t| { + if (echo.items.len != 0) try echo.append(app.alloc, '\n'); + try echo.appendSlice(app.alloc, t.items); + }, + else => {}, + }; + try app.spawnUser(if (echo.items.len != 0) echo.items else "[non-text message]"); // fires `user_message` + + // A `user_message` handler override has the same authority as on a + // typed line: it replaces the queued message wholesale. + if (app.bus.takeOverride()) |t| { + defer app.alloc.free(t); + for (blocks) |*b| b.deinit(alloc); + alloc.free(blocks); + blocks = &.{}; // keep the defer safe if the realloc below fails + blocks = try alloc.alloc(panto.ContentBlock, 1); + blocks[0] = .{ .Text = try panto.textualBlockFromSlice(alloc, t) }; + } + + app.beginTurn(); + try app.renderNow(); + resolveAuthForTurn(app, opts, false) catch |err| { + // The turn never opened; the block contents are still ours. + for (blocks) |*b| b.deinit(alloc); + try app.routeError(err); + try app.renderNow(); + return; + }; + driveTurnBlocks(app, term, opts, blocks) catch |err| { + try app.routeError(err); + }; + try app.renderNow(); +} + /// The provider name (left of `provider:alias`) currently selected. fn currentProviderName(app: *App, opts: RunOptions) []const u8 { const label = if (app.selectors) |c| c.model_label else opts.model_label; @@ -2115,7 +2171,13 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const var blocks = [_]panto.ContentBlock{ .{ .Text = try panto.textualBlockFromSlice(app.alloc, message_text) }, }; - var stream = try opts.agent.run(.{ .blocks = &blocks }); + return driveTurnBlocks(app, term, opts, &blocks); +} + +/// `driveTurn` for a pre-built user message. `run` adopts the block +/// contents; the slice itself stays the caller's. +fn driveTurnBlocks(app: *App, term: *Terminal, opts: RunOptions, blocks: []panto.ContentBlock) !void { + var stream = try opts.agent.run(.{ .blocks = blocks }); defer stream.deinit(); // Two single-shot fallbacks can re-open the SAME turn (no user-message // duplication): -- cgit v1.3