diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index c885b87c92c..ffba7d5a5b0 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -15610,7 +15610,9 @@ def action_toggle_tool_output(self) -> None: # Toggle whichever collapsible unit is most recent in DOM order so # content mounted after a tool group stays reachable. - # Grouped tool rows are folded into their summary, so skip them here. + # Skip grouped tool rows only while they are folded into their summary. + # Expanded groups retain the marker, but their visible rows should take + # precedence over the summary so Ctrl+O reaches their collapsible content. try: messages = self.query_one("#messages", Container) except NoMatches: @@ -15625,12 +15627,19 @@ def action_toggle_tool_output(self) -> None: if isinstance(child, SkillMessage) and child._stripped_body.strip(): child.toggle_body() return - if isinstance(child, ToolCallMessage) and not child.has_class("-grouped"): + if isinstance(child, ToolCallMessage) and ( + not child.has_class("-grouped") or child.display + ): # 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. + # A `task` row's truncated description takes the same role, + # owning Ctrl+O while its output stays reachable by click. + if child.has_expandable_task_desc: + child.toggle_task_desc() + return if child.has_expandable_args: child.toggle_args() return diff --git a/libs/code/deepagents_code/tui/widgets/messages.py b/libs/code/deepagents_code/tui/widgets/messages.py index ac87e6779b4..169c88f2833 100644 --- a/libs/code/deepagents_code/tui/widgets/messages.py +++ b/libs/code/deepagents_code/tui/widgets/messages.py @@ -15,6 +15,7 @@ from textual import on from textual.containers import Horizontal, Vertical, VerticalScroll from textual.content import Content +from textual.css.query import NoMatches from textual.events import Click from textual.geometry import Offset from textual.message import Message @@ -1127,6 +1128,14 @@ class ToolCallMessage(Vertical): Inline rendering uses `result: value` rather than a standalone labeled block. """ + _TASK_DESC_MAX_LENGTH = 120 + """Maximum `task` description length shown before it is truncated. + + A longer description collapses to at most this many characters (trailing + whitespace trimmed) with a trailing ellipsis and becomes expandable via + click or Ctrl+O. + """ + _RUNNING_TIMER_THRESHOLD_SECS = 10 """Seconds a tool must run before the elapsed-time counter appears. @@ -1155,11 +1164,14 @@ def __init__( self._output: str = "" self._expanded: bool = False self._args_expanded: bool = False + self._task_desc_expanded: bool = False # User-provided reason attached to a HITL reject decision (if any). 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._task_desc_widget: Static | None = None + self._task_desc_hint_widget: Static | None = None self._args_widget: Static | None = None self._args_hint_widget: Static | None = None self._preview_widget: Static | None = None @@ -1191,17 +1203,16 @@ def compose(self) -> ComposeResult: """ tool_label = format_tool_display(self._tool_name, self._args) yield Static(tool_label, markup=False, classes="tool-header", id="tool-header") - # Task: dedicated description line (dim, truncated) + # Task: dedicated description line (dim, truncated). A long description + # collapses to a truncated preview that expands on click or Ctrl+O. if self._tool_name == "task": - desc = self._args.get("description", "") - if desc: - max_len = 120 - suffix = "..." if len(desc) > max_len else "" - truncated = desc[:max_len].rstrip() + suffix + if self._task_description(): yield Static( - Content.styled(truncated, "dim"), + self._task_desc_content(), classes="tool-task-desc", + id="task-desc", ) + yield Static("", classes="tool-output-hint", id="task-desc-hint") # Only show args for tools where header doesn't capture the key info elif self._tool_name not in _TOOLS_WITH_HEADER_INFO: args = self._filtered_args() @@ -1248,6 +1259,13 @@ def on_mount(self) -> None: self._status_widget = self.query_one("#status", Static) self._header_widget = self.query_one("#tool-header", Static) + try: + self._task_desc_widget = self.query_one("#task-desc", Static) + self._task_desc_hint_widget = self.query_one("#task-desc-hint", Static) + except NoMatches: + # Only mounted for `task` calls that carry a description. + self._task_desc_widget = None + self._task_desc_hint_widget = None 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) @@ -1265,6 +1283,7 @@ def on_mount(self) -> None: self._full_row.display = False self._reject_reason_widget.display = False self._update_args_display() + self._update_task_desc_display() # Restore deferred state if this widget was hydrated from data self._restore_deferred_state() @@ -1595,25 +1614,40 @@ def toggle_args(self) -> None: self._args_expanded = not self._args_expanded self._update_args_display() + def toggle_task_desc(self) -> None: + """Toggle between the truncated and full `task` description.""" + if not self.has_expandable_task_desc: + return + self._task_desc_expanded = not self._task_desc_expanded + self._update_task_desc_display() + def on_click(self, event: Click) -> None: - """Toggle output/argument expansion. + """Toggle output/argument/description expansion. 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. + output below it is *also* expandable. A `task` row routes clicks on its + description region to the description toggle for the same reason. + 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.has_expandable_args and self._click_targets_args_region(event.widget): + if self.has_expandable_task_desc and self._click_targets_task_desc_region( + event.widget + ): + self.toggle_task_desc() + elif 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() + elif self.has_expandable_task_desc: + self.toggle_task_desc() def _click_targets_args_region(self, widget: object) -> bool: """Whether a click landed on the header/args block (not the output). @@ -1656,6 +1690,37 @@ def _click_targets_args_region(self, widget: object) -> bool: node = getattr(node, "parent", None) return False + def _click_targets_task_desc_region(self, widget: object) -> bool: + """Whether a click landed on the `task` header/description block. + + Mirrors `_click_targets_args_region` but matches the cached header, + description, and description-hint widgets so a `task` row expands its + description when clicked, even when its output below is also expandable. + + Returns: + `True` if the click landed on the header/description region. + """ + targets = tuple( + target + for target in ( + self._header_widget, + self._task_desc_widget, + self._task_desc_hint_widget, + ) + if target is not None + ) + if not targets: + logger.debug("_click_targets_task_desc_region: header/desc refs not cached") + return False + 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: @@ -2698,16 +2763,19 @@ 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 + has one (see `action_toggle_tool_output`), and to a truncated `task` + description when the row is a `task` call, 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. + command/code block or expandable `task` description owns Ctrl+O 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"`. + `"click"` when an expandable command/code block or `task` + description owns Ctrl+O, otherwise `"click or Ctrl+O"`. """ - return "click" if self.has_expandable_args else "click or Ctrl+O" + if self.has_expandable_args or self.has_expandable_task_desc: + return "click" + return "click or Ctrl+O" @property def has_output(self) -> bool: @@ -2782,6 +2850,72 @@ def has_expandable_args(self) -> bool: return len(command.strip()) > EXECUTE_HEADER_MAX_LENGTH return False + @property + def has_expandable_task_desc(self) -> bool: + """Whether the `task` description is long enough to be truncated. + + A `task` row renders its description on a dedicated dim line, truncated + at `_TASK_DESC_MAX_LENGTH`. When the full description exceeds that, the + truncated preview becomes expandable via click or Ctrl+O. + """ + return len(self._task_description()) > self._TASK_DESC_MAX_LENGTH + + def _task_description(self) -> str: + """Return the `task` call's description string, or empty when absent. + + A non-string `description` (schema-typed as a string) is coerced to + `""` so downstream length/slice logic stays safe; the anomaly is logged. + """ + if self._tool_name != "task": + return "" + desc = self._args.get("description", "") + if isinstance(desc, str): + return desc + if desc is not None: + logger.debug("task description is not a string: %r", type(desc)) + return "" + + def _task_desc_content(self) -> Content: + """Render the `task` description, truncated unless expanded. + + Returns: + Dim `Content`: the full description when expanded or when it already + fits within `_TASK_DESC_MAX_LENGTH`; otherwise the preview truncated + to that length (trailing whitespace trimmed) with a trailing + ellipsis. + """ + desc = self._task_description() + if self._task_desc_expanded or len(desc) <= self._TASK_DESC_MAX_LENGTH: + text = desc + else: + ellipsis = get_glyphs().ellipsis + text = desc[: self._TASK_DESC_MAX_LENGTH].rstrip() + ellipsis + return Content.styled(text, "dim") + + def _update_task_desc_display(self) -> None: + """Update the truncated/expanded `task` description and its hint.""" + if self._task_desc_widget is None or self._task_desc_hint_widget is None: + # Refs are legitimately None for non-`task` rows (never mounted). Log + # only when a `task` row that carries a description is missing them, + # so a regression that nulls them post-mount isn't a silent no-op. + if self._task_description(): + logger.debug("_update_task_desc_display: task-desc refs not cached") + return + if not self._task_description(): + self._task_desc_widget.display = False + self._task_desc_hint_widget.display = False + return + self._task_desc_widget.update(self._task_desc_content()) + self._task_desc_widget.display = True + if not self.has_expandable_task_desc: + self._task_desc_hint_widget.display = False + return + verb = "collapse" if self._task_desc_expanded else "expand" + self._task_desc_hint_widget.update( + Content.styled(f"click or Ctrl+O to {verb}", "dim italic") + ) + self._task_desc_hint_widget.display = True + def _format_code_detail(self) -> Content: """Render the `js_eval` program for the collapsible code block. diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index d8c98c3d239..dee1e3aef16 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -4471,6 +4471,7 @@ def test_ctrl_o_falls_back_to_tool_with_expandable_args(self) -> None: tool = MagicMock(spec=ToolCallMessage) tool.has_class.return_value = False tool.has_output = False + tool.has_expandable_task_desc = False tool.has_expandable_args = True container = MagicMock() container.children = [tool] @@ -4496,6 +4497,7 @@ def test_ctrl_o_falls_through_to_args_when_output_unexpandable(self) -> None: tool.has_class.return_value = False tool.has_output = True tool.has_expandable_output = False # short result, nothing to expand + tool.has_expandable_task_desc = False tool.has_expandable_args = True # multi-line code block container = MagicMock() container.children = [tool] @@ -4520,6 +4522,7 @@ def test_ctrl_o_prefers_command_when_both_expandable(self) -> None: tool.has_class.return_value = False tool.has_output = True tool.has_expandable_output = True # long stdout, expandable + tool.has_expandable_task_desc = False tool.has_expandable_args = True # long command, expandable container = MagicMock() container.children = [tool] @@ -4539,10 +4542,12 @@ def test_ctrl_o_prefers_more_recent_tool_in_dom_order(self) -> None: older = MagicMock(spec=ToolCallMessage) older.has_class.return_value = False older.has_output = True + older.has_expandable_task_desc = False older.has_expandable_args = False newer = MagicMock(spec=ToolCallMessage) newer.has_class.return_value = False newer.has_output = False + newer.has_expandable_task_desc = False newer.has_expandable_args = True container = MagicMock() container.children = [older, newer] @@ -4554,6 +4559,31 @@ def test_ctrl_o_prefers_more_recent_tool_in_dom_order(self) -> None: newer.toggle_args.assert_called_once_with() older.toggle_output.assert_not_called() + def test_ctrl_o_prefers_task_description_when_truncated(self) -> None: + """Ctrl+O toggles a truncated `task` description before its output. + + The description owns Ctrl+O (like a command/code block); the output + stays reachable by clicking its own row. + """ + from deepagents_code.tui.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 # expandable output present + tool.has_expandable_task_desc = True # long description + tool.has_expandable_args = False + container = MagicMock() + container.children = [tool] + + with patch.object(app, "query_one", return_value=container): + app.action_toggle_tool_output() + + tool.toggle_task_desc.assert_called_once_with() + tool.toggle_output.assert_not_called() + def test_ctrl_o_targets_content_mounted_after_a_group(self) -> None: """Content mounted after a tool group stays reachable from Ctrl+O. @@ -4592,6 +4622,7 @@ def test_ctrl_o_toggles_group_and_skips_its_folded_rows(self) -> None: group = MagicMock(spec=ToolGroupSummary) folded = MagicMock(spec=ToolCallMessage) folded.has_class.return_value = True # folded into the group + folded.display = False # DOM: older skill, then the group summary followed by its folded row. container = MagicMock() container.children = [skill, group, folded] @@ -4603,6 +4634,33 @@ def test_ctrl_o_toggles_group_and_skips_its_folded_rows(self) -> None: folded.toggle_output.assert_not_called() skill.toggle_body.assert_not_called() + def test_ctrl_o_targets_visible_grouped_task_description(self) -> None: + """An expanded group's visible task description takes Ctrl+O priority.""" + from deepagents_code.tui.widgets.messages import ( + ToolCallMessage, + ToolGroupSummary, + ) + + app = DeepAgentsApp(agent=MagicMock()) + app._pending_ask_user_widget = None + group = MagicMock(spec=ToolGroupSummary) + task = MagicMock(spec=ToolCallMessage) + task.has_class.return_value = True # marker remains after group expansion + task.display = True + task.has_expandable_task_desc = True + task.has_expandable_args = False + task.has_output = True + task.has_expandable_output = True + container = MagicMock() + container.children = [group, task] + + with patch.object(app, "query_one", return_value=container): + app.action_toggle_tool_output() + + task.toggle_task_desc.assert_called_once_with() + task.toggle_output.assert_not_called() + group.toggle.assert_not_called() + def test_ctrl_o_prefers_recent_rubric_over_tool_group(self) -> None: """A newer rubric result should win Ctrl+O over an older tool group.""" from deepagents_code.tui.widgets.messages import ( diff --git a/libs/code/tests/unit_tests/tui/widgets/test_messages.py b/libs/code/tests/unit_tests/tui/widgets/test_messages.py index c31892f88a2..1e29b9b7528 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_messages.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_messages.py @@ -2093,6 +2093,155 @@ def compose(self) -> ComposeResult: assert msg._args_expanded is False +class TestToolCallMessageTaskDescription: + """Tests for the expandable, truncated `task` description line.""" + + def test_short_description_not_expandable(self) -> None: + """A description that fits is shown in full with no expand affordance.""" + msg = ToolCallMessage("task", {"description": "investigate the bug"}) + assert msg.has_expandable_task_desc is False + + def test_long_description_is_expandable(self) -> None: + """A description longer than the limit becomes expandable.""" + long_desc = "x" * (ToolCallMessage._TASK_DESC_MAX_LENGTH + 1) + msg = ToolCallMessage("task", {"description": long_desc}) + assert msg.has_expandable_task_desc is True + + def test_description_at_limit_not_expandable(self) -> None: + """The threshold is strict `>`: a description of exactly the limit fits. + + Guards against a `>`-to-`>=` regression (or an off-by-one in the slice) + that every `MAX + 1` test would still pass. + """ + for length in ( + ToolCallMessage._TASK_DESC_MAX_LENGTH, + ToolCallMessage._TASK_DESC_MAX_LENGTH - 1, + ): + msg = ToolCallMessage("task", {"description": "x" * length}) + assert msg.has_expandable_task_desc is False + + def test_non_task_not_expandable(self) -> None: + """Only `task` rows expose an expandable description.""" + msg = ToolCallMessage("read_file", {"path": "/tmp/x"}) + assert msg.has_expandable_task_desc is False + + def test_non_string_description_not_expandable(self) -> None: + """A non-string `description` is coerced to empty, never raising. + + `has_expandable_task_desc` calls `len()` on the description, so dropping + the `isinstance` guard would raise `TypeError` on these inputs. + """ + for bad in (123, None, {"nested": "dict"}, ["list"]): + msg = ToolCallMessage("task", {"description": bad}) + assert msg.has_expandable_task_desc is False + + def test_output_hint_drops_ctrl_o_when_description_expandable(self) -> None: + """The output hint advertises click-only once the description owns Ctrl+O.""" + long_desc = "x" * (ToolCallMessage._TASK_DESC_MAX_LENGTH + 1) + msg = ToolCallMessage("task", {"description": long_desc}) + assert msg.has_expandable_task_desc is True + assert msg._output_hint_keys() == "click" + + short = ToolCallMessage("task", {"description": "short"}) + assert short.has_expandable_task_desc is False + assert short._output_hint_keys() == "click or Ctrl+O" + + async def test_short_description_shows_widget_hides_hint(self) -> None: + """A short but present description renders, with no expand hint.""" + app = _tool_msg_app("task", {"description": "investigate the bug"}) + async with app.run_test() as pilot: + await pilot.pause() + msg = app.msg + assert msg._task_desc_widget is not None + assert msg._task_desc_hint_widget is not None + assert msg._task_desc_widget.display is True + assert msg._task_desc_hint_widget.display is False + + async def test_toggle_task_desc_swaps_display_state(self) -> None: + """`toggle_task_desc` should reveal the full description then re-hide it.""" + from deepagents_code.config import get_glyphs + + long_desc = "word " * 60 # well over the truncation limit + + app = _tool_msg_app("task", {"description": long_desc}) + async with app.run_test() as pilot: + await pilot.pause() + msg = app.msg + + assert msg._task_desc_widget is not None + assert msg._task_desc_hint_widget is not None + # Collapsed: hint reads "expand", description truncated to the limit + # with a trailing ellipsis glyph. + assert msg._task_desc_hint_widget.display is True + hint = msg._task_desc_hint_widget._Static__content # ty: ignore + assert hint.plain == "click or Ctrl+O to expand" + collapsed = msg._task_desc_widget._Static__content # ty: ignore + ellipsis = get_glyphs().ellipsis + assert collapsed.plain.endswith(ellipsis) + body = collapsed.plain[: -len(ellipsis)] + assert len(body) <= ToolCallMessage._TASK_DESC_MAX_LENGTH + assert long_desc.startswith(body) + + msg.toggle_task_desc() + await pilot.pause() + assert msg._task_desc_expanded is True + expanded = msg._task_desc_widget._Static__content # ty: ignore + assert expanded.plain == long_desc + hint = msg._task_desc_hint_widget._Static__content # ty: ignore + assert hint.plain == "click or Ctrl+O to collapse" + + msg.toggle_task_desc() + await pilot.pause() + assert msg._task_desc_expanded is False + + async def test_click_on_description_toggles_task_desc(self) -> None: + """Clicking a truncated `task` row should expand its description.""" + app = _tool_msg_app("task", {"description": "word " * 60}) + async with app.run_test() as pilot: + await pilot.pause() + msg = app.msg + event = MagicMock() + event.widget = msg._task_desc_widget + msg.on_click(event) + await pilot.pause() + event.stop.assert_called_once() + assert msg._task_desc_expanded is True + + async def test_click_on_header_toggles_task_desc(self) -> None: + """Clicking the header of a truncated `task` row expands the description.""" + app = _tool_msg_app("task", {"description": "word " * 60}) + async with app.run_test() as pilot: + await pilot.pause() + msg = app.msg + event = MagicMock() + event.widget = msg._header_widget + msg.on_click(event) + await pilot.pause() + assert msg._task_desc_expanded is True + + async def test_click_on_output_toggles_output_not_description(self) -> None: + """A click on the output region toggles output, leaving the desc alone. + + The load-bearing precedence rule: even when the description is + expandable, a click that lands on the output routes to the output. + """ + app = _tool_msg_app("task", {"description": "word " * 60}) + async with app.run_test() as pilot: + await pilot.pause() + msg = app.msg + msg.set_success("line\n" * 200) # long, expandable output + await pilot.pause() + assert msg.has_expandable_task_desc is True + assert msg.has_expandable_output is True + + event = MagicMock() + event.widget = msg._preview_widget + msg.on_click(event) + await pilot.pause() + assert msg._expanded is True + assert msg._task_desc_expanded is False + + class TestToolCallMessageExecuteCommandExpand: """Tests for the collapsible full-command block on `execute` tool calls."""