From 129031c680aff494571ae209b78194d2ef4840a6 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:33:43 +0800 Subject: [PATCH] fix(agent): width-aware interactive prompt/status line (#209) Agent.prompt emitted the full model/effort/provider/mode/cwd/context status line with no width budget, so a narrow terminal pane soft-wrapped it mid-badge (e.g. splitting `codex`) and left the cursor visually inside a label. readline.zig only reacts after the fact by moving the input to a fresh row; the status string had already wrapped. prompt() now budgets each segment against termCols(): the model plus the settings that disambiguate the cursor (effort, provider, mode badges) come first, and cwd/context/cache/cost are dropped when they no longer fit. The context meter outranks cwd for the budget (the urgent, changing signal near the compaction threshold) but still renders after cwd so the familiar order is kept when both fit. The line reserves the fixed frame plus a column of slack, so nothing but a model name longer than the pane can wrap; readline then gives the input its own row as before. The wcwidth-style dispWidth/codepointWidth table moves from repl.zig into term.zig (the shared terminal-utilities leaf) so the TUI table math and this budgeting share one definition; repl.zig delegates and keeps its #142 test. Tests: new scripts/test-pty-narrow-prompt.py drives a real 28-col PTY and asserts the line fits, badges stay whole, and cwd is dropped, while a wide pane still shows cwd; test-pty-repl.py widened to 200 cols so its badge-state session fits the full line; zig build test 184/184, zig fmt clean, full PTY suite green. Refs #209 Co-Authored-By: blackfloofie <265516171+blackfloofie@users.noreply.github.com> --- scripts/test-pty-narrow-prompt.py | 119 ++++++++++++++++++++++++++++++ scripts/test-pty-repl.py | 5 ++ src/agent.zig | 94 ++++++++++++++++------- src/repl.zig | 51 ++----------- src/term.zig | 50 +++++++++++++ 5 files changed, 249 insertions(+), 70 deletions(-) create mode 100755 scripts/test-pty-narrow-prompt.py diff --git a/scripts/test-pty-narrow-prompt.py b/scripts/test-pty-narrow-prompt.py new file mode 100755 index 00000000..0f82c058 --- /dev/null +++ b/scripts/test-pty-narrow-prompt.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Real-PTY test for the width-aware interactive prompt/status line (#209). + +Before the fix, `Agent.prompt` emitted the full model/effort/provider/cwd/context +status line with no width budget, so a narrow terminal pane soft-wrapped it mid- +badge (e.g. splitting `codex`) and left the cursor visually inside a label. The +fix budgets each segment against `termCols()`: the model and the badges that +disambiguate the cursor come first, and cwd/context/cache/cost are dropped when +they no longer fit, so the rendered line never exceeds the pane width and no +badge is split at the edge. + +This drives a real terminal at several widths and asserts: + * narrow pane -> the prompt line fits the pane, its badges stay whole, and + low-priority metadata (cwd) is dropped; + * wide pane -> the same launch shows the full metadata (cwd), proving the + narrow layout is genuine width budgeting and not an unconditional strip. + +Rendered (not raw) so the check is independent of the terminal's newline +translation. `-`/`>`-style separators are single display columns and the cwd is +dropped in the narrow case, so len() of the rendered line equals its display +width here. +""" + +import os +import sys +import tempfile + +from pty_harness import PtySession, terminal_text + +_arg = sys.argv[1] if len(sys.argv) > 1 else "graff" +GRAFF = os.path.abspath(_arg) if os.sep in _arg else _arg + +MODEL = "deepseek-v4-pro" # local-only model: no backend, deterministic prompt + + +def prompt_line(cols: int) -> str: + """Launch graff in a `cols`-wide pane and return the rendered prompt line.""" + with tempfile.TemporaryDirectory(prefix="graff-pty-narrow-") as tmp: + env = { + "HOME": tmp, + "CODEGRAFF_API_KEY": "local-pty-test", + "GRAFF_FLEET": "off", + "GRAFF_NO_TELEMETRY": "1", + } + with PtySession( + GRAFF, + ["--model", MODEL, "--no-telemetry"], + cwd=tmp, + env=env, + unset_env=("CODEX_HOME", "NO_COLOR"), + timeout=15.0, + rows=12, + cols=cols, + ) as session: + session.wait_for_literal("] ›") + session.pump_for(0.3) # let the final prompt redraw settle + rendered = terminal_text(bytes(session.raw)) + # The live prompt is the last line carrying the input marker. + candidates = [ln for ln in rendered.split("\n") if "] ›" in ln] + if not candidates: + raise AssertionError(f"never saw a prompt line:\n{rendered!r}") + line = candidates[-1] + + session.send_key("ctrl-d") + result = session.read_until_exit(5.0) + if result.timed_out or result.exit_code != 0: + raise SystemExit( + f"REPL did not exit cleanly: exit={result.exit_code} " + f"timed_out={result.timed_out}" + ) + return line + + +def main() -> None: + narrow_cols = 28 # the width from the issue's tmux repro + narrow = prompt_line(narrow_cols) + + # 1. The line must fit the pane: an overflow is exactly the soft-wrap that + # split badges in the report. + if len(narrow) > narrow_cols: + raise AssertionError( + f"prompt line overflows a {narrow_cols}-col pane (would soft-wrap " + f"mid-badge, #209): width={len(narrow)} line={narrow!r}" + ) + + # 2. It must still end on a complete frame (never a badge sliced by the edge). + if not narrow.rstrip().endswith("] ›"): + raise AssertionError(f"prompt frame is not intact in narrow pane: {narrow!r}") + + # 3. The model badge is mandatory and must appear whole, not truncated. + if MODEL not in narrow: + raise AssertionError(f"model badge missing/split in narrow pane: {narrow!r}") + + # 4. Low-priority metadata (cwd) must be the thing that gives way. + if "cwd" in narrow: + raise AssertionError( + f"cwd should be dropped in a {narrow_cols}-col pane: {narrow!r}" + ) + + # 5. A wide pane keeps the full metadata, proving the narrow layout is real + # width budgeting and not an unconditional strip. 220 comfortably fits the + # model/effort/provider badges plus the temp-dir cwd path at any width. + wide_cols = 220 + wide = prompt_line(wide_cols) + if len(wide) > wide_cols: + raise AssertionError(f"prompt line overflows a {wide_cols}-col pane: {wide!r}") + if "cwd" not in wide: + raise AssertionError(f"wide pane should show cwd metadata: {wide!r}") + if len(wide) <= len(narrow): + raise AssertionError( + "prompt line did not adapt to width: " + f"narrow({narrow_cols})={narrow!r} wide({wide_cols})={wide!r}" + ) + + print("ok PTY width-aware prompt: no mid-badge wrap in a narrow pane (#209)") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-pty-repl.py b/scripts/test-pty-repl.py index 28cf5234..dae8665b 100755 --- a/scripts/test-pty-repl.py +++ b/scripts/test-pty-repl.py @@ -22,6 +22,10 @@ def main() -> None: "GRAFF_FLEET": "off", "GRAFF_NO_TELEMETRY": "1", } + # A wide pane so the full status line (all mode badges + the deep temp + # cwd) fits without the width budgeting (#209) dropping cwd; this session + # exercises command/badge state, while the cols=44 session below and + # test-pty-narrow-prompt.py cover the narrow-width layout. with PtySession( GRAFF, ["--model", "deepseek-v4-pro", "--no-telemetry"], @@ -29,6 +33,7 @@ def main() -> None: env=env, unset_env=("CODEX_HOME", "NO_COLOR"), timeout=15.0, + cols=200, ) as session: session.wait_for_literal("] ›") diff --git a/src/agent.zig b/src/agent.zig index f5c480f3..7f729e03 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -38,6 +38,7 @@ const pricing = @import("pricing.zig"); const ansi = @import("ansi.zig"); const style = &ansi.style; +const terminal = @import("term.zig"); fn reasoningPromptLabel(effort: ReasoningEffort) []const u8 { return switch (effort) { @@ -65,6 +66,18 @@ fn writePromptBadge(w: *Io.Writer, color: []const u8, label: []const u8) !void { try w.print("{s} · {s}{s}{s}{s}", .{ style.dim, style.reset, color, label, style.reset }); } +/// Include a status-line segment only if its display width still fits the +/// remaining budget, charging it against `used` when it does. Lets prompt() +/// drop low-priority metadata instead of soft-wrapping mid-badge in a narrow +/// pane (#209). +fn fitsSegment(used: *usize, avail: usize, seg_width: usize) bool { + if (used.* + seg_width <= avail) { + used.* += seg_width; + return true; + } + return false; +} + fn compactTokenCount(buf: []u8, tokens: u64) []const u8 { return if (tokens >= 1000) std.fmt.bufPrint(buf, "{d}k", .{tokens / 1000}) catch "?" @@ -189,40 +202,71 @@ pub const Agent = struct { // Prompt-cache hit from the last response — proof caching is working. var kbuf: [32]u8 = undefined; var kval: [24]u8 = undefined; - const cached: []const u8 = if (self.last_cache_read > 0) + const cached: []const u8 = if (self.last_context_tokens > 0 and self.last_cache_read > 0) (std.fmt.bufPrint(&kbuf, " · ⚡{s} cached", .{compactTokenCount(&kval, self.last_cache_read)}) catch "") else ""; - try w.print("\n{s}[{s}{s}{s}{s}", .{ style.dim, style.reset, style.cyan, self.provider.model, style.reset }); - // Fast is the most operationally important model setting, so keep it - // immediately beside the model instead of letting permission modes - // push it deeper into the status line. - if (self.fast and self.provider.kind == .responses) try writePromptBadge(w, style.green, "Fast"); - if (self.effortApplies()) try writePromptBadge(w, reasoningPromptColor(self.reasoning), reasoningPromptLabel(self.reasoning)); - try writePromptBadge(w, style.cyan, self.provider.id); - if (self.fallback_active) try writePromptBadge(w, style.yellow, "Fallback"); - if (main_mod.plan_mode) try writePromptBadge(w, style.yellow, "Plan"); - if (self.strict) try writePromptBadge(w, style.red, "Strict"); - if (self.ultracode_mode) try writePromptBadge(w, style.magenta, "Ultracode"); - try w.print("{s} · cwd {s}{s}{s}", .{ style.dim, style.reset, main_mod.g_cwd_display, style.dim }); - if (self.last_context_tokens > 0) { + var ctxbuf: [80]u8 = undefined; + var used_buf: [24]u8 = undefined; + const ctx: []const u8 = if (self.last_context_tokens > 0) blk: { const threshold = self.provider.compactAt(); const pct = if (self.provider.context > 0) self.last_context_tokens * 100 / self.provider.context else 0; - var used_buf: [24]u8 = undefined; - try w.print(" · {s}/{d}k ctx ({d}% · compact@{d}k){s}{s}]{s} {s}›{s} ", .{ + break :blk std.fmt.bufPrint(&ctxbuf, " · {s}/{d}k ctx ({d}% · compact@{d}k)", .{ compactTokenCount(&used_buf, self.last_context_tokens), self.provider.context / 1000, pct, threshold / 1000, - cached, - cost, - style.reset, - style.bold, - style.reset, - }); - } else { - try w.print("{s}]{s} {s}›{s} ", .{ cost, style.reset, style.bold, style.reset }); - } + }) catch ""; + } else ""; + + // Budget the status line against terminal width so a narrow pane never + // soft-wraps mid-badge (splitting e.g. `codex`) or strands the cursor. + // The model plus the settings that disambiguate the cursor (effort, + // provider, mode badges) are offered first; cwd/context/cache/cost are + // the first to be dropped when they no longer fit. readline.zig then + // gives the input its own row if what remains is still cramped. #209 + // + // Reserve the fixed frame ('[' + '] › ' = 5 cols) plus 1 column of slack + // so a width miscount on an exotic glyph can't tip the line into a wrap. + const cols = terminal.termCols(); + const avail = if (cols > 6) cols - 6 else 0; + var used: usize = terminal.dispWidth(self.provider.model); + // A leading " · " separator is 3 columns; cwd's " · cwd " label is 7. + const show_fast = self.fast and self.provider.kind == .responses and + fitsSegment(&used, avail, 3 + terminal.dispWidth("Fast")); + const show_effort = self.effortApplies() and + fitsSegment(&used, avail, 3 + terminal.dispWidth(reasoningPromptLabel(self.reasoning))); + const show_provider = fitsSegment(&used, avail, 3 + terminal.dispWidth(self.provider.id)); + const show_fallback = self.fallback_active and + fitsSegment(&used, avail, 3 + terminal.dispWidth("Fallback")); + const show_plan = main_mod.plan_mode and fitsSegment(&used, avail, 3 + terminal.dispWidth("Plan")); + const show_strict = self.strict and fitsSegment(&used, avail, 3 + terminal.dispWidth("Strict")); + const show_ultra = self.ultracode_mode and fitsSegment(&used, avail, 3 + terminal.dispWidth("Ultracode")); + // The context meter outranks cwd for the budget (it is the urgent, + // changing signal near the compaction threshold) but still renders after + // cwd below, preserving the familiar left-to-right order when both fit. + const show_ctx = ctx.len > 0 and fitsSegment(&used, avail, terminal.dispWidth(ctx)); + const show_cwd = fitsSegment(&used, avail, 7 + terminal.dispWidth(main_mod.g_cwd_display)); + const show_cached = cached.len > 0 and fitsSegment(&used, avail, terminal.dispWidth(cached)); + const show_cost = cost.len > 0 and fitsSegment(&used, avail, terminal.dispWidth(cost)); + + try w.print("\n{s}[{s}{s}{s}{s}", .{ style.dim, style.reset, style.cyan, self.provider.model, style.reset }); + // Fast is the most operationally important model setting, so keep it + // immediately beside the model instead of letting permission modes + // push it deeper into the status line. + if (show_fast) try writePromptBadge(w, style.green, "Fast"); + if (show_effort) try writePromptBadge(w, reasoningPromptColor(self.reasoning), reasoningPromptLabel(self.reasoning)); + if (show_provider) try writePromptBadge(w, style.cyan, self.provider.id); + if (show_fallback) try writePromptBadge(w, style.yellow, "Fallback"); + if (show_plan) try writePromptBadge(w, style.yellow, "Plan"); + if (show_strict) try writePromptBadge(w, style.red, "Strict"); + if (show_ultra) try writePromptBadge(w, style.magenta, "Ultracode"); + try w.print("{s}", .{style.dim}); + if (show_cwd) try w.print(" · cwd {s}{s}{s}", .{ style.reset, main_mod.g_cwd_display, style.dim }); + if (show_ctx) try w.print("{s}", .{ctx}); + if (show_cached) try w.print("{s}", .{cached}); + if (show_cost) try w.print("{s}", .{cost}); + try w.print("{s}]{s} {s}›{s} ", .{ style.reset, style.reset, style.bold, style.reset }); try w.flush(); } diff --git a/src/repl.zig b/src/repl.zig index 1189dbc1..972bd06e 100644 --- a/src/repl.zig +++ b/src/repl.zig @@ -28,6 +28,7 @@ const model_turn = @import("repl_model_turn.zig"); const model_commands = @import("repl_model_commands.zig"); const model_render = @import("repl_model_render.zig"); const repl_run = @import("repl_run.zig"); +const term = @import("term.zig"); test { _ = parser_mod; @@ -489,50 +490,11 @@ fn plainInline(a: std.mem.Allocator, line: []const u8) ![]const u8 { return out.items; } -/// Display columns for one codepoint (wcwidth-style, #142): 0 for combining / -/// zero-width marks, 2 for East-Asian wide + emoji, 1 otherwise. Approximate — -/// covers the ranges that misalign TUI table borders (CJK, Hangul, kana, -/// fullwidth forms, common emoji planes). Emoji-presentation via a VS16 (U+FE0F) -/// on a text-default base is not width-promoted. ASCII/Latin-1 skip the table. -fn codepointWidth(cp: u21) usize { - if (cp < 0x300) return 1; // ASCII + Latin-1 (control bytes stay 1, as before) - if ((cp >= 0x300 and cp <= 0x36F) or // combining diacritical marks - (cp >= 0x200B and cp <= 0x200F) or // ZWSP..RLM - (cp >= 0xFE00 and cp <= 0xFE0F) or // variation selectors - cp == 0xFEFF) return 0; // BOM / ZWNBSP - if ((cp >= 0x1100 and cp <= 0x115F) or // Hangul Jamo - (cp >= 0x2E80 and cp <= 0x303E) or // CJK radicals .. symbols - (cp >= 0x3041 and cp <= 0x33FF) or // kana .. CJK compat - (cp >= 0x3400 and cp <= 0x4DBF) or // CJK ext A - (cp >= 0x4E00 and cp <= 0x9FFF) or // CJK unified - (cp >= 0xA000 and cp <= 0xA4CF) or // Yi - (cp >= 0xAC00 and cp <= 0xD7A3) or // Hangul syllables - (cp >= 0xF900 and cp <= 0xFAFF) or // CJK compat ideographs - (cp >= 0xFE30 and cp <= 0xFE4F) or // CJK compat forms - (cp >= 0xFF00 and cp <= 0xFF60) or // fullwidth forms - (cp >= 0xFFE0 and cp <= 0xFFE6) or // fullwidth signs - (cp >= 0x1F000 and cp <= 0x1F02F) or // mahjong tiles - (cp >= 0x1F0A0 and cp <= 0x1F0FF) or // playing cards - (cp >= 0x1F100 and cp <= 0x1F1FF) or // enclosed alphanumeric / regional - (cp >= 0x1F300 and cp <= 0x1FAFF) or // emoji & pictographs - (cp >= 0x20000 and cp <= 0x3FFFD)) return 2; // CJK ext B+ - return 1; -} - +/// Display columns of a UTF-8 string (wcwidth-style, #142). The width table now +/// lives in term.zig so the TUI tables here and the narrow-prompt budgeting +/// (#209) share one definition; delegate so the call sites below stay unqualified. fn dispWidth(s: []const u8) usize { - var n: usize = 0; - var i: usize = 0; - while (i < s.len) { - const len = std.unicode.utf8ByteSequenceLength(s[i]) catch { - i += 1; - n += 1; - continue; - }; - const end = @min(i + len, s.len); - n += if (std.unicode.utf8Decode(s[i..end])) |cp| codepointWidth(cp) else |_| 1; - i = end; - } - return n; + return term.dispWidth(s); } test "dispWidth: East-Asian wide, combining, emoji (#142)" { @@ -565,7 +527,7 @@ fn wrapCell(a: std.mem.Allocator, s: []const u8, w: usize) ![]const []const u8 { var cw: usize = 0; while (bytes < rem.len) { const clen = @min(std.unicode.utf8ByteSequenceLength(rem[bytes]) catch 1, rem.len - bytes); - const cpw = if (std.unicode.utf8Decode(rem[bytes .. bytes + clen])) |cp| codepointWidth(cp) else |_| 1; + const cpw = if (std.unicode.utf8Decode(rem[bytes .. bytes + clen])) |cp| term.codepointWidth(cp) else |_| 1; if (cw + cpw > w and bytes > 0) break; // stop before overflowing the column (>=1 char guaranteed) bytes += clen; cw += cpw; @@ -633,7 +595,6 @@ fn renderRecords(out: *std.array_list.Managed(u8), a: std.mem.Allocator, rows: [ } } - /// Per-column alignment parsed from a table's `:---:` separator row (#143). const TableAlign = enum { left, center, right }; diff --git a/src/term.zig b/src/term.zig index 44385be3..34c23dd2 100644 --- a/src/term.zig +++ b/src/term.zig @@ -211,6 +211,56 @@ pub fn termRows() usize { return tty.rows(); } +/// Display columns of a UTF-8 string: sums per-codepoint widths (wcwidth-style) +/// so East-Asian wide forms and emoji count as 2, combining / zero-width marks +/// as 0, everything else as 1. The shared width primitive for budgeting status +/// and prompt lines and TUI table cells against termCols(). (#142, #209) +pub fn dispWidth(s: []const u8) usize { + var n: usize = 0; + var i: usize = 0; + while (i < s.len) { + const len = std.unicode.utf8ByteSequenceLength(s[i]) catch { + i += 1; + n += 1; + continue; + }; + const end = @min(i + len, s.len); + n += if (std.unicode.utf8Decode(s[i..end])) |cp| codepointWidth(cp) else |_| 1; + i = end; + } + return n; +} + +/// Display columns for one codepoint (wcwidth-style, #142): 0 for combining / +/// zero-width marks, 2 for East-Asian wide + emoji, 1 otherwise. Approximate — +/// covers the ranges that misalign TUI table borders (CJK, Hangul, kana, +/// fullwidth forms, common emoji planes). Emoji-presentation via a VS16 (U+FE0F) +/// on a text-default base is not width-promoted. ASCII/Latin-1 skip the table. +pub fn codepointWidth(cp: u21) usize { + if (cp < 0x300) return 1; // ASCII + Latin-1 (control bytes stay 1, as before) + if ((cp >= 0x300 and cp <= 0x36F) or // combining diacritical marks + (cp >= 0x200B and cp <= 0x200F) or // ZWSP..RLM + (cp >= 0xFE00 and cp <= 0xFE0F) or // variation selectors + cp == 0xFEFF) return 0; // BOM / ZWNBSP + if ((cp >= 0x1100 and cp <= 0x115F) or // Hangul Jamo + (cp >= 0x2E80 and cp <= 0x303E) or // CJK radicals .. symbols + (cp >= 0x3041 and cp <= 0x33FF) or // kana .. CJK compat + (cp >= 0x3400 and cp <= 0x4DBF) or // CJK ext A + (cp >= 0x4E00 and cp <= 0x9FFF) or // CJK unified + (cp >= 0xA000 and cp <= 0xA4CF) or // Yi + (cp >= 0xAC00 and cp <= 0xD7A3) or // Hangul syllables + (cp >= 0xF900 and cp <= 0xFAFF) or // CJK compat ideographs + (cp >= 0xFE30 and cp <= 0xFE4F) or // CJK compat forms + (cp >= 0xFF00 and cp <= 0xFF60) or // fullwidth forms + (cp >= 0xFFE0 and cp <= 0xFFE6) or // fullwidth signs + (cp >= 0x1F000 and cp <= 0x1F02F) or // mahjong tiles + (cp >= 0x1F0A0 and cp <= 0x1F0FF) or // playing cards + (cp >= 0x1F100 and cp <= 0x1F1FF) or // enclosed alphanumeric / regional + (cp >= 0x1F300 and cp <= 0x1FAFF) or // emoji & pictographs + (cp >= 0x20000 and cp <= 0x3FFFD)) return 2; // CJK ext B+ + return 1; +} + /// Advance (rows, col) as `chunk` of plain text is printed in a `cols`-wide /// terminal: hard newlines and soft wraps each start a new row, and UTF-8 /// continuation bytes share a glyph cell so they do not advance the column.