diff options
| author | t <t@tjp.lol> | 2026-06-13 23:45:59 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-15 15:08:32 -0600 |
| commit | 02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 (patch) | |
| tree | 134196a82dd6fdd55b735be4c0cf38428c85038d /src/tui_components.zig | |
| parent | 71643a5d69ffc40882c9fcde3cc8a3bcf02d7396 (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.zig | 167 |
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); } |
