fix(code): make execute command expandable in code TUI transcript (#4428)

The code TUI now lets you click a long `execute` command in the
transcript to expand and view it in full.

---

In the `deepagents-code` TUI, long `execute` shell commands were
truncated at 120 chars in the transcript header with no way to see the
full text — clicking a row toggled the *output*, not the command. This
adds a collapsible full-command block that mirrors the existing
`js_eval` code block: clicking the command header (or its hint) expands
the full command, while clicking the output still toggles output.
`on_click` is now region-aware so both the command and an expandable
output can each be toggled independently.

Made by [Open
SWE](https://openswe.vercel.app/agents/de77dad1-3f8b-b84c-4d8e-553560c9db74)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-02 23:39:29 -04:00
committed by GitHub
parent 9ae927d73e
commit d999181e84
5 changed files with 363 additions and 19 deletions
+8 -3
View File
@@ -12125,12 +12125,17 @@ class DeepAgentsApp(App):
child.toggle_body()
return
if isinstance(child, ToolCallMessage) and not child.has_class("-grouped"):
if child.has_output and child.has_expandable_output:
child.toggle_output()
return
# Prefer the collapsible command/code block when the row has one,
# so Ctrl+O matches the "click or Ctrl+O to show command/code"
# hint rendered beside it. The output stays reachable by clicking
# its own region (see `ToolCallMessage.on_click`); rows without an
# expandable command/code block fall through to the output.
if child.has_expandable_args:
child.toggle_args()
return
if child.has_output and child.has_expandable_output:
child.toggle_output()
return
if child.has_output:
child.toggle_output()
return
+10 -1
View File
@@ -27,6 +27,13 @@ the "offer a collapsible code block" threshold stay in lock-step from a single
source of truth.
"""
EXECUTE_HEADER_MAX_LENGTH = 120
"""Width at which the `execute` header truncates the shell command.
Shared with `messages.py` so the header cutoff and the "offer a collapsible
command block" threshold stay in lock-step from a single source of truth.
"""
def _format_timeout(seconds: int) -> str:
"""Format timeout in human-readable units (e.g., 300 -> '5m', 3600 -> '1h').
@@ -217,7 +224,9 @@ def format_tool_display(tool_name: str, tool_args: dict) -> str:
elif tool_name == "execute":
# Execute: show the command, and timeout only if non-default
if "command" in tool_args:
command = _sanitize_display_value(tool_args["command"], max_length=120)
command = _sanitize_display_value(
tool_args["command"], max_length=EXECUTE_HEADER_MAX_LENGTH
)
timeout = _coerce_timeout_seconds(tool_args.get("timeout"))
from deepagents.backends import DEFAULT_EXECUTE_TIMEOUT
+108 -14
View File
@@ -32,6 +32,7 @@ from deepagents_code.config import (
from deepagents_code.formatting import format_duration
from deepagents_code.input import EMAIL_PREFIX_PATTERN, INPUT_HIGHLIGHT_PATTERN
from deepagents_code.tool_display import (
EXECUTE_HEADER_MAX_LENGTH,
JS_EVAL_HEADER_MAX_LENGTH,
format_tool_display,
)
@@ -1108,6 +1109,7 @@ class ToolCallMessage(Vertical):
self._reject_reason: str | None = None
# Widget references (set in on_mount)
self._status_widget: Static | None = None
self._header_widget: Static | None = None
self._args_widget: Static | None = None
self._args_hint_widget: Static | None = None
self._preview_widget: Static | None = None
@@ -1136,7 +1138,7 @@ class ToolCallMessage(Vertical):
Widgets for header, arguments, status, and output display.
"""
tool_label = format_tool_display(self._tool_name, self._args)
yield Static(tool_label, markup=False, classes="tool-header")
yield Static(tool_label, markup=False, classes="tool-header", id="tool-header")
# Task: dedicated description line (dim, truncated)
if self._tool_name == "task":
desc = self._args.get("description", "")
@@ -1193,6 +1195,7 @@ class ToolCallMessage(Vertical):
self.add_class("-ascii")
self._status_widget = self.query_one("#status", Static)
self._header_widget = self.query_one("#tool-header", Static)
self._args_widget = self.query_one("#args-full", Static)
self._args_hint_widget = self.query_one("#args-hint", Static)
self._preview_widget = self.query_one("#output-preview", Static)
@@ -1508,18 +1511,64 @@ class ToolCallMessage(Vertical):
def on_click(self, event: Click) -> None:
"""Toggle output/argument expansion.
Prefer toggling output, but only when the output can actually
expand/collapse. Otherwise fall through to the collapsible args/code
block — `js_eval` commonly has a short, unexpandable result sitting
A click on the header/args region (the truncated command or code line
and its hint) toggles the collapsible args/code block directly, so an
`execute` command or `js_eval` program can be expanded even when the
output below it is *also* expandable. Otherwise prefer toggling output,
falling through to the args/code block only when the output can't
expand — `js_eval` commonly has a short, unexpandable result sitting
below a multi-line, collapsible code block, and the old
"output wins whenever it exists" rule left that code block stuck.
"""
event.stop() # Prevent click from bubbling up and scrolling
if self._output and self.has_expandable_output:
if self.has_expandable_args and self._click_targets_args_region(event.widget):
self.toggle_args()
elif self._output and self.has_expandable_output:
self.toggle_output()
elif self.has_expandable_args:
self.toggle_args()
def _click_targets_args_region(self, widget: object) -> bool:
"""Whether a click landed on the header/args block (not the output).
Walks up from the clicked widget to `self`, matching the cached
header, collapsed-args, and args-hint widgets. The walk is bounded so a
mock or detached node (which never reaches `self` via `.parent`) returns
`False` instead of looping, preserving the generic "prefer output"
routing for those cases.
Returns:
`True` if the click landed on the header/args region.
"""
targets = tuple(
target
for target in (
self._header_widget,
self._args_widget,
self._args_hint_widget,
)
if target is not None
)
if not targets:
# A click can only arrive post-mount, where these refs are always
# cached, so an empty tuple means a regression nulled them out. Log
# it rather than silently routing every click to output (mirrors
# `_update_args_display`).
logger.debug("_click_targets_args_region: header/args refs not cached")
return False
# The header/args/hint widgets are direct children of `self` (a click on
# rendered text reports a descendant, so the real match depth is 0-1).
# 8 is generous headroom that also bounds the walk for a detached or mock
# node, whose `.parent` chain never reaches `self`.
node = widget
for _ in range(8):
if node is None or node is self:
return False
if any(node is target for target in targets):
return True
node = getattr(node, "parent", None)
return False
def _format_output(
self, output: str, *, is_preview: bool = False
) -> FormattedOutput:
@@ -2497,7 +2546,9 @@ class ToolCallMessage(Vertical):
# "click to collapse" there is misleading.
if self._has_expandable_output():
self._hint_widget.update(
Content.styled("click or Ctrl+O to collapse", "dim italic")
Content.styled(
f"{self._output_hint_keys()} to collapse", "dim italic"
)
)
self._hint_widget.display = True
else:
@@ -2520,7 +2571,9 @@ class ToolCallMessage(Vertical):
self._preview_row.display = False
ellipsis = get_glyphs().ellipsis
self._hint_widget.update(
Content.styled(f"{ellipsis} click or Ctrl+O to expand", "dim")
Content.styled(
f"{ellipsis} {self._output_hint_keys()} to expand", "dim"
)
)
self._hint_widget.display = True
return
@@ -2544,7 +2597,8 @@ class ToolCallMessage(Vertical):
ellipsis = get_glyphs().ellipsis
self._hint_widget.update(
Content.styled(
f"{ellipsis} {result.truncation}click or Ctrl+O to expand",
f"{ellipsis} {result.truncation}"
f"{self._output_hint_keys()} to expand",
"dim",
)
)
@@ -2552,6 +2606,21 @@ class ToolCallMessage(Vertical):
else:
self._hint_widget.display = False
def _output_hint_keys(self) -> str:
"""Affordances to advertise in the output expand/collapse hint.
Ctrl+O routes to the collapsible command/code block whenever this row
has one (see `action_toggle_tool_output`), so the output hint only
advertises Ctrl+O when Ctrl+O would actually toggle the *output*. When a
command/code block is present the output is reachable by clicking its
own region instead.
Returns:
`"click"` when an expandable command/code block owns Ctrl+O,
otherwise `"click or Ctrl+O"`.
"""
return "click" if self.has_expandable_args else "click or Ctrl+O"
@property
def has_output(self) -> bool:
"""Check if this tool message has output to display.
@@ -2596,6 +2665,10 @@ class ToolCallMessage(Vertical):
`JS_EVAL_HEADER_MAX_LENGTH`), so the full program is offered as a
collapsible block whenever it spans more than one non-blank line *or*
a single line is long enough to be truncated in the header.
- `execute`: the header truncates the shell command at
`EXECUTE_HEADER_MAX_LENGTH`, so the full command is offered as a
collapsible block when the command, after stripping surrounding
whitespace, is longer than `EXECUTE_HEADER_MAX_LENGTH`.
"""
if self._tool_name == "ask_user":
return bool(self._args)
@@ -2604,6 +2677,10 @@ class ToolCallMessage(Vertical):
if isinstance(code, str) and code.strip():
non_blank = sum(1 for line in code.splitlines() if line.strip())
return non_blank > 1 or len(code.strip()) > JS_EVAL_HEADER_MAX_LENGTH
if self._tool_name == "execute":
command = self._args.get("command")
if isinstance(command, str) and command.strip():
return len(command.strip()) > EXECUTE_HEADER_MAX_LENGTH
return False
def _format_code_detail(self) -> Content:
@@ -2626,6 +2703,22 @@ class ToolCallMessage(Vertical):
# line above and the "show/hide code" hint below.
return Content("\n").join((Content(""), Content(code_str), Content("")))
def _format_command_detail(self) -> Content:
"""Render the full `execute` command for the collapsible block.
The command is shown verbatim and left-aligned, as plain uncolored
`Content`, mirroring `_format_code_detail`. Hidden/deceptive Unicode is
rendered as visible markers so a truncated header can't conceal it.
Returns:
A plain `Content` renderable with a blank line of padding on
top and bottom.
"""
command = self._args.get("command")
command_str = command.strip("\n") if isinstance(command, str) else str(command)
command_str = render_with_unicode_markers(command_str)
return Content("\n").join((Content(""), Content(command_str), Content("")))
def _format_args_detail(self) -> Content:
"""Render tool arguments as an indented `Content` block.
@@ -2662,13 +2755,14 @@ class ToolCallMessage(Vertical):
self._args_hint_widget.display = False
return
is_code = self._tool_name == "js_eval"
noun = "code" if is_code else "arguments"
if self._tool_name == "js_eval":
noun, detail_fn = "code", self._format_code_detail
elif self._tool_name == "execute":
noun, detail_fn = "command", self._format_command_detail
else:
noun, detail_fn = "arguments", self._format_args_detail
if self._args_expanded:
detail = (
self._format_code_detail() if is_code else self._format_args_detail()
)
self._args_widget.update(detail)
self._args_widget.update(detail_fn())
self._args_widget.display = True
self._args_hint_widget.update(
Content.styled(f"click or Ctrl+O to hide {noun}", "dim italic")
+24
View File
@@ -3641,6 +3641,30 @@ class TestAskUserLifecycle:
tool.toggle_args.assert_called_once_with()
tool.toggle_output.assert_not_called()
def test_ctrl_o_prefers_command_when_both_expandable(self) -> None:
"""Ctrl+O toggles the command/code block even when output can also expand.
Matches the region-aware click routing and the "click or Ctrl+O to show
command/code" hint: the output stays reachable by clicking its own row.
"""
from deepagents_code.widgets.messages import ToolCallMessage
app = DeepAgentsApp(agent=MagicMock())
app._pending_ask_user_widget = None
tool = MagicMock(spec=ToolCallMessage)
tool.has_class.return_value = False
tool.has_output = True
tool.has_expandable_output = True # long stdout, expandable
tool.has_expandable_args = True # long command, expandable
container = MagicMock()
container.children = [tool]
with patch.object(app, "query_one", return_value=container):
app.action_toggle_tool_output()
tool.toggle_args.assert_called_once_with()
tool.toggle_output.assert_not_called()
def test_ctrl_o_prefers_more_recent_tool_in_dom_order(self) -> None:
"""The newest tool row in DOM order wins over an older one."""
from deepagents_code.widgets.messages import ToolCallMessage
+213 -1
View File
@@ -14,7 +14,10 @@ from textual.widgets import Markdown
from deepagents_code import theme
from deepagents_code.formatting import format_duration
from deepagents_code.input import INPUT_HIGHLIGHT_PATTERN
from deepagents_code.tool_display import JS_EVAL_HEADER_MAX_LENGTH
from deepagents_code.tool_display import (
EXECUTE_HEADER_MAX_LENGTH,
JS_EVAL_HEADER_MAX_LENGTH,
)
from deepagents_code.widgets.messages import (
AppMessage,
AssistantMessage,
@@ -1912,6 +1915,215 @@ class TestToolCallMessageExpandableArgs:
assert msg._args_expanded is False
class TestToolCallMessageExecuteCommandExpand:
"""Tests for the collapsible full-command block on `execute` tool calls."""
def test_long_command_is_expandable(self) -> None:
"""A command too long for the header offers a collapsible block."""
long_cmd = "echo " + "x" * EXECUTE_HEADER_MAX_LENGTH
msg = ToolCallMessage("execute", {"command": long_cmd})
assert msg.has_expandable_args is True
def test_short_command_not_expandable(self) -> None:
"""A command that fits in the header has nothing to expand."""
short_cmd = "x" * (EXECUTE_HEADER_MAX_LENGTH - 1)
msg = ToolCallMessage("execute", {"command": short_cmd})
assert msg.has_expandable_args is False
def test_missing_command_not_expandable(self) -> None:
"""An execute call without a command string is not expandable."""
assert ToolCallMessage("execute", {}).has_expandable_args is False
def test_command_detail_is_plain_and_left_aligned(self) -> None:
"""The command is plain `Content`, left-aligned, with blank padding."""
command = "cd /tmp && \\\n make build\nmake test"
msg = ToolCallMessage("execute", {"command": command})
detail = msg._format_command_detail()
assert isinstance(detail, Content)
assert not detail.spans
assert detail.plain.split("\n") == [
"",
"cd /tmp && \\",
" make build",
"make test",
"",
]
def test_command_detail_marks_hidden_unicode(self) -> None:
"""Hidden controls in the expanded command render as visible markers."""
msg = ToolCallMessage("execute", {"command": "echo safe\n#\u202e hidden"})
detail = msg._format_command_detail()
assert "\u202e" not in detail.plain
assert "<U+202E RIGHT-TO-LEFT OVERRIDE>" in detail.plain
async def test_click_on_header_toggles_command(self) -> None:
"""Clicking the command header expands the collapsible command block."""
long_cmd = "echo " + "x" * EXECUTE_HEADER_MAX_LENGTH
class _Harness(App[None]):
def __init__(self) -> None:
super().__init__()
self.msg = ToolCallMessage("execute", {"command": long_cmd})
def compose(self) -> ComposeResult:
yield self.msg
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause()
msg = app.msg
# Long stdout makes the output expandable too, so a generic click
# would prefer output; a header click must still reach the command.
msg.set_success("\n".join(str(i) for i in range(50)))
await pilot.pause()
assert msg.has_expandable_output is True
event = MagicMock()
event.widget = msg._header_widget
msg.on_click(event)
await pilot.pause()
event.stop.assert_called_once()
assert msg._args_expanded is True
assert msg._expanded is False
async def test_generic_click_still_prefers_output(self) -> None:
"""A click outside the header region toggles output, not the command."""
long_cmd = "echo " + "x" * EXECUTE_HEADER_MAX_LENGTH
class _Harness(App[None]):
def __init__(self) -> None:
super().__init__()
self.msg = ToolCallMessage("execute", {"command": long_cmd})
def compose(self) -> ComposeResult:
yield self.msg
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause()
msg = app.msg
msg.set_success("\n".join(str(i) for i in range(50)))
await pilot.pause()
assert msg.has_expandable_output is True
event = MagicMock() # mock widget is outside the args region
msg.on_click(event)
await pilot.pause()
assert msg._expanded is True
assert msg._args_expanded is False
@staticmethod
def _harness(msg: ToolCallMessage) -> App[None]:
"""Build a minimal single-widget app hosting `msg`."""
class _Harness(App[None]):
def compose(self) -> ComposeResult:
yield msg
return _Harness()
async def test_click_targets_args_region_walks_parents(self) -> None:
"""A click on a descendant of the header still routes to the command.
A real Textual click reports the rendered-text node *inside* the
`Static`, not the `Static` itself, so the region check must walk up the
`.parent` chain rather than compare identities directly.
"""
msg = ToolCallMessage("execute", {"command": "echo hi"})
app = self._harness(msg)
async with app.run_test() as pilot:
await pilot.pause()
# Direct hit, one hop, and two hops all resolve to the header.
one_hop = SimpleNamespace(parent=msg._header_widget)
two_hops = SimpleNamespace(parent=one_hop)
assert msg._click_targets_args_region(msg._header_widget) is True
assert msg._click_targets_args_region(one_hop) is True
assert msg._click_targets_args_region(two_hops) is True
# A detached node and `self` never match.
assert msg._click_targets_args_region(SimpleNamespace(parent=None)) is False
assert msg._click_targets_args_region(msg) is False
async def test_click_on_args_hint_toggles_command(self) -> None:
"""Clicking the args-hint line expands the command, not the output."""
long_cmd = "echo " + "x" * EXECUTE_HEADER_MAX_LENGTH
msg = ToolCallMessage("execute", {"command": long_cmd})
app = self._harness(msg)
async with app.run_test() as pilot:
await pilot.pause()
msg.set_success("\n".join(str(i) for i in range(50)))
await pilot.pause()
assert msg.has_expandable_output is True
event = MagicMock()
event.widget = msg._args_hint_widget
msg.on_click(event)
await pilot.pause()
assert msg._args_expanded is True
assert msg._expanded is False
async def test_click_on_expanded_command_collapses(self) -> None:
"""Clicking the expanded command block collapses it again."""
long_cmd = "echo " + "x" * EXECUTE_HEADER_MAX_LENGTH
msg = ToolCallMessage("execute", {"command": long_cmd})
app = self._harness(msg)
async with app.run_test() as pilot:
await pilot.pause()
msg.toggle_args()
await pilot.pause()
assert msg._args_expanded is True
event = MagicMock()
event.widget = msg._args_widget
msg.on_click(event)
await pilot.pause()
assert msg._args_expanded is False
async def test_expanded_command_uses_command_noun_and_detail(self) -> None:
"""Expanding wires the `command` noun and `_format_command_detail`."""
long_cmd = "echo " + "x" * EXECUTE_HEADER_MAX_LENGTH
msg = ToolCallMessage("execute", {"command": long_cmd})
app = self._harness(msg)
async with app.run_test() as pilot:
await pilot.pause()
msg.toggle_args()
await pilot.pause()
hint = msg._args_hint_widget._Static__content # ty: ignore
body = msg._args_widget._Static__content # ty: ignore
assert "hide command" in hint.plain
assert body.plain == msg._format_command_detail().plain
async def test_output_hint_omits_ctrl_o_when_command_expandable(self) -> None:
"""With an expandable command, Ctrl+O owns it, so the output hint drops it."""
long_cmd = "echo " + "x" * EXECUTE_HEADER_MAX_LENGTH
msg = ToolCallMessage("execute", {"command": long_cmd})
app = self._harness(msg)
async with app.run_test() as pilot:
await pilot.pause()
msg.set_success("\n".join(str(i) for i in range(50)))
await pilot.pause()
assert msg.has_expandable_args is True
hint = msg._hint_widget._Static__content # ty: ignore
assert "click to expand" in hint.plain
assert "Ctrl+O" not in hint.plain
async def test_output_hint_keeps_ctrl_o_without_expandable_command(self) -> None:
"""A short command leaves Ctrl+O on the output, so the hint keeps it."""
msg = ToolCallMessage("execute", {"command": "echo hi"})
app = self._harness(msg)
async with app.run_test() as pilot:
await pilot.pause()
msg.set_success("\n".join(str(i) for i in range(50)))
await pilot.pause()
assert msg.has_expandable_args is False
hint = msg._hint_widget._Static__content # ty: ignore
assert "Ctrl+O" in hint.plain
class TestToolCallMessageShellCommand:
"""Test ToolCallMessage shows full shell command for errors.