summaryrefslogtreecommitdiff
path: root/src/tui_components.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-13 23:45:59 -0600
committert <t@tjp.lol>2026-06-15 15:08:32 -0600
commit02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 (patch)
tree134196a82dd6fdd55b735be4c0cf38428c85038d /src/tui_components.zig
parent71643a5d69ffc40882c9fcde3cc8a3bcf02d7396 (diff)
Add Codex Responses support and session debugging
Teach provider config and auth resolution about the Codex Responses dialect, including user-facing `style = "openai_responses"` with `dialect = "codex"`. Serialize and parse Responses traffic with provider-specific reasoning replay, assistant phase metadata, and robust function-call assembly keyed by `output_index` so streamed tool inputs survive proxy quirks and empty terminal payloads. Also persist thinking origins and message metadata across sessions, add the Anthropic interleaved-thinking header switch, write per-session debug logs, and improve the TUI and scripts for inspecting tool output and session costs.
Diffstat (limited to 'src/tui_components.zig')
-rw-r--r--src/tui_components.zig167
1 files changed, 126 insertions, 41 deletions
diff --git a/src/tui_components.zig b/src/tui_components.zig
index e64b5bd..14c61d9 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -983,9 +983,10 @@ pub const InputBox = struct {
// Locate the cursor's (row, byte-col-in-row).
const focused = self.focusable.focused;
- // Walk lines, tracking byte offset so we know which row holds cursor.
- // `cursor_row` records which produced row carries the cursor block, so
- // the scroll-window below can keep it visible.
+ // Walk logical lines, but render HARD-WRAPPED visual rows. Wrapping is
+ // purely visual: no '\n' bytes are inserted into the editor buffer.
+ // `cursor_row` records which produced visual row carries the cursor
+ // block, so the scroll-window below can keep it visible.
var line_byte_start: usize = 0;
var produced_any = false;
var cursor_row: usize = 0;
@@ -994,22 +995,19 @@ pub const InputBox = struct {
const line_start = line_byte_start;
const line_end = line_start + line.len;
const cursor_in_line = focused and self.cursor >= line_start and self.cursor <= line_end and
- // The cursor belongs to the FIRST line whose range contains it
- // (at a '\n' boundary it stays on the line before the break,
- // i.e. == line_end). Disambiguate the boundary: if cursor ==
- // line_end and there are more lines, it belongs to the NEXT
- // line's start unless this is the last line.
+ // At an explicit '\n' boundary, the cursor belongs to the next
+ // logical line's start unless this is the final line.
(self.cursor < line_end or it.peek() == null);
- if (cursor_in_line) cursor_row = rows.items.len;
- const row = try self.renderRow(line, if (cursor_in_line) self.cursor - line_start else null, cursor_style, width, focused);
- try rows.append(a, row);
- produced_any = true;
+ const first_row = rows.items.len;
+ try self.appendWrappedRows(line, line_start, cursor_in_line, cursor_style, width, focused, &rows, &cursor_row);
+ produced_any = produced_any or rows.items.len > first_row;
line_byte_start = line_end + 1; // skip the '\n'
}
if (!produced_any) {
// Empty buffer: a single (possibly cursor-bearing) row.
const row = try self.renderRow("", if (focused) @as(?usize, 0) else null, cursor_style, width, focused);
+ if (focused) cursor_row = 0;
try rows.append(a, row);
}
@@ -1036,6 +1034,80 @@ pub const InputBox = struct {
return cacheLines(&self.cache);
}
+ /// Append the hard-wrapped visual rows for one logical line. The slices are
+ /// rendered immediately into owned row bytes; the editor buffer itself is
+ /// unchanged. Cursor placement follows terminal wrapping semantics: a
+ /// cursor exactly at a wrap boundary appears at column 0 of the next visual
+ /// row, not at the end of the previous one.
+ fn appendWrappedRows(
+ self: *InputBox,
+ line: []const u8,
+ line_start: usize,
+ cursor_in_line: bool,
+ cursor_style: Style,
+ width: usize,
+ focused: bool,
+ rows: *std.ArrayList([]const u8),
+ cursor_row: *usize,
+ ) !void {
+ const a = self.alloc;
+
+ if (width == 0) {
+ const row = try self.renderRow("", if (cursor_in_line) @as(?usize, 0) else null, cursor_style, width, focused);
+ if (cursor_in_line) cursor_row.* = rows.items.len;
+ try rows.append(a, row);
+ return;
+ }
+
+ if (line.len == 0) {
+ const row = try self.renderRow("", if (cursor_in_line) @as(?usize, 0) else null, cursor_style, width, focused);
+ if (cursor_in_line) cursor_row.* = rows.items.len;
+ try rows.append(a, row);
+ return;
+ }
+
+ var i: usize = 0;
+ while (i < line.len) {
+ const chunk_start = i;
+ var cols: usize = 0;
+ while (i < line.len) {
+ const seq_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
+ const adv = @min(seq_len, line.len - i);
+ const cp = std.unicode.utf8Decode(line[i .. i + adv]) catch '?';
+ const w = codepointWidth(cp);
+ if (cols > 0 and cols + w > width) break;
+ i += adv;
+ cols += w;
+ if (cols >= width) break;
+ }
+ if (i == chunk_start) i = self.nextBoundary(line_start + i) - line_start; // defensive progress
+
+ const chunk = line[chunk_start..i];
+ const abs_start = line_start + chunk_start;
+ const abs_end = line_start + i;
+ var cursor_col: ?usize = null;
+ if (cursor_in_line) {
+ if (self.cursor >= abs_start and self.cursor < abs_end) {
+ cursor_col = self.cursor - abs_start;
+ } else if (i == line.len and self.cursor == abs_end) {
+ cursor_col = chunk.len;
+ }
+ }
+ if (cursor_col != null) cursor_row.* = rows.items.len;
+ const row = try self.renderRow(chunk, cursor_col, cursor_style, width, focused);
+ try rows.append(a, row);
+ }
+
+ // If the cursor is at the end of a line that exactly fills the last
+ // visual row, show the cursor on the following wrapped row instead of
+ // replacing the last visible character with the block.
+ if (cursor_in_line and self.cursor == line_start + line.len and displayWidth(line) % width == 0) {
+ const row = try self.renderRow("", @as(?usize, 0), cursor_style, width, focused);
+ cursor_row.* = rows.items.len;
+ try rows.append(a, row);
+ }
+ }
+
/// Build a dim, full-width horizontal rule (box-drawing `โ”€`). Caller owns
/// the returned slice.
fn horizontalRule(self: *InputBox, width: usize) ![]u8 {
@@ -1079,6 +1151,7 @@ pub const InputBox = struct {
/// cursor (or a space at end-of-line) and emits CURSOR_MARKER there.
fn renderRow(self: *InputBox, line: []const u8, cursor_col: ?usize, cursor_style: Style, width: usize, focused: bool) ![]u8 {
const a = self.alloc;
+ if (width == 0) return a.dupe(u8, "");
// The cursor block consumes one visible column, so usable text width
// is width-1 when the cursor sits at/after the truncated end and we
// must show the block. To keep it simple and always-safe: truncate the
@@ -1632,19 +1705,18 @@ pub const Selector = struct {
};
// ===========================================================================
-// Welcome โ€” session-start banner (plan ยง6: "version, cwd, model info")
+// Welcome โ€” session-start banner
// ===========================================================================
/// A static banner shown as the first transcript entry at session start.
-/// Structured data in (version / cwd / model label via setters), lines out.
-/// Re-rendered only when one of the fields changes (markDirty), which in
-/// practice is once during bring-up.
+/// Structured data in (version / cwd via setters), lines out. Re-rendered
+/// only when one of the visible fields changes (markDirty), which in practice
+/// is once during bring-up.
pub const Welcome = struct {
alloc: std.mem.Allocator,
cache: RenderCache,
version: std.ArrayList(u8) = .empty,
cwd: std.ArrayList(u8) = .empty,
- model: std.ArrayList(u8) = .empty,
pub fn init(alloc: std.mem.Allocator) Welcome {
return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
@@ -1653,7 +1725,6 @@ pub const Welcome = struct {
pub fn deinit(self: *Welcome) void {
self.version.deinit(self.alloc);
self.cwd.deinit(self.alloc);
- self.model.deinit(self.alloc);
self.cache.deinit();
}
@@ -1673,11 +1744,6 @@ pub const Welcome = struct {
try self.setField(&self.cwd, value);
}
- /// Set the model label shown in the banner.
- pub fn setModel(self: *Welcome, value: []const u8) !void {
- try self.setField(&self.model, value);
- }
-
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = alloc;
const self: *Welcome = @ptrCast(@alignCast(ptr));
@@ -1707,19 +1773,13 @@ pub const Welcome = struct {
try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ accent.open(), vis, accent.close() }));
}
- // Detail lines (dim, with leading space): cwd and model, only when set.
+ // Detail lines (dim, with leading space): cwd only when set.
if (self.cwd.items.len != 0) {
const txt = try std.fmt.allocPrint(a, " cwd: {s}", .{self.cwd.items});
defer a.free(txt);
const vis = truncateToCols(txt, width);
try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
}
- if (self.model.items.len != 0) {
- const txt = try std.fmt.allocPrint(a, " model: {s}", .{self.model.items});
- defer a.free(txt);
- const vis = truncateToCols(txt, width);
- try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
- }
_ = plain_bg;
// Blank margin below.
@@ -2366,7 +2426,35 @@ test "InputBox: cursor block fits within width at end of a full line" {
for ("abcde") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
// width 5, cursor at end: the block must not overflow.
const lines = try ib.comp().render(5, testing.allocator);
- try testing.expect(vw(lines[1]) <= 5);
+ for (lines) |ln| try testing.expect(vw(ln) <= 5);
+}
+
+test "InputBox: long logical line hard-wraps visually without inserting newlines" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.setFocused(true);
+ try typeStr(&ib, "abcdef");
+ const lines = try ib.comp().render(5, testing.allocator);
+ // Two content rows ("abcde" and "f"+cursor), plus top/bottom rules.
+ try testing.expectEqual(@as(usize, 4), lines.len);
+ try testing.expectEqualStrings("abcdef", ib.text.items);
+ try testing.expect(std.mem.indexOf(u8, lines[1], "abcde") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[2], "f") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[2], CURSOR_MARKER) != null);
+ for (lines) |ln| try testing.expect(vw(ln) <= 5);
+}
+
+test "InputBox: line cap applies to visual wrapped rows" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.line_cap = 2;
+ try typeStr(&ib, "abcde");
+ const lines = try ib.comp().render(2, testing.allocator);
+ // Three wrapped content rows are capped to the tail two, plus rules.
+ try testing.expectEqual(@as(usize, 4), lines.len);
+ try testing.expect(std.mem.indexOf(u8, lines[1], "cd") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[2], "e") != null);
+ for (lines) |ln| try testing.expect(vw(ln) <= 2);
}
fn ctrlKey(letter: u8) Key {
@@ -2873,22 +2961,20 @@ test "components drive the real engine without a TTY" {
// -- Welcome / Thinking / CompactionSummary / ToolUse (P2) ------------------
-test "Welcome: renders title + cwd + model, all within width" {
+test "Welcome: renders title + cwd, all within width" {
var w = Welcome.init(testing.allocator);
defer w.deinit();
try w.setVersion("0.1.0");
try w.setCwd("/tmp/project");
- try w.setModel("anthropic:claude");
const lines = try w.comp().render(40, testing.allocator);
- // 3 content lines + 2 margin = 5.
- try testing.expectEqual(@as(usize, 5), lines.len);
+ // 2 content lines + 2 margin = 4.
+ try testing.expectEqual(@as(usize, 4), lines.len);
for (lines) |l| try testing.expect(vw(l) <= 40);
try testing.expect(std.mem.indexOf(u8, lines[1], "panto v0.1.0") != null);
try testing.expect(std.mem.indexOf(u8, lines[2], "/tmp/project") != null);
- try testing.expect(std.mem.indexOf(u8, lines[3], "anthropic:claude") != null);
}
-test "Welcome: title only when cwd/model unset" {
+test "Welcome: title only when cwd unset" {
var w = Welcome.init(testing.allocator);
defer w.deinit();
const lines = try w.comp().render(20, testing.allocator);
@@ -2902,11 +2988,10 @@ test "Welcome: honors the width contract at a tiny width" {
defer w.deinit();
try w.setVersion("0.1.0");
try w.setCwd("/a/very/long/working/directory/path/that/overflows");
- try w.setModel("anthropic:claude-some-very-long-model-id");
- // Width 6: every banner row (title + cwd + model) must truncate to fit.
- // 3 content lines + 2 margin = 5.
+ // Width 6: every banner row (title + cwd) must truncate to fit.
+ // 2 content lines + 2 margin = 4.
const lines = try w.comp().render(6, testing.allocator);
- try testing.expectEqual(@as(usize, 5), lines.len);
+ try testing.expectEqual(@as(usize, 4), lines.len);
for (lines) |l| try testing.expect(vw(l) <= 6);
}