diff options
| -rw-r--r-- | issues.md | 6 | ||||
| -rw-r--r-- | libpanto/src/agent.zig | 20 | ||||
| -rw-r--r-- | src/tui_app.zig | 79 |
3 files changed, 95 insertions, 10 deletions
diff --git a/issues.md b/issues.md deleted file mode 100644 index daf9010..0000000 --- a/issues.md +++ /dev/null @@ -1,6 +0,0 @@ -- [x] at the end of _any_ **kind** ~of~ `formatting`, ALL styles are cleared, which for user-text clears the background that should still be there. -- [x] screen flickers. I think our differential rendering has gotten broken and we're re-drawing the full conversation on every update. -- [x] tool components don't have their collapsed form any more, they're always showing their output expanded, ctrl+o does nothing. -- [x] empty lines in user-text are getting dropped - (shift+enter)*2 doesn't show an empty line in the middle when the user text is rendered in the component. - - [x] in another test, this text: "`code block`\n_underline_\n**bold**\n~strikethrough~" was collapsed down to one line with just spaces between them. this time it wasn't even a double-newline. -- [ ] keybindings need to work while the agent is working in a loop. most important: Escape should interrupt the agent wherever it is, return back to user-input mode (to avoid a broken convo history we'll have to clear any tool calls that didn't get results). diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 8a8a2e2..39c2f1a 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -1179,6 +1179,26 @@ pub const Stream = struct { self._agent._allocator.destroy(self); } + /// Abort this turn from the embedder side (for example, Escape in a TUI). + /// Provider/tool work that is currently inside `next()` cannot be unwound + /// until that call returns, but once control reaches the embedder this + /// drops every assistant/tool-result message produced by this stream before + /// `deinit()` has a chance to persist them. The already-persisted user + /// prompt (written by `Agent.run`) is intentionally kept, leaving a valid + /// conversation prefix with no dangling ToolUse blocks. + pub fn cancel(self: *Stream) void { + if (self._response) |ps| { + ps.deinit(); + self._response = null; + } + const conv = &self._agent.conversation; + while (conv.messages.items.len > self._start) { + var msg = conv.messages.pop().?; + msg.deinit(conv.allocator); + } + self.state = .done; + } + fn persistTail(self: *Stream) void { if (self._persisted) return; self._persisted = true; diff --git a/src/tui_app.zig b/src/tui_app.zig index c68daf4..4313556 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -1779,7 +1779,7 @@ fn handleBytes(app: *App, term: *Terminal, hs: *Handshake, bytes: []const u8, op // Copy: the box may reuse its buffer. const line = try app.alloc.dupe(u8, line_borrowed); defer app.alloc.free(line); - try handleSubmittedLine(app, line, opts); + try handleSubmittedLine(app, term, line, opts); } } return off; @@ -1904,7 +1904,7 @@ fn splitEditorArgv( } /// Handle a submitted input line: slash command vs. model turn. -fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void { +fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOptions) !void { if (line.len == 0) return; if (std.mem.startsWith(u8, line, "/")) { @@ -1947,7 +1947,7 @@ fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void { return; }; - driveTurn(app, opts, .{ .text = line }) catch |err| { + driveTurn(app, term, opts, .{ .text = line }) catch |err| { try app.routeError(err); }; try app.renderNow(); @@ -2025,7 +2025,7 @@ fn tryAdaptiveFallback(app: *App, err: anyerror) bool { /// component state until it terminates, rendering coalesced frames as deltas /// arrive. The stream is always `deinit`ed (persisting the turn tail) on every /// exit path — agent persistence is untouched. -fn driveTurn(app: *App, opts: RunOptions, message: panto.UserMessage) !void { +fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message: panto.UserMessage) !void { var stream = try opts.agent.run(message); defer stream.deinit(); // Two single-shot fallbacks can re-open the SAME turn (no user-message @@ -2036,7 +2036,16 @@ fn driveTurn(app: *App, opts: RunOptions, message: panto.UserMessage) !void { // A second failure of either kind propagates. var fallback_used = false; var auth_retry_used = false; + var turn_input_tail: std.ArrayList(u8) = .empty; + defer turn_input_tail.deinit(app.alloc); + var interrupted = false; while (true) { + if (try pumpTurnKeys(app, term, &turn_input_tail)) { + interrupted = true; + stream.cancel(); + _ = app.spawnStatus("[interrupted]") catch {}; + break; + } const ev = stream.next() catch |err| { if (!fallback_used and tryAdaptiveFallback(app, err)) { fallback_used = true; @@ -2057,7 +2066,69 @@ fn driveTurn(app: *App, opts: RunOptions, message: panto.UserMessage) !void { const e = ev orelse break; try app.routeEvent(e); _ = try app.maybeRender(); + if (try pumpTurnKeys(app, term, &turn_input_tail)) { + interrupted = true; + stream.cancel(); + _ = app.spawnStatus("[interrupted]") catch {}; + break; + } + } + if (interrupted) { + app.input_box.setFocused(true); + try app.rebuildEngineList(); + try app.renderNow(); + } +} + +/// Service app-level keybindings while a turn is in flight. Returns true when +/// the user requested an interrupt (Escape). Text-editing keys are ignored: the +/// input box is restored only after the interrupted turn is back in user-input +/// mode. This keeps the old single-threaded stream architecture intact while +/// making global TUI controls responsive between stream/tool-loop events. +fn pumpTurnKeys(app: *App, term: *Terminal, tail: *std.ArrayList(u8)) !bool { + var read_buf: [1024]u8 = undefined; + while (pollReadable(term.fd, 0) catch false) { + const n = posix.read(term.fd, &read_buf) catch |err| switch (err) { + error.WouldBlock => break, + else => return err, + }; + if (n == 0) break; + try tail.appendSlice(app.alloc, read_buf[0..n]); + } + + if (tail.items.len == 1 and tail.items[0] == 0x1b) { + tail.items.len = 0; + return true; + } + + var off: usize = 0; + while (off < tail.items.len) { + const step = input_mod.decodeOne(tail.items[off..]) orelse break; + switch (step.decoded) { + .key => |k| { + if (k.code == .escape) { + off += step.consumed; + const leftover = tail.items.len - off; + std.mem.copyForwards(u8, tail.items[0..leftover], tail.items[off..]); + tail.items.len = leftover; + return true; + } + if (k.isCtrl('o')) app.toggleToolCollapse(); + if (k.isCtrl('m')) if (app.selectors) |ctrl| ctrl.openModel() catch {}; + if (k.isCtrl('r')) if (app.selectors) |ctrl| ctrl.openReasoning() catch {}; + }, + .paste => {}, + .negotiation => {}, + } + off += step.consumed; + } + if (off != 0) { + const leftover = tail.items.len - off; + std.mem.copyForwards(u8, tail.items[0..leftover], tail.items[off..]); + tail.items.len = leftover; } + _ = try app.maybeRender(); + return false; } /// Poll the fd for readability with a millisecond timeout. Returns true when |
