From 493a15c310fe2566107a1b200cb654295502657a Mon Sep 17 00:00:00 2001 From: t Date: Tue, 16 Jun 2026 12:15:49 -0600 Subject: esc key to interrupt the agent --- src/tui_app.zig | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 4 deletions(-) (limited to 'src/tui_app.zig') 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 -- cgit v1.3