summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--issues.md6
-rw-r--r--libpanto/src/agent.zig20
-rw-r--r--src/tui_app.zig79
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