mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(code): add structured TUI display for js_eval (#4151)
Cleans up how the `js_eval` interpreter tool (enabled via `--interpreter`) renders in the `dcode` TUI. Previously it dumped its raw XML-ish wire format (`<stdout>`/`<result>`/`<error>` blocks) and a raw `code=` kwarg line, which read poorly. - Add a `js_eval` output formatter that parses the stdout/result/error blocks, unescapes XML entities, and styles them (stdout dim, result success-colored, error error-colored). A single short scalar result renders inline as `result: value` instead of a misplaced badge. - Suppress the raw `code=` args line and show only the first code line (with an ellipsis when multiline) in the tool header. - Make the code a collapsible block — plain, left-aligned, with top/bottom padding — toggled via click or Ctrl+O. - Fix toggle routing so Ctrl+O falls through to the code block when the output itself is not expandable. **Before**: <img width="876" height="164" alt="Screenshot 2026-06-22 at 3 28 34 PM" src="https://github.com/user-attachments/assets/6665961f-7b5f-459f-b667-5e283281f892" /> **After**: <img width="876" height="397" alt="Screenshot 2026-06-22 at 3 28 48 PM" src="https://github.com/user-attachments/assets/e5a940df-5e87-4599-a7e4-05ab837243ee" /> --------- Co-authored-by: Mason Daugherty <mason@langchain.dev> Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
@@ -8851,12 +8851,15 @@ class DeepAgentsApp(App):
|
||||
except NoMatches:
|
||||
tool_messages = []
|
||||
for tool_msg in reversed(tool_messages):
|
||||
if tool_msg.has_output:
|
||||
if tool_msg.has_output and tool_msg.has_expandable_output:
|
||||
tool_msg.toggle_output()
|
||||
return
|
||||
if tool_msg.has_expandable_args:
|
||||
tool_msg.toggle_args()
|
||||
return
|
||||
if tool_msg.has_output:
|
||||
tool_msg.toggle_output()
|
||||
return
|
||||
|
||||
# Approval menu action handlers (delegated from App-level bindings)
|
||||
# NOTE: These only activate when approval widget is pending
|
||||
|
||||
@@ -19,6 +19,14 @@ _HIDDEN_CHAR_MARKER = " [hidden chars removed]"
|
||||
"""Marker appended to display values that had dangerous Unicode stripped, so
|
||||
users know the value was modified for safety."""
|
||||
|
||||
JS_EVAL_HEADER_MAX_LENGTH = 120
|
||||
"""Width at which the `js_eval` header truncates the first code line.
|
||||
|
||||
Shared with `messages.py` so the "header truncates the first line" cutoff and
|
||||
the "offer a collapsible code 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').
|
||||
@@ -219,11 +227,20 @@ def format_tool_display(tool_name: str, tool_args: dict) -> str:
|
||||
return f'{prefix} {tool_name}("{command}")'
|
||||
|
||||
elif tool_name == "js_eval":
|
||||
# JS interpreter: show the first line of the snippet, truncated.
|
||||
# JS interpreter: show only the first non-blank line of the snippet so a
|
||||
# multi-line program collapses to a single, scannable header line. The
|
||||
# full code is available via the collapsible args block.
|
||||
code = tool_args.get("code")
|
||||
if isinstance(code, str) and code.strip():
|
||||
snippet = _sanitize_display_value(code, max_length=120)
|
||||
return f'{prefix} {tool_name}("{snippet}")'
|
||||
first_line = next(
|
||||
(line for line in code.splitlines() if line.strip()), ""
|
||||
).strip()
|
||||
multiline = sum(1 for line in code.splitlines() if line.strip()) > 1
|
||||
snippet = _sanitize_display_value(
|
||||
first_line, max_length=JS_EVAL_HEADER_MAX_LENGTH
|
||||
)
|
||||
ellipsis = get_glyphs().ellipsis if multiline else ""
|
||||
return f'{prefix} {tool_name}("{snippet}{ellipsis}")'
|
||||
return f"{prefix} {tool_name}()"
|
||||
|
||||
elif tool_name == "ls":
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Helpers for displaying `js_eval` tool output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JsEvalStdout:
|
||||
"""Captured stdout printed during a `js_eval` evaluation."""
|
||||
|
||||
body: str
|
||||
"""Stdout text, verbatim (the wire format does not escape stdout)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JsEvalResult:
|
||||
"""A successful `js_eval` evaluation result."""
|
||||
|
||||
kind: str
|
||||
"""The result `kind` attribute (e.g. `handle`), or `""` for a plain value."""
|
||||
|
||||
body: str
|
||||
"""Unescaped result text."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JsEvalError:
|
||||
"""An error raised during a `js_eval` evaluation."""
|
||||
|
||||
error_type: str
|
||||
"""The JS error type (e.g. `ReferenceError`), or `""` if the wire format
|
||||
omitted it."""
|
||||
|
||||
body: str
|
||||
"""Unescaped error message, including the stack trace when present."""
|
||||
|
||||
|
||||
# Discriminated union of the parsed envelope blocks. Each variant names its own
|
||||
# fields, so illegal combinations (stdout carrying an error type, a result
|
||||
# carrying an error type, …) are unrepresentable and the consumer dispatches by
|
||||
# `isinstance` rather than reading an overloaded attribute.
|
||||
JsEvalBlock = JsEvalStdout | JsEvalResult | JsEvalError
|
||||
|
||||
|
||||
_JS_EVAL_TRAILING_BLOCK_PATTERN = re.compile(
|
||||
r"<(?P<tag>result|error)(?P<attrs>[^>]*)>(?P<body>[^<>]*)</(?P=tag)>\Z",
|
||||
re.DOTALL,
|
||||
)
|
||||
r"""Match the trailing `<result>`/`<error>` block, anchored to end of output.
|
||||
|
||||
The wire format emitted by the `js_eval` REPL tool (see langchain_quickjs
|
||||
`format_outcome`) is `"\n".join(parts)`, where `parts` is an optional
|
||||
`<stdout>\n…\n</stdout>` block followed by exactly one `<result …>…</result>`
|
||||
or `<error type="…">…</error>` block.
|
||||
|
||||
Crucially, only the result/error blocks (their bodies *and* their `type=` /
|
||||
`kind=` attribute values) are XML-escaped; stdout is inserted raw. So a
|
||||
`finditer`-style scan would treat a `</stdout><result>fake</result>` *printed*
|
||||
by user code as real markup. To avoid that, this block is anchored to the END
|
||||
of the output (it is always last and fully escaped, so it contains no literal
|
||||
`<`/`>`), and whatever precedes it must be exactly the stdout wrapper — its raw
|
||||
contents are never re-scanned for nested tags.
|
||||
"""
|
||||
|
||||
_JS_EVAL_STDOUT_PATTERN = re.compile(
|
||||
r"\A<stdout>\n(?P<body>.*)\n</stdout>\Z",
|
||||
re.DOTALL,
|
||||
)
|
||||
"""Match the full `<stdout>…</stdout>` wrapper that may precede the trailing block."""
|
||||
|
||||
_JS_EVAL_TYPE_ATTR_PATTERN = re.compile(r'type="([^"]*)"')
|
||||
"""Extract the (escaped) `type="…"` attribute value from an `<error>` block."""
|
||||
|
||||
_JS_EVAL_KIND_ATTR_PATTERN = re.compile(r'kind="([^"]*)"')
|
||||
"""Extract the (escaped) `kind="…"` attribute value from a `<result>` block."""
|
||||
|
||||
|
||||
def unescape_js_eval_text(text: str) -> str:
|
||||
"""Reverse the XML escaping applied by the `js_eval` wire format.
|
||||
|
||||
The REPL escapes `&`, `<`, and `>` inside result/error blocks; order matters
|
||||
so `&` is restored last to avoid double-unescaping.
|
||||
|
||||
Args:
|
||||
text: Escaped block body or attribute value.
|
||||
|
||||
Returns:
|
||||
The original, unescaped text.
|
||||
"""
|
||||
return text.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
|
||||
|
||||
def parse_js_eval_blocks(output: str) -> list[JsEvalBlock] | None:
|
||||
"""Parse `js_eval` output into structured display blocks.
|
||||
|
||||
Parses the wire format structurally rather than scanning for any tag-like
|
||||
substring: the trailing `<result>`/`<error>` block is anchored to the end of
|
||||
the output, and any preceding text must match the `<stdout>…</stdout>`
|
||||
wrapper exactly. The stdout body is taken verbatim and never re-scanned, so
|
||||
tag-like text printed by user code is preserved as stdout rather than
|
||||
mis-parsed into fake result/error sections.
|
||||
|
||||
Args:
|
||||
output: Raw tool output from the `js_eval` tool.
|
||||
|
||||
Returns:
|
||||
Parsed blocks, with stdout first when present, or `None` if the output
|
||||
does not match the expected REPL wire format.
|
||||
"""
|
||||
trailing = _JS_EVAL_TRAILING_BLOCK_PATTERN.search(output)
|
||||
if trailing is None:
|
||||
return None
|
||||
|
||||
tag = trailing.group("tag")
|
||||
attrs = trailing.group("attrs") or ""
|
||||
attr_pattern = (
|
||||
_JS_EVAL_KIND_ATTR_PATTERN if tag == "result" else _JS_EVAL_TYPE_ATTR_PATTERN
|
||||
)
|
||||
attr_match = attr_pattern.search(attrs)
|
||||
attr = unescape_js_eval_text(attr_match.group(1)) if attr_match else ""
|
||||
body = unescape_js_eval_text(trailing.group("body"))
|
||||
|
||||
prefix = output[: trailing.start()]
|
||||
blocks: list[JsEvalBlock] = []
|
||||
if prefix:
|
||||
if not prefix.endswith("\n"):
|
||||
return None
|
||||
stdout_match = _JS_EVAL_STDOUT_PATTERN.match(prefix[:-1])
|
||||
if stdout_match is None:
|
||||
return None
|
||||
blocks.append(JsEvalStdout(stdout_match.group("body")))
|
||||
|
||||
if tag == "result":
|
||||
blocks.append(JsEvalResult(attr, body))
|
||||
else:
|
||||
blocks.append(JsEvalError(attr, body))
|
||||
return blocks
|
||||
@@ -31,12 +31,27 @@ 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 format_tool_display
|
||||
from deepagents_code.tool_display import (
|
||||
JS_EVAL_HEADER_MAX_LENGTH,
|
||||
format_tool_display,
|
||||
)
|
||||
from deepagents_code.unicode_security import render_with_unicode_markers
|
||||
from deepagents_code.widgets._js_eval_display import (
|
||||
JsEvalBlock,
|
||||
JsEvalError,
|
||||
JsEvalResult,
|
||||
JsEvalStdout,
|
||||
parse_js_eval_blocks,
|
||||
)
|
||||
from deepagents_code.widgets._links import open_style_link
|
||||
from deepagents_code.widgets.diff import compose_diff_lines
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import Console as RichConsole, ConsoleOptions, RenderResult
|
||||
from rich.console import (
|
||||
Console as RichConsole,
|
||||
ConsoleOptions,
|
||||
RenderResult,
|
||||
)
|
||||
from textual.app import ComposeResult
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import Markdown
|
||||
@@ -99,6 +114,7 @@ _TOOLS_WITH_HEADER_INFO: set[str] = {
|
||||
"glob",
|
||||
"grep",
|
||||
"execute", # sandbox shell
|
||||
"js_eval", # JS interpreter
|
||||
# Web tools
|
||||
"web_search",
|
||||
"fetch_url",
|
||||
@@ -911,9 +927,17 @@ class ToolCallMessage(Vertical):
|
||||
"""
|
||||
"""Left border tracks tool lifecycle; hover brightens for interactivity."""
|
||||
|
||||
# Max lines/chars to show in preview mode
|
||||
_PREVIEW_LINES = 6
|
||||
"""Maximum number of lines to show in preview mode."""
|
||||
|
||||
_PREVIEW_CHARS = 400
|
||||
"""Maximum number of characters to show in preview mode."""
|
||||
|
||||
_JS_EVAL_INLINE_RESULT_MAX = 80
|
||||
"""Maximum single-line `js_eval` result length rendered inline.
|
||||
|
||||
Inline rendering uses `result: value` rather than a standalone labeled block.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1299,9 +1323,16 @@ class ToolCallMessage(Vertical):
|
||||
self._update_args_display()
|
||||
|
||||
def on_click(self, event: Click) -> None:
|
||||
"""Toggle output/argument expansion."""
|
||||
"""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
|
||||
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:
|
||||
if self._output and self.has_expandable_output:
|
||||
self.toggle_output()
|
||||
elif self.has_expandable_args:
|
||||
self.toggle_args()
|
||||
@@ -1338,6 +1369,7 @@ class ToolCallMessage(Vertical):
|
||||
"grep": self._format_search_output,
|
||||
"glob": self._format_search_output,
|
||||
"execute": self._format_shell_output,
|
||||
"js_eval": self._format_js_eval_output,
|
||||
"web_search": self._format_web_output,
|
||||
"fetch_url": self._format_web_output,
|
||||
"task": self._format_task_output,
|
||||
@@ -1362,6 +1394,18 @@ class ToolCallMessage(Vertical):
|
||||
# Default: plain text (Content treats input as literal)
|
||||
return FormattedOutput(content=Content(output))
|
||||
|
||||
@property
|
||||
def has_expandable_output(self) -> bool:
|
||||
"""Whether collapsed output has hidden content worth a toggle.
|
||||
|
||||
Public wrapper around `_has_expandable_output` so toggle routing (click
|
||||
and Ctrl+O) can tell "has output" apart from "has output that can
|
||||
actually expand/collapse". `js_eval` results are frequently short and
|
||||
unexpandable while the code block above them *is* collapsible, so the
|
||||
routing must fall through to args when output cannot toggle.
|
||||
"""
|
||||
return self._has_expandable_output()
|
||||
|
||||
def _has_expandable_output(self) -> bool:
|
||||
"""Return whether collapsed output has hidden content to expand."""
|
||||
output = self._output.strip()
|
||||
@@ -1902,6 +1946,138 @@ class ToolCallMessage(Vertical):
|
||||
|
||||
return FormattedOutput(content=content, truncation=truncation)
|
||||
|
||||
def _format_js_eval_output(
|
||||
self, output: str, *, is_preview: bool = False
|
||||
) -> FormattedOutput:
|
||||
"""Format `js_eval` (JS interpreter) output.
|
||||
|
||||
Unwraps the REPL's `<stdout>` / `<result>` / `<error>` envelope into
|
||||
labeled, styled sections instead of dumping the raw XML-escaped blob.
|
||||
|
||||
Returns:
|
||||
FormattedOutput with the formatted REPL output and optional
|
||||
truncation info.
|
||||
"""
|
||||
blocks = parse_js_eval_blocks(output)
|
||||
if blocks is None:
|
||||
# Unexpected shape — fall back to plain line rendering.
|
||||
return self._format_lines_output(output.split("\n"), is_preview=is_preview)
|
||||
|
||||
colors = theme.get_theme_colors(self)
|
||||
|
||||
# Common case: a single short scalar result with no stdout. Rendering a
|
||||
# standalone "result" header above a one-word value reads as a
|
||||
# misplaced badge, so collapse it to an inline `result: value` line.
|
||||
if len(blocks) == 1:
|
||||
block = blocks[0]
|
||||
if (
|
||||
isinstance(block, JsEvalResult)
|
||||
and not block.kind
|
||||
and "\n" not in block.body
|
||||
and len(block.body) <= self._JS_EVAL_INLINE_RESULT_MAX
|
||||
):
|
||||
content = Content.assemble(
|
||||
Content.styled("result: ", colors.success),
|
||||
Content(block.body),
|
||||
)
|
||||
return FormattedOutput(content=content)
|
||||
lines: list[Content] = []
|
||||
total_lines = 0
|
||||
max_lines = self._PREVIEW_LINES if is_preview else None
|
||||
# Char budget mirrors the other formatters so a single very long body
|
||||
# line (e.g. a 10k-char result) is clipped instead of flooding the
|
||||
# collapsed preview. `None` outside preview means no char cap.
|
||||
remaining_chars = self._PREVIEW_CHARS if is_preview else None
|
||||
# Chars hidden when a single over-budget body line is clipped. Only
|
||||
# meaningful for the hint when no whole lines were dropped (line counts
|
||||
# take precedence below, matching `_build_truncation_hint`).
|
||||
clipped_chars = 0
|
||||
|
||||
def add_section(label: Content, body: str) -> None:
|
||||
nonlocal total_lines, remaining_chars, clipped_chars
|
||||
if max_lines is not None and total_lines >= max_lines:
|
||||
return
|
||||
if remaining_chars is not None and remaining_chars <= 0:
|
||||
return
|
||||
lines.append(label)
|
||||
total_lines += 1
|
||||
body_lines = body.split("\n") if body else [""]
|
||||
for body_line in body_lines:
|
||||
if max_lines is not None and total_lines >= max_lines:
|
||||
break
|
||||
if remaining_chars is not None:
|
||||
if remaining_chars <= 0:
|
||||
break
|
||||
if len(body_line) > remaining_chars:
|
||||
# Clip the over-budget line and stop adding more.
|
||||
lines.append(Content(f" {body_line[:remaining_chars]}"))
|
||||
total_lines += 1
|
||||
clipped_chars = len(body_line) - remaining_chars
|
||||
remaining_chars = 0
|
||||
break
|
||||
remaining_chars -= len(body_line)
|
||||
lines.append(Content(f" {body_line}"))
|
||||
total_lines += 1
|
||||
|
||||
for block in blocks:
|
||||
if isinstance(block, JsEvalStdout):
|
||||
add_section(Content.styled("stdout", "dim"), block.body)
|
||||
elif isinstance(block, JsEvalError):
|
||||
header = f"error ({block.error_type})" if block.error_type else "error"
|
||||
add_section(Content.styled(header, colors.error), block.body)
|
||||
else: # JsEvalResult
|
||||
label = "result (handle)" if block.kind else "result"
|
||||
add_section(Content.styled(label, colors.success), block.body)
|
||||
|
||||
content = Content("\n").join(lines) if lines else Content("")
|
||||
truncation = self._build_js_eval_truncation_hint(
|
||||
blocks=blocks,
|
||||
shown_lines=total_lines,
|
||||
clipped_chars=clipped_chars,
|
||||
is_preview=is_preview,
|
||||
)
|
||||
return FormattedOutput(content=content, truncation=truncation)
|
||||
|
||||
@staticmethod
|
||||
def _build_js_eval_truncation_hint(
|
||||
*,
|
||||
blocks: list[JsEvalBlock],
|
||||
shown_lines: int,
|
||||
clipped_chars: int,
|
||||
is_preview: bool,
|
||||
) -> str | None:
|
||||
"""Quantify how much `js_eval` preview content was hidden.
|
||||
|
||||
Prefers a hidden-line count over a hidden-char count (mirroring
|
||||
`_build_truncation_hint`): when whole sections were dropped, "N more
|
||||
lines" is the more useful signal; a lone clipped body line reports the
|
||||
chars it lost.
|
||||
|
||||
Args:
|
||||
blocks: The parsed blocks, used to compute the full (untruncated)
|
||||
display-line count.
|
||||
shown_lines: Display lines actually emitted into the preview.
|
||||
clipped_chars: Chars dropped from a single clipped body line, if any.
|
||||
is_preview: Whether this is preview rendering; full renders never
|
||||
truncate.
|
||||
|
||||
Returns:
|
||||
A hint string for the UI, or `None` when nothing was hidden.
|
||||
"""
|
||||
if not is_preview:
|
||||
return None
|
||||
# Each block renders as one label line plus its body lines; an empty
|
||||
# body still occupies one (blank) line.
|
||||
full_lines = sum(
|
||||
1 + (len(block.body.split("\n")) if block.body else 1) for block in blocks
|
||||
)
|
||||
hidden_lines = full_lines - shown_lines
|
||||
if hidden_lines > 0:
|
||||
return f"{hidden_lines} more lines"
|
||||
if clipped_chars > 0:
|
||||
return f"{clipped_chars} more chars"
|
||||
return None
|
||||
|
||||
def _format_web_output(
|
||||
self, output: str, *, is_preview: bool = False
|
||||
) -> FormattedOutput:
|
||||
@@ -2120,17 +2296,49 @@ class ToolCallMessage(Vertical):
|
||||
def has_expandable_args(self) -> bool:
|
||||
"""Whether the tool's args are large enough to deserve a collapsible block.
|
||||
|
||||
Only `ask_user` qualifies today: its `questions` payload is too noisy to
|
||||
render inline, but users still need a way to inspect it.
|
||||
- `ask_user`: its `questions` payload is too noisy to render inline.
|
||||
- `js_eval`: the header shows only the first code line (truncated at
|
||||
`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.
|
||||
"""
|
||||
return self._tool_name == "ask_user" and bool(self._args)
|
||||
if self._tool_name == "ask_user":
|
||||
return bool(self._args)
|
||||
if self._tool_name == "js_eval":
|
||||
code = self._args.get("code")
|
||||
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
|
||||
return False
|
||||
|
||||
def _format_code_detail(self) -> Content:
|
||||
"""Render the `js_eval` program for the collapsible code block.
|
||||
|
||||
The code is shown verbatim and left-aligned (its own indentation is the
|
||||
only indentation), as plain uncolored `Content`. Blank lines of
|
||||
top/bottom padding add breathing room between the `js_eval` header above
|
||||
and the "show/hide code" hint below.
|
||||
|
||||
Returns:
|
||||
A plain `Content` renderable with a blank line of padding on
|
||||
top and bottom.
|
||||
"""
|
||||
code = self._args.get("code")
|
||||
code_str = code.strip("\n") if isinstance(code, str) else str(code)
|
||||
code_str = render_with_unicode_markers(code_str)
|
||||
|
||||
# Blank lines of top/bottom padding separate the block from the header
|
||||
# line above and the "show/hide code" hint below.
|
||||
return Content("\n").join((Content(""), Content(code_str), Content("")))
|
||||
|
||||
def _format_args_detail(self) -> Content:
|
||||
"""Render tool arguments as an indented `Content` block.
|
||||
|
||||
Falls back to `str(self._args)` (with a visible marker) when JSON
|
||||
serialization fails — `default=str` already handles most non-serializable
|
||||
values, so reaching the fallback indicates a deeper issue worth logging.
|
||||
Renders JSON-pretty-printed args, falling back to `str(self._args)`
|
||||
(with a visible marker) when JSON serialization fails — `default=str`
|
||||
already handles most non-serializable values, so reaching the fallback
|
||||
indicates a deeper issue worth logging. `js_eval` code is handled
|
||||
separately by `_format_code_detail`.
|
||||
|
||||
Returns:
|
||||
Indented `Content` containing JSON-pretty-printed arguments, or a
|
||||
@@ -2159,16 +2367,21 @@ 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._args_expanded:
|
||||
self._args_widget.update(self._format_args_detail())
|
||||
detail = (
|
||||
self._format_code_detail() if is_code else self._format_args_detail()
|
||||
)
|
||||
self._args_widget.update(detail)
|
||||
self._args_widget.display = True
|
||||
self._args_hint_widget.update(
|
||||
Content.styled("click or Ctrl+O to hide arguments", "dim italic")
|
||||
Content.styled(f"click or Ctrl+O to hide {noun}", "dim italic")
|
||||
)
|
||||
else:
|
||||
self._args_widget.display = False
|
||||
self._args_hint_widget.update(
|
||||
Content.styled("click or Ctrl+O to show arguments", "dim italic")
|
||||
Content.styled(f"click or Ctrl+O to show {noun}", "dim italic")
|
||||
)
|
||||
self._args_hint_widget.display = True
|
||||
|
||||
|
||||
@@ -3083,6 +3083,38 @@ class TestAskUserLifecycle:
|
||||
tool.toggle_args.assert_called_once_with()
|
||||
tool.toggle_output.assert_not_called()
|
||||
|
||||
def test_ctrl_o_falls_through_to_args_when_output_unexpandable(self) -> None:
|
||||
"""Ctrl+O on a tool with unexpandable output toggles its code/args.
|
||||
|
||||
Regression: `js_eval` sets `_output` to a short, unexpandable result,
|
||||
which used to swallow the toggle and leave the collapsible code block
|
||||
stuck. The action must fall through to args in that case.
|
||||
"""
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
app._pending_ask_user_widget = None
|
||||
tool = MagicMock()
|
||||
tool.has_output = True
|
||||
tool.has_expandable_output = False # short result, nothing to expand
|
||||
tool.has_expandable_args = True # multi-line code block
|
||||
|
||||
def fake_query(query_type: object) -> list[object]:
|
||||
from deepagents_code.widgets.messages import (
|
||||
SkillMessage,
|
||||
ToolCallMessage,
|
||||
)
|
||||
|
||||
if query_type is ToolCallMessage:
|
||||
return [tool]
|
||||
if query_type is SkillMessage:
|
||||
return []
|
||||
return []
|
||||
|
||||
with patch.object(app, "query", side_effect=fake_query):
|
||||
app.action_toggle_tool_output()
|
||||
|
||||
tool.toggle_args.assert_called_once_with()
|
||||
tool.toggle_output.assert_not_called()
|
||||
|
||||
def test_ctrl_o_prefers_tool_with_output_over_expandable_args(self) -> None:
|
||||
"""Tool with real output wins over a later one with only expandable args."""
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
|
||||
@@ -10,6 +10,7 @@ from textual.content import Content
|
||||
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.input import INPUT_HIGHLIGHT_PATTERN
|
||||
from deepagents_code.tool_display import JS_EVAL_HEADER_MAX_LENGTH
|
||||
from deepagents_code.widgets.messages import (
|
||||
AppMessage,
|
||||
AssistantMessage,
|
||||
@@ -1160,6 +1161,75 @@ class TestToolCallMessageExpandableArgs:
|
||||
await pilot.pause()
|
||||
assert msg._args_expanded is False
|
||||
|
||||
async def test_js_eval_click_toggles_code_when_result_unexpandable(self) -> None:
|
||||
"""After a short `js_eval` result, clicking must toggle the code block.
|
||||
|
||||
Regression: once eval returned, `_output` was set and `on_click`
|
||||
unconditionally routed to `toggle_output`. A short, unexpandable result
|
||||
made that a no-op, so the collapsible code block could never open.
|
||||
"""
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
class _Harness(App[None]):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.msg = ToolCallMessage(
|
||||
"js_eval",
|
||||
{"code": "const x = 1;\nx + 1"}, # multi-line -> expandable
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield self.msg
|
||||
|
||||
app = _Harness()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
msg = app.msg
|
||||
# Eval returns a short, unexpandable result.
|
||||
msg.set_success("<result>2</result>")
|
||||
await pilot.pause()
|
||||
assert msg.has_output is True
|
||||
assert msg.has_expandable_output is False
|
||||
assert msg.has_expandable_args is True
|
||||
|
||||
event = MagicMock()
|
||||
msg.on_click(event)
|
||||
await pilot.pause()
|
||||
event.stop.assert_called_once()
|
||||
# Falls through to the code block instead of no-op output toggle.
|
||||
assert msg._args_expanded is True
|
||||
|
||||
async def test_js_eval_click_prefers_expandable_output(self) -> None:
|
||||
"""When the result *is* expandable, clicking still toggles output."""
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
class _Harness(App[None]):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.msg = ToolCallMessage(
|
||||
"js_eval",
|
||||
{"code": "const x = 1;\nx + 1"},
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield self.msg
|
||||
|
||||
app = _Harness()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
msg = app.msg
|
||||
# A long multi-line stdout makes the output expandable.
|
||||
body = "\n".join(str(i) for i in range(50))
|
||||
msg.set_success(f"<stdout>\n{body}\n</stdout>\n<result>done</result>")
|
||||
await pilot.pause()
|
||||
assert msg.has_expandable_output is True
|
||||
|
||||
event = MagicMock()
|
||||
msg.on_click(event)
|
||||
await pilot.pause()
|
||||
assert msg._expanded is True
|
||||
assert msg._args_expanded is False
|
||||
|
||||
|
||||
class TestToolCallMessageShellCommand:
|
||||
"""Test ToolCallMessage shows full shell command for errors.
|
||||
@@ -1337,6 +1407,240 @@ class TestToolCallMessageShellCommand:
|
||||
assert result.content.plain == " indented"
|
||||
|
||||
|
||||
class TestToolCallMessageJsEvalOutput:
|
||||
"""Tests for `_format_js_eval_output`.
|
||||
|
||||
The `js_eval` REPL tool returns an XML-ish envelope
|
||||
(`<stdout>`, `<result>`, `<error>`) with `&`, `<`, `>` escaped. The
|
||||
formatter unwraps that into labeled, styled sections instead of dumping the
|
||||
raw blob.
|
||||
"""
|
||||
|
||||
def test_format_single_scalar_result_renders_inline(self) -> None:
|
||||
"""A lone short scalar result renders inline as `result: value`."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "1 + 1"})
|
||||
result = msg._format_output("<result>2</result>", is_preview=False)
|
||||
|
||||
assert result.content.plain == "result: 2"
|
||||
assert result.truncation is None
|
||||
|
||||
def test_format_empty_string_result_stays_empty(self) -> None:
|
||||
"""An empty string result must not be rewritten as `undefined`."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "''"})
|
||||
result = msg._format_output("<result></result>", is_preview=False)
|
||||
|
||||
assert result.content.plain == "result: "
|
||||
|
||||
def test_format_newline_only_result_preserves_body(self) -> None:
|
||||
"""A newline-only string result remains a real value in block form."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "'\\n'"})
|
||||
result = msg._format_output("<result>\n</result>", is_preview=False)
|
||||
|
||||
assert result.content.plain.split("\n") == ["result", " ", " "]
|
||||
|
||||
def test_format_multiline_result_uses_block(self) -> None:
|
||||
"""A multi-line result keeps the labeled-block layout."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "x"})
|
||||
result = msg._format_output("<result>line1\nline2</result>", is_preview=False)
|
||||
|
||||
assert result.content.plain.split("\n") == ["result", " line1", " line2"]
|
||||
|
||||
def test_format_long_scalar_result_uses_block(self) -> None:
|
||||
"""A long single-line result is not collapsed inline."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "x"})
|
||||
body = "x" * (msg._JS_EVAL_INLINE_RESULT_MAX + 1)
|
||||
result = msg._format_output(f"<result>{body}</result>", is_preview=False)
|
||||
|
||||
assert result.content.plain.split("\n") == ["result", f" {body}"]
|
||||
|
||||
def test_format_stdout_and_result(self) -> None:
|
||||
"""Stdout and result both render as separate labeled sections."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "console.log('hi'); 42"})
|
||||
output = "<stdout>\nhi\n</stdout>\n<result>42</result>"
|
||||
result = msg._format_output(output, is_preview=False)
|
||||
|
||||
# stdout present -> result is not collapsed inline.
|
||||
lines = result.content.plain.split("\n")
|
||||
assert lines == ["stdout", " hi", "result", " 42"]
|
||||
|
||||
def test_format_unescapes_xml_entities(self) -> None:
|
||||
"""Escaped `<`, `>`, `&` in the body are restored for display."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "x"})
|
||||
output = "<result><div> && true</result>"
|
||||
result = msg._format_output(output, is_preview=False)
|
||||
|
||||
# Single short scalar -> inline form.
|
||||
assert result.content.plain == "result: <div> && true"
|
||||
|
||||
def test_format_error_block_includes_type(self) -> None:
|
||||
"""An error block surfaces the error type in its label."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "boom()"})
|
||||
output = '<error type="ReferenceError">boom is not defined</error>'
|
||||
result = msg._format_output(output, is_preview=False)
|
||||
|
||||
lines = result.content.plain.split("\n")
|
||||
assert lines == ["error (ReferenceError)", " boom is not defined"]
|
||||
|
||||
def test_format_handle_result_labeled(self) -> None:
|
||||
"""A `kind`-tagged result is labeled as a handle."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "() => 1"})
|
||||
output = '<result kind="handle">[Function] arity=0</result>'
|
||||
result = msg._format_output(output, is_preview=False)
|
||||
|
||||
lines = result.content.plain.split("\n")
|
||||
assert lines == ["result (handle)", " [Function] arity=0"]
|
||||
|
||||
def test_format_preview_truncates_long_output(self) -> None:
|
||||
"""Preview mode caps lines and reports the count of hidden lines."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "x"})
|
||||
body = "\n".join(str(i) for i in range(50))
|
||||
output = f"<stdout>\n{body}\n</stdout>\n<result>done</result>"
|
||||
result = msg._format_output(output, is_preview=True)
|
||||
|
||||
shown = len(result.content.plain.split("\n"))
|
||||
assert shown <= msg._PREVIEW_LINES
|
||||
# Full render is the stdout label + 50 stdout lines + result label + 1
|
||||
# result line; the hint reports exactly what the preview dropped.
|
||||
assert result.truncation == f"{53 - shown} more lines"
|
||||
|
||||
def test_format_falls_back_for_unexpected_shape(self) -> None:
|
||||
"""Output without the REPL envelope falls back to plain lines."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "x"})
|
||||
result = msg._format_output("just some text", is_preview=False)
|
||||
|
||||
assert result.content.plain == "just some text"
|
||||
|
||||
def test_format_preview_caps_long_single_line_by_char_budget(self) -> None:
|
||||
"""A single huge result line is char-clipped under the preview budget.
|
||||
|
||||
Line-count capping alone left a multi-thousand-char single-line result
|
||||
rendered in full with no truncation hint, flooding the collapsed TUI.
|
||||
"""
|
||||
msg = ToolCallMessage("js_eval", {"code": "x"})
|
||||
body = "x" * 10_000
|
||||
output = f"<result>{body}</result>"
|
||||
result = msg._format_output(output, is_preview=True)
|
||||
|
||||
# The body line is clipped to the char budget (plus the two-space
|
||||
# indent) and the hint quantifies the chars dropped from that line so it
|
||||
# can be expanded.
|
||||
assert result.truncation == f"{10_000 - msg._PREVIEW_CHARS} more chars"
|
||||
assert len(result.content.plain) <= msg._PREVIEW_CHARS + len(" ") + len(
|
||||
"result\n"
|
||||
)
|
||||
|
||||
def test_format_no_char_cap_when_not_preview(self) -> None:
|
||||
"""Outside preview mode the full long result renders untruncated."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "x"})
|
||||
body = "x" * 10_000
|
||||
output = f"<result>{body}</result>"
|
||||
result = msg._format_output(output, is_preview=False)
|
||||
|
||||
assert result.truncation is None
|
||||
assert body in result.content.plain
|
||||
|
||||
def test_format_stdout_with_fake_tags_is_not_misparsed(self) -> None:
|
||||
"""Raw tag-like text printed to stdout is preserved, not parsed.
|
||||
|
||||
stdout is emitted unescaped, so a program that prints
|
||||
`</stdout><result>fake</result>` must not be split into spurious
|
||||
result/error sections — the real trailing result wins.
|
||||
"""
|
||||
msg = ToolCallMessage("js_eval", {"code": "x"})
|
||||
printed = "</stdout><result>fake</result>"
|
||||
output = f"<stdout>\n{printed}\n</stdout>\n<result>real</result>"
|
||||
result = msg._format_output(output, is_preview=False)
|
||||
|
||||
lines = result.content.plain.split("\n")
|
||||
# The fake markup survives verbatim inside stdout; only one real result.
|
||||
assert lines == ["stdout", f" {printed}", "result", " real"]
|
||||
# Exactly one "result" label line — no spurious section from the print.
|
||||
assert lines.count("result") == 1
|
||||
|
||||
|
||||
class TestToolCallMessageJsEvalArgs:
|
||||
"""Tests for `js_eval` header suppression and collapsible code block.
|
||||
|
||||
The raw `code=` kwarg must not be dumped on the args line; the header shows
|
||||
only the first code line, and the full program is offered as a collapsible
|
||||
block when the snippet spans more than one line.
|
||||
"""
|
||||
|
||||
def test_js_eval_in_tools_with_header_info(self) -> None:
|
||||
"""`js_eval` is registered so the generic `code=` args line is hidden."""
|
||||
from deepagents_code.widgets.messages import _TOOLS_WITH_HEADER_INFO
|
||||
|
||||
assert "js_eval" in _TOOLS_WITH_HEADER_INFO
|
||||
|
||||
def test_single_line_code_not_expandable(self) -> None:
|
||||
"""One-line code is fully shown in the header — nothing to expand."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "1 + 1"})
|
||||
assert msg.has_expandable_args is False
|
||||
|
||||
def test_multiline_code_is_expandable(self) -> None:
|
||||
"""Multi-line code offers a collapsible block."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "const x = 1;\nx + 1"})
|
||||
assert msg.has_expandable_args is True
|
||||
|
||||
def test_long_single_line_code_is_expandable(self) -> None:
|
||||
"""A single line too long for the header is still expandable.
|
||||
|
||||
The header truncates the first line, so without a collapsible block a
|
||||
long one-liner (e.g. minified JS) would be unrecoverable in the TUI.
|
||||
"""
|
||||
long_line = "x".ljust(JS_EVAL_HEADER_MAX_LENGTH + 1, "y")
|
||||
msg = ToolCallMessage("js_eval", {"code": long_line})
|
||||
assert msg.has_expandable_args is True
|
||||
|
||||
def test_short_single_line_code_not_expandable(self) -> None:
|
||||
"""A single line that fits in the header has nothing to expand."""
|
||||
short_line = "x" * (JS_EVAL_HEADER_MAX_LENGTH - 1)
|
||||
msg = ToolCallMessage("js_eval", {"code": short_line})
|
||||
assert msg.has_expandable_args is False
|
||||
|
||||
def test_code_detail_is_plain_and_left_aligned(self) -> None:
|
||||
"""The code is plain `Content`, left-aligned, with blank padding lines."""
|
||||
code = "const x = 1;\n nested();\nx + 1"
|
||||
msg = ToolCallMessage("js_eval", {"code": code})
|
||||
detail = msg._format_code_detail()
|
||||
|
||||
from textual.content import Content
|
||||
|
||||
assert isinstance(detail, Content)
|
||||
# Blank padding lines top and bottom; code's own indentation is
|
||||
# preserved and no extra indent is injected.
|
||||
assert detail.plain.split("\n") == [
|
||||
"",
|
||||
"const x = 1;",
|
||||
" nested();",
|
||||
"x + 1",
|
||||
"",
|
||||
]
|
||||
|
||||
def test_code_detail_is_unstyled(self) -> None:
|
||||
"""No syntax highlighting: the rendered code carries no style spans."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "const x = 1;\nx + 1"})
|
||||
detail = msg._format_code_detail()
|
||||
|
||||
assert not detail.spans
|
||||
|
||||
def test_code_detail_strips_surrounding_blank_lines(self) -> None:
|
||||
"""Code's own surrounding blanks are trimmed (padding lines remain)."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "\n\nconst x = 1;\n\n"})
|
||||
detail = msg._format_code_detail()
|
||||
|
||||
# One blank padding line top and bottom, around the trimmed code.
|
||||
assert detail.plain == "\nconst x = 1;\n"
|
||||
|
||||
def test_code_detail_marks_hidden_unicode(self) -> None:
|
||||
"""Hidden controls in expanded code are rendered as visible markers."""
|
||||
msg = ToolCallMessage("js_eval", {"code": "const safe = 1;\n//\u202e hidden"})
|
||||
detail = msg._format_code_detail()
|
||||
|
||||
assert "\u202e" not in detail.plain
|
||||
assert "<U+202E RIGHT-TO-LEFT OVERRIDE>" in detail.plain
|
||||
|
||||
|
||||
class TestToolCallMessageFileOutput:
|
||||
"""Tests for `_format_file_output` char-budget handling.
|
||||
|
||||
|
||||
@@ -291,6 +291,27 @@ class TestFormatToolDisplay:
|
||||
result = format_tool_display("execute", {"command": "ls", "timeout": "abc"})
|
||||
assert "timeout" not in result
|
||||
|
||||
# --- js_eval ---
|
||||
|
||||
def test_js_eval_single_line_shows_full_snippet(self) -> None:
|
||||
result = format_tool_display("js_eval", {"code": "1 + 1"})
|
||||
assert result == f'{_PREFIX} js_eval("1 + 1")'
|
||||
|
||||
def test_js_eval_multiline_shows_first_line_with_ellipsis(self) -> None:
|
||||
result = format_tool_display(
|
||||
"js_eval", {"code": "const x = 1;\nconst y = 2;\nx + y"}
|
||||
)
|
||||
# First non-blank line only, with an ellipsis marking the elision.
|
||||
assert result == f'{_PREFIX} js_eval("const x = 1;{ASCII_GLYPHS.ellipsis}")'
|
||||
|
||||
def test_js_eval_skips_leading_blank_lines(self) -> None:
|
||||
result = format_tool_display("js_eval", {"code": "\n\nreal();\nmore();"})
|
||||
assert result == f'{_PREFIX} js_eval("real();{ASCII_GLYPHS.ellipsis}")'
|
||||
|
||||
def test_js_eval_empty_code(self) -> None:
|
||||
result = format_tool_display("js_eval", {"code": " "})
|
||||
assert result == f"{_PREFIX} js_eval()"
|
||||
|
||||
# --- ls ---
|
||||
|
||||
def test_ls_with_path(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user