fix(ui): share compact tool previews across clients

Move terminal/execute_code/read_file preview compaction into agent.display so CLI, gateway, and Ink TUI all inherit the same labels that desktop introduced in #52321.

The shared preview keeps raw args intact while trimming display-only shell plumbing (`cd`, pipe tails, banner/status echoes) and read_file line ranges. Desktop now prefers backend `context` for live rows and keeps its TypeScript fallback only for hydrated history.
This commit is contained in:
Brooklyn Nicholson 2026-06-25 00:10:19 -05:00
parent 3af22c0ed5
commit f3d6d9bbd3
4 changed files with 290 additions and 26 deletions

View file

@ -6,6 +6,7 @@ Used by AIAgent._execute_tool_calls for CLI feedback.
import logging
import os
import re
import sys
import threading
import time
@ -177,6 +178,167 @@ def _truncate_preview(text: str, max_len: int | None) -> str:
return text
_SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"}
_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"}
def _shell_basename(head: str) -> str:
return head.rsplit("/", 1)[-1] if head else ""
def _split_shell_words(segment: str) -> list[str]:
words: list[str] = []
buf: list[str] = []
quote: str | None = None
for i, ch in enumerate(segment):
if quote:
buf.append(ch)
if ch == quote and (i == 0 or segment[i - 1] != "\\"):
quote = None
continue
if ch in {"'", '"'}:
quote = ch
buf.append(ch)
continue
if ch.isspace():
if buf:
words.append("".join(buf))
buf = []
continue
buf.append(ch)
if buf:
words.append("".join(buf))
return words
def _strip_shell_pipe_tail(segment: str) -> str:
words = _split_shell_words(segment)
out: list[str] = []
for i, word in enumerate(words):
if word == "|" and _shell_basename(words[i + 1] if i + 1 < len(words) else "") in _SHELL_PIPE_TAIL_HEADS:
break
out.append(word)
return " ".join(out).strip()
def _split_shell_compound(command: str) -> list[str]:
segments: list[str] = []
buf: list[str] = []
quote: str | None = None
i = 0
while i < len(command):
ch = command[i]
if quote:
buf.append(ch)
if ch == quote and (i == 0 or command[i - 1] != "\\"):
quote = None
i += 1
continue
if ch in {"'", '"'}:
quote = ch
buf.append(ch)
i += 1
continue
op_len = 2 if command.startswith("&&", i) or command.startswith("||", i) else 1 if ch in {";", "\n"} else 0
if op_len:
segment = _strip_shell_pipe_tail("".join(buf).strip())
if segment:
segments.append(segment)
buf = []
i += op_len
continue
buf.append(ch)
i += 1
segment = _strip_shell_pipe_tail("".join(buf).strip())
if segment:
segments.append(segment)
return segments
def _shell_head_word(segment: str) -> str:
words = _split_shell_words(segment)
index = 0
while index < len(words) and re.match(r"^[A-Za-z_]\w*=", words[index]):
index += 1
return _shell_basename(words[index] if index < len(words) else "")
def _clean_shell_segment(segment: str) -> str:
words = _split_shell_words(segment)
out: list[str] = []
i = 0
while i < len(words):
word = words[i]
if re.match(r"^\d*(?:>>?|<)$", word):
i += 2
continue
if re.match(r"^\d*(?:>&|<&)\d+$", word) or re.match(r"^\d*>&\d+$", word):
i += 1
continue
out.append(word)
i += 1
return " ".join(out).strip()
def _is_shell_boundary_echo(segment: str) -> bool:
words = _split_shell_words(segment)
if _shell_basename(words[0] if words else "") != "echo":
return False
rest = " ".join(words[1:])
return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", rest))
def summarize_shell_command(command: str) -> str:
"""Compact shell wrapper/plumbing for display while preserving raw command elsewhere."""
original = _oneline(command)
if not original:
return ""
segments = _split_shell_compound(original)
if len(segments) <= 1:
return _clean_shell_segment(segments[0] if segments else original) or original
core: list[str] = []
for segment in segments:
cleaned = _clean_shell_segment(segment)
head = _shell_head_word(cleaned)
if cleaned and head not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned):
core.append(cleaned)
if not core:
return original
if len(core) == 1:
return core[0]
count = len(core) - 1
return f"{core[0]} + {count} {'command' if count == 1 else 'commands'}"
def _read_file_line_label(args: dict) -> str:
offset = args.get("offset")
limit = args.get("limit")
if not isinstance(offset, int) or offset <= 0:
return ""
if not isinstance(limit, int) or limit <= 1:
return f"L{offset}"
return f"L{offset}-{offset + limit - 1}"
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
if not isinstance(tasks, list):
return 0, []
@ -253,6 +415,23 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
else:
return f"planning {len(todos_arg)} task(s)"
if tool_name in {"terminal", "execute_code"}:
key = "code" if tool_name == "execute_code" else "command"
command = args.get(key)
if command is None:
return None
preview = summarize_shell_command(str(command))
return _truncate_preview(preview, max_len) if preview else None
if tool_name == "read_file":
path = args.get("path") or args.get("file") or args.get("filepath")
if path is None:
return None
label = Path(str(path).replace("\\", "/")).name or str(path)
line_label = _read_file_line_label(args)
preview = f"{label} {line_label}".strip()
return _truncate_preview(preview, max_len) if preview else None
if tool_name == "session_search":
query = _oneline(args.get("query", ""))
return f"recall: \"{query[:25]}{'...' if len(query) > 25 else ''}\""
@ -943,7 +1122,7 @@ def get_cute_tool_message(
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
if tool_name == "terminal":
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
return _wrap(f"┊ 💻 $ {_trunc(build_tool_preview(tool_name, args) or args.get('command', ''), 42)} {dur}")
if tool_name == "process":
action = args.get("action", "?")
sid = args.get("session_id", "")[:12]
@ -951,7 +1130,7 @@ def get_cute_tool_message(
"wait": f"wait {sid}", "kill": f"kill {sid}", "write": f"write {sid}", "submit": f"submit {sid}"}
return _wrap(f"┊ ⚙️ proc {labels.get(action, f'{action} {sid}')} {dur}")
if tool_name == "read_file":
return _wrap(f"┊ 📖 read {_path(args.get('path', ''))} {dur}")
return _wrap(f"┊ 📖 read {_trunc(build_tool_preview(tool_name, args) or args.get('path', ''), 42)} {dur}")
if tool_name == "write_file":
return _wrap(f"┊ ✍️ write {_path(args.get('path', ''))} {dur}")
if tool_name == "patch":

View file

@ -136,8 +136,8 @@ describe('buildToolView title actions', () => {
''
)
expect(read.title).toBe('Reading file')
expect(read.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' file' })
expect(read.title).toBe('Reading demo.txt')
expect(read.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' demo.txt' })
expect(web.title).toBe('Reading example.com/docs')
expect(web.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' example.com/docs' })
expect(terminal.title).toBe('Running npm test -- --runInBand')
@ -191,6 +191,20 @@ describe('buildToolView title actions', () => {
expect(view.title).toBe('Read package.json L1-5')
})
it('uses inherited backend context for live read_file rows', () => {
const view = buildToolView(
part({
args: { context: 'package.json L1-5', path: './package.json' },
result: undefined,
toolName: 'read_file'
}),
''
)
expect(view.title).toBe('Reading package.json L1-5')
expect(view.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' package.json L1-5' })
})
it('uses returned line numbers for negative-offset read_file rows', () => {
const view = buildToolView(
part({
@ -239,6 +253,24 @@ describe('buildToolView title actions', () => {
}
})
it('uses inherited backend context for live terminal rows', () => {
const view = buildToolView(
part({
args: {
command: 'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20',
context: 'pnpm run lint'
},
result: undefined,
toolName: 'terminal'
}),
''
)
expect(view.title).toBe('Running pnpm run lint')
expect(view.subtitle).toBe('')
expect(view.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' pnpm run lint' })
})
it('uses the runtime locale for title text and action placement', () => {
setRuntimeI18nLocale('ja')
@ -249,8 +281,8 @@ describe('buildToolView title actions', () => {
''
)
expect(read.title).toBe('ファイルを読み取り中')
expect(read.titleAction).toEqual({ prefix: 'ファイルを', text: '読み取り中', suffix: '' })
expect(read.title).toBe('demo.txt を読み取り中')
expect(read.titleAction).toEqual({ prefix: 'demo.txt を', text: '読み取り中', suffix: '' })
expect(web.title).toBe('example.com/docs を読み取り中')
expect(web.titleAction).toEqual({ prefix: 'example.com/docs を', text: '読み取り中', suffix: '' })
})

View file

@ -165,6 +165,24 @@ function readFileLineLabel(args: Record<string, unknown>, result: Record<string,
return start === end ? `L${start}` : `L${start}-${end}`
}
function readFileDisplayTarget(args: Record<string, unknown>, result: Record<string, unknown>): string {
const inherited = firstStringField(args, ['context', 'preview'])
if (inherited) {
return inherited
}
const path = firstStringField(args, ['path', 'file', 'filepath'])
if (!path) {
return ''
}
const lineLabel = readFileLineLabel(args, result)
return [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
}
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
browser_click: {
icon: 'globe',
@ -1220,9 +1238,9 @@ function toolSubtitle(
}
}
const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord)
const command = firstStringField(argsRecord, ['context', 'preview', 'command', 'code']) || contextValue(argsRecord)
return command ? compactPreview(summarizeShellCommand(command), 120) : 'Executed command'
return command ? '' : 'Executed command'
}
if (toolName === 'read_file' || isFileEditTool(toolName)) {
@ -1543,8 +1561,18 @@ function dynamicTitle(
: fallback
}
if (part.toolName === 'read_file') {
const target = readFileDisplayTarget(args, result)
const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read'))
return target
? titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, target))
: fallback
}
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
const command =
firstStringField(args, ['context', 'preview']) || firstStringField(args, ['command', 'code']) || contextValue(args)
if (command) {
const action =
@ -1563,18 +1591,6 @@ function dynamicTitle(
}
}
if (part.toolName === 'read_file' && part.result !== undefined) {
const path = firstStringField(args, ['path', 'file', 'filepath'])
if (path) {
const lineLabel = readFileLineLabel(args, result)
const target = [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read'))
return { title: translateNow('assistant.tool.titleTemplates.actionTarget', action, target) }
}
}
if (isFileEditTool(part.toolName)) {
const path = fileEditPath(args, result)

View file

@ -40,6 +40,38 @@ class TestBuildToolPreview:
assert result is not None
assert "ls -la" in result
def test_terminal_preview_compacts_shell_plumbing(self):
result = build_tool_preview(
"terminal",
{
"command": (
'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 '
'| tail -20; echo "lint_exit=${PIPESTATUS[0]}"'
)
},
)
assert result == "pnpm run lint"
def test_terminal_preview_compacts_multi_command_probe(self):
result = build_tool_preview(
"terminal",
{
"command": (
'which node pnpm corepack; node -v; echo "---"; '
'corepack --version 2>&1; echo "---pnpm via corepack---"; '
'pnpm --version 2>&1 | tail -5'
)
},
)
assert result == "which node pnpm corepack + 3 commands"
def test_execute_code_preview_uses_same_shell_summary(self):
result = build_tool_preview(
"execute_code",
{"code": 'cd /tmp/demo && python -m pytest -q 2>&1 | tail -5; echo "exit=$?"'},
)
assert result == "python -m pytest -q"
def test_web_search_preview(self):
result = build_tool_preview("web_search", {"query": "hello world"})
assert result is not None
@ -48,7 +80,11 @@ class TestBuildToolPreview:
def test_read_file_preview(self):
result = build_tool_preview("read_file", {"path": "/tmp/test.py", "offset": 1})
assert result is not None
assert "/tmp/test.py" in result
assert result == "test.py L1"
def test_read_file_preview_includes_requested_line_range(self):
result = build_tool_preview("read_file", {"path": "./package.json", "offset": 1, "limit": 5})
assert result == "package.json L1-5"
def test_unknown_tool_with_fallback_key(self):
"""Unknown tool but with a recognized fallback key should still preview."""
@ -145,7 +181,8 @@ class TestCuteToolMessagePreviewLength:
line = get_cute_tool_message("terminal", {"command": command}, 0.1)
assert command in line
assert "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")'" in line
assert "head -5" not in line
assert "..." not in line
def test_terminal_preview_uses_positive_configured_limit(self):
@ -154,8 +191,8 @@ class TestCuteToolMessagePreviewLength:
line = get_cute_tool_message("terminal", {"command": command}, 0.1)
assert command[:77] in line
assert "..." in line
assert "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")'" in line
assert "..." not in line
assert "head -5" not in line
def test_search_files_preview_uses_positive_configured_limit_not_default(self):
@ -173,7 +210,7 @@ class TestCuteToolMessagePreviewLength:
line = get_cute_tool_message("read_file", {"path": path}, 0.1)
assert path in line
assert "test-output.txt" in line
assert "..." not in line
def test_write_file_lint_error_result_is_not_marked_failed(self):