Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions scripts/test-pty-narrow-prompt.py
Original file line number Diff line number Diff line change
@@ -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()
5 changes: 5 additions & 0 deletions scripts/test-pty-repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@ 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"],
cwd=tmp,
env=env,
unset_env=("CODEX_HOME", "NO_COLOR"),
timeout=15.0,
cols=200,
) as session:
session.wait_for_literal("] ›")

Expand Down
94 changes: 69 additions & 25 deletions src/agent.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 "?"
Expand Down Expand Up @@ -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();
}

Expand Down
51 changes: 6 additions & 45 deletions src/repl.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)" {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };

Expand Down
50 changes: 50 additions & 0 deletions src/term.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading