diff --git a/clients/terminal/tui/renderers/diff.py b/clients/terminal/tui/renderers/diff.py index dc04252..b8db8b1 100644 --- a/clients/terminal/tui/renderers/diff.py +++ b/clients/terminal/tui/renderers/diff.py @@ -14,8 +14,13 @@ # ``{marker} {num}│ {content}`` — the line-number prefix the server # (``navi/tools/filesystem.py:_number_diff``) prepends to each diff content line. -# A fixed space follows the marker, then the right-aligned number and ``│ ``. -_DIFF_NUM_RE = re.compile(r"^ (\d+)│ ?(.*)$", re.DOTALL) +# A fixed space follows the marker, then the right-aligned number (with its own +# leading spaces, one per digit of width) and ``│ ``. We capture the number's +# leading spaces separately so the column stays right-aligned when we render +# the number first (``{num} {marker}``) instead of the server's marker-first +# order — and so files with more than 9 lines (width > 1) still match, which the +# old single-space regex silently dropped, falling back to whole-line colour. +_DIFF_NUM_RE = re.compile(r"^( +)(\d+)│ ?(.*)$", re.DOTALL) def _diff_marker_style(marker: str, theme: Theme) -> str: @@ -30,10 +35,13 @@ """Color a unified-diff string: ``+`` green, ``-`` red, ``@@`` dim, rest text. When a content line carries a server-prefixed line number - (``{marker} {num}│ {content}``), the ``{marker} {num}│`` column is rendered - dim and only the content takes the marker color — so the number reads as - meta, not as part of the added/removed text. Lines without the prefix (e.g. - the standalone ``diff`` event, or older callers) are colored whole, as before. + (``{marker} {num}│ {content}``), the number and marker are shown in the + marker color (green/red) and the content is rendered neutral (``theme.text``) + — the number and marker act as a coloured anchor, the code itself reads + plainly. The display order is ``{num} {marker} {content}`` (number first); + the server still emits marker-first in the model-facing text, so the agent's + contract is unchanged. Lines without the prefix (e.g. the standalone + ``diff`` event, or older callers) are colored whole, as before. Shared by the standalone ``diff`` event renderer and the ``filesystem`` tool-call renderer (``edit_lines`` / ``smart_edit`` / ``diff`` actions embed @@ -53,8 +61,15 @@ rest = line[1:] m = _DIFF_NUM_RE.match(rest) if m: - out.append(f"{marker} {m.group(1)}│ ", style=theme.text_dim.hex) - out.append(m.group(2), style=_diff_marker_style(marker, theme)) + # {leading}{num} keeps the server's right-alignment (leading spaces + # come from {num:>width}); we then show the marker and two spaces + # before the content. Number+marker in the marker colour, content + # neutral. + out.append( + f"{m.group(1)}{m.group(2)} {marker} ", + style=_diff_marker_style(marker, theme), + ) + out.append(m.group(3), style=theme.text.hex) else: out.append(line, style=_diff_marker_style(marker, theme)) return out diff --git a/clients/terminal/tui/renderers/filesystem.py b/clients/terminal/tui/renderers/filesystem.py index 24f83bf..8ad364f 100644 --- a/clients/terminal/tui/renderers/filesystem.py +++ b/clients/terminal/tui/renderers/filesystem.py @@ -170,16 +170,22 @@ idx = 2 # Body: file contents (no syntax highlighting — rejected by design). + # Default matches FilesystemTool._read (numbered=true) so a read the model + # issued without an explicit `numbered` arg still gets the number column + # highlighted when the tool numbered the output. The line number is the + # accent colour (an anchor for navigation); the content is neutral text — + # the same "number coloured, content neutral" standard the diff renderer + # uses. body = lines[idx:] if body: out.append("\n") - if args.get("numbered", False): + if args.get("numbered", True): for j, ln in enumerate(body): if j: out.append("\n") num, sep2, content = ln.partition(": ") if sep2: - out.append(num + ": ", style=theme.text_dim.hex) + out.append(num + ": ", style=theme.accent.hex) out.append(content, style=theme.text.hex) else: out.append(ln, style=theme.text.hex) diff --git a/tests/clients/test_filesystem_renderer.py b/tests/clients/test_filesystem_renderer.py index 62d5be3..7ef71bc 100644 --- a/tests/clients/test_filesystem_renderer.py +++ b/tests/clients/test_filesystem_renderer.py @@ -176,8 +176,11 @@ def test_diff_line_number_column_is_dim() -> None: - """Server-prefixed ``{marker} {num}│`` column renders dim; content keeps the - marker color.""" + """Server-prefixed ``{marker} {num}│ {content}`` lines render with the + number+marker in the marker colour (green/red) and the content neutral + (theme.text) — the number is a coloured anchor, the code reads plainly. + Display order is ``{num} {marker} {content}``; the server still emits + marker-first in the model-facing text, so the agent's contract is unchanged.""" set_active_theme("gnexus-dark") theme = get_active_theme() renderer = FilesystemToolResultRenderer() @@ -193,17 +196,46 @@ } body = _body(renderer.render(msg)) assert isinstance(body, Text) - # The number prefix is dim (marker + number + separator). - assert _span_styles_at(body, "- 2│") == {theme.text_dim.hex} - assert _span_styles_at(body, "+ 2│") == {theme.text_dim.hex} - # The content after the prefix keeps the marker color. - assert _span_styles_at(body, "BETA") == {theme.success.hex} - assert _span_styles_at(body, "beta") == {theme.error.hex} + # The number+marker anchor takes the marker colour (red for removed). + assert _span_styles_at(body, "2 - ") == {theme.error.hex} + # ...and green for added. + assert _span_styles_at(body, "2 + ") == {theme.success.hex} + # The content after the anchor is neutral (theme.text), not the marker colour. + assert _span_styles_at(body, "BETA") == {theme.text.hex} + assert _span_styles_at(body, "beta") == {theme.text.hex} + assert _span_styles_at(body, "alpha") == {theme.text.hex} # Hunk header is dim, file headers are plain text. assert theme.text_dim.hex in _span_styles_at(body, "@@ -1,3 +1,3 @@") -def test_edit_without_metadata_falls_back_to_plain() -> None: +def test_diff_numbered_lines_beyond_nine_rows_parses_width() -> None: + """The server right-aligns line numbers to the max width, so a diff of a + >9-line file has multi-space leading on single-digit rows. The number regex + must still match these (the old single-space regex dropped them to the + fallback path and coloured the whole line). Number+marker keep the marker + colour; content is neutral.""" + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "diff"}, + "result": ( + "--- a.py\n+++ b.py\n@@ -1,12 +1,12 @@\n" + " 1│ a\n 10│ j\n 11│ k\n- 12│ old\n+ 12│ NEW\n" + ), + "success": True, + } + body = _body(renderer.render(msg)) + assert isinstance(body, Text) + # Single-digit row (1) and double-digit rows (12) both parse: the anchor + # (num+marker) carries the marker colour, not the whole line. + assert _span_styles_at(body, " 12 - ") == {theme.error.hex} + assert _span_styles_at(body, " 12 + ") == {theme.success.hex} + # Content is neutral. + assert _span_styles_at(body, "old") == {theme.text.hex} + assert _span_styles_at(body, "NEW") == {theme.text.hex} set_active_theme("gnexus-dark") theme = get_active_theme() renderer = FilesystemToolResultRenderer() @@ -520,8 +552,9 @@ } body = _body(renderer.render(msg)) assert isinstance(body, Text) - # Line numbers are dim, line content is text. - assert _span_styles_at(body, "1") == {theme.text_dim.hex} + # Line numbers are the accent colour (navigation anchor); content is neutral. + assert theme.accent.hex in _span_styles_at(body, "1: ") + assert theme.accent.hex in _span_styles_at(body, "2: ") assert _span_styles_at(body, "foo") == {theme.text.hex} assert _span_styles_at(body, "bar") == {theme.text.hex} diff --git a/webclient/src/components/messages/ToolCard.vue b/webclient/src/components/messages/ToolCard.vue index 2b19ab9..14e9bc4 100644 --- a/webclient/src/components/messages/ToolCard.vue +++ b/webclient/src/components/messages/ToolCard.vue @@ -34,7 +34,7 @@
Result
-
+
@@ -146,7 +146,7 @@ return formatCompactJson(val) } -function renderResult(val) { +function renderResult(val, tool) { if (typeof val === 'string') { // Try JSON first try { @@ -158,6 +158,19 @@ return renderMarkdown(val) } + // Diff output (filesystem diff/edit_lines/smart_edit) gets a compact, + // marker-coloured rendering instead of the roomy per-line result-line divs. + if (tool && tool.name === 'filesystem' + && ['diff', 'edit_lines', 'smart_edit'].includes(tool.args?.action)) { + return renderDiff(val) + } + + // Filesystem read: header plaque + optional warning + numbered body, with + // the line number in the accent colour (matches the TUI _render_read). + if (tool && tool.name === 'filesystem' && tool.args?.action === 'read') { + return renderRead(val) + } + return escapeHtml(val) .split('\n') .map((line, i) => `
${line || ' '}
`) @@ -166,6 +179,110 @@ return formatCompactJson(val) } +// Render a unified-diff string. edit_lines/smart_edit produce "summary\n\n"; +// the diff action is hunks only. The server prefixes each content line with its +// file line number as "{marker} {num}│ {content}". We render the number+marker +// in the marker colour (green/red) and the content neutral — the number is a +// coloured anchor, the code reads plainly (standard, matches the TUI renderer +// in renderers/diff.py). Lines without the prefix (hunk headers, file headers, +// older callers) fall back to whole-line colouring. +function renderDiff(val) { + let summary = '' + let body = val + const sep = val.indexOf('\n\n') + if (sep !== -1) { + const after = val.slice(sep + 2) + if (after.includes('@@') || /^[ +\-]/m.test(after)) { + summary = val.slice(0, sep) + body = after + } + } + const parts = [] + if (summary) { + parts.push(`
${escapeHtml(summary) || ' '}
`) + } + for (const line of body.split('\n')) { + parts.push(renderDiffLine(line)) + } + return parts.join('') +} + +// "{marker}{leading}{num}│ {content}" — marker is +/-/space, leading is the +// number's right-align padding, num the file line, content the rest. The match +// keeps leading so the number column stays aligned when shown first. +const _DIFF_NUM_RE = /^([ +\-])( +)(\d+)│ ?(.*)$/ + +function renderDiffLine(line) { + const m = _DIFF_NUM_RE.exec(line) + if (m) { + const marker = m[1] + const cls = marker === '+' ? 'diff-add' : marker === '-' ? 'diff-del' : 'diff-ctx' + const anchor = `${m[2]}${m[3]} ${marker} ` + const content = m[4] + return `
${escapeHtml(anchor)}${escapeHtml(content) || ' '}
` + } + const cls = diffLineClass(line) + return `
${escapeHtml(line) || ' '}
` +} + +function diffLineClass(line) { + if (line.startsWith('@@')) return 'diff-hunk' + if (line.startsWith('+')) return 'diff-add' + if (line.startsWith('-')) return 'diff-del' + return 'diff-ctx' +} + +// Render a filesystem `read` result. Layout: a header plaque +// "[path | N lines | size]" (or "[path | lines A–B of N | size]"), an +// optional "⚠ Large file …" warning line, then the numbered body +// "{num:>width}: {line}". The line number is the accent colour (a navigation +// anchor); the content is neutral — the same "number coloured, content neutral" +// standard the diff renderer uses and matching the TUI _render_read. +const _READ_NUM_RE = /^(\s*)(\d+): (.*)$/ + +function renderRead(val) { + const lines = val.split('\n') + const parts = [] + let idx = 0 + + // Header plaque: path in accent, the "N lines | size" tail dim. + if (lines.length && lines[0].startsWith('[')) { + const header = lines[0] + const inner = header.replace(/^\[/, '').replace(/\]$/, '') + const sepIdx = inner.indexOf(' | ') + if (sepIdx !== -1) { + const pathPart = inner.slice(0, sepIdx) + const rest = inner.slice(sepIdx + 5) // length of " | " + parts.push(`
${escapeHtml(pathPart)} | ${escapeHtml(rest)}
`) + } else { + parts.push(`
${escapeHtml(header) || ' '}
`) + } + idx = 1 + } + + // Optional "⚠ Large file …" warning line (warning colour). + if (idx < lines.length && lines[idx].startsWith('⚠')) { + parts.push(`
${escapeHtml(lines[idx]) || ' '}
`) + idx += 1 + } + + // Body: "{num:>width}: {line}" — number accent, content neutral. + for (; idx < lines.length; idx++) { + const ln = lines[idx] + if (ln === '') { + parts.push('
 
') + continue + } + const m = _READ_NUM_RE.exec(ln) + if (m) { + parts.push(`
${escapeHtml(m[1] + m[2])}: ${escapeHtml(m[3])}
`) + } else { + parts.push(`
${escapeHtml(ln)}
`) + } + } + return parts.join('') +} + function formatCompactJson(val) { if (typeof val === 'string') { try { val = JSON.parse(val) } catch { return escapeHtml(val) } diff --git a/webclient/src/styles/app.scss b/webclient/src/styles/app.scss index c6f1425..5af2ee6 100644 --- a/webclient/src/styles/app.scss +++ b/webclient/src/styles/app.scss @@ -1166,6 +1166,43 @@ word-break: break-word; } +// Diff output (filesystem diff/edit_lines/smart_edit) — rendered line-by-line +// with a tight line-height so consecutive diff lines don't show the wide +// vertical gaps the generic result-line divs (line-height:1.5 + padding:1px 0) +// produce. +.result-line.diff-summary, +.result-line.diff-hunk, +.result-line.diff-add, +.result-line.diff-del, +.result-line.diff-ctx { + padding: 0; + line-height: 1.3; +} +// Fallback colouring for whole lines without a server-prefixed number +// (hunk/file headers, or older callers). +.result-line.diff-summary { color: $color-text-medium; } +.result-line.diff-hunk { color: $color-text-medium; } +.result-line.diff-add { color: $color-success; } +.result-line.diff-del { color: $color-error; } +.result-line.diff-ctx { color: inherit; } + +// Numbered diff lines (the common case — the server prefixes "{marker} {num}│ +// {content}"): only the {num} {marker} anchor takes the marker colour; the +// content reads neutral. Matches the TUI renderer (renderers/diff.py). +.diff-num-marker.diff-add { color: $color-success; } +.diff-num-marker.diff-del { color: $color-error; } +.diff-num-marker.diff-ctx { color: inherit; } +.diff-content { color: inherit; } + +// Filesystem `read` output: header plaque (path accent, tail dim), optional +// ⚠ warning, then numbered body — the line number is the accent colour +// (navigation anchor), the content neutral. Matches the TUI _render_read. +.read-header-path { color: $color-accent; } +.read-header-rest { color: $color-text-medium; } +.read-warning { color: $color-warning; } +.read-num { color: $color-accent; } +.read-content { color: inherit; } + .json-object, .json-array { font-family: "IBM Plex Mono", monospace;