diff --git a/agent/display.py b/agent/display.py index 01267e91ea1..e6d26f1ce02 100644 --- a/agent/display.py +++ b/agent/display.py @@ -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": diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts index a378a40a2c5..2322d22d53a 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts @@ -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: '' }) }) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index 58c07317160..5305c594f96 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -165,6 +165,24 @@ function readFileLineLabel(args: Record, result: Record, result: Record): 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 = { 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) diff --git a/tests/agent/test_display.py b/tests/agent/test_display.py index 2e9afd20193..941cb526707 100644 --- a/tests/agent/test_display.py +++ b/tests/agent/test_display.py @@ -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):