fix(session-ui): wrap execute calls and show code mode output (#56915)

This commit is contained in:
Luke Parker
2026-10-19 11:38:47 +10:00
committed by GitHub
Unverified
parent b110e4a20e
commit b367a4b3ca
5 changed files with 117 additions and 4 deletions
@@ -179,6 +179,31 @@ story("opens an execution's code and output from its View code action", async ({
await expect(dialog.getByRole("button", { name: "Copy output" })).toBeVisible()
})
story("keeps long execute calls inside the tool and shows the script output", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--execute-code")
const tool = timeline.locator('[data-timeline-part-id="tool_execute_long_call"]')
const calls = tool.locator('[data-component="execute-tool-calls"]')
const rows = calls.locator('[data-slot="execute-tool-call"]')
await expect(rows).toHaveText([/mesh\.exec/, /Output\s*3 lines/])
// The script output stays collapsed until its row is expanded.
const result = calls.locator('[data-slot="bash-result"]')
await expect(result).toHaveCount(0)
const top = await rows.first().evaluate((element) => element.getBoundingClientRect().top)
await rows.nth(1).click()
await expect(rows.nth(1)).toHaveAttribute("aria-expanded", "true")
await expect(result).toContainText("Cleanup finished on blade")
// Opening the output must not move the rows above it.
expect(await rows.first().evaluate((element) => element.getBoundingClientRect().top)).toBe(top)
const overflow = await tool.evaluate((element) => {
const calls = element.querySelector('[data-component="execute-tool-calls"]')
if (!calls) return Number.NaN
return calls.getBoundingClientRect().right - element.getBoundingClientRect().right
})
expect(overflow).toBeLessThanOrEqual(0)
})
story("shows a failed execution's error in place of its output", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--execute-failed")
await timeline.getByRole("button", { name: /^Used 5 / }).click()
@@ -192,6 +192,8 @@
> [data-slot="collapsible-content"] {
flex-basis: 100%;
/* In this row layout, unbreakable content would otherwise widen the content past the tool's width. */
min-width: 0;
}
}
@@ -502,14 +502,20 @@
color: var(--v2-text-text-muted);
}
/* Code Mode results have no command line above them. */
[data-slot="bash-result"]:first-child {
border-block-start: 0;
}
[data-slot="bash-command"] {
color: var(--v2-text-text-base);
}
}
}
/* Only a shell's own card; an Execute output card opens inside the content and must not move the rows above it. */
[data-component="collapsible"].tool-collapsible:has(
> [data-slot="collapsible-content"] [data-component="bash-output"][data-variant="shell"]
> [data-slot="collapsible-content"] > [data-component="bash-output"][data-variant="shell"]
) {
--tool-content-gap: 8px;
}
@@ -666,6 +672,11 @@
color: var(--v2-state-fg-danger);
}
[data-slot="execute-tool-output"] {
margin-top: 4px;
padding-inline-start: 20px;
}
[data-slot="execute-tool-error"] {
margin-top: 4px;
max-height: 240px;
@@ -562,6 +562,29 @@ export const executeCodeDocument = document([
output: "[]",
metadata: { toolCalls: [{ tool: "browser.tabs.list", status: "completed" }] },
}),
completedTool({
id: "tool_execute_long_call",
name: "execute",
offset: 52_700,
args: {
code: 'return await tools.mesh.exec({ target: "blade", script: "foreach ($t in \'\\\\opencode-mesh\\\\sync\', \'\\\\opencode-mesh\\\\run\') { schtasks /delete /tn $t /f 2>&1 | Out-Null; \\"deleted $t\\" }" })',
},
output:
"deleted \\opencode-mesh\\sync\ndeleted \\opencode-mesh\\run\nCleanup finished on blade after removing both scheduled tasks and their trigger history.",
metadata: {
toolCalls: [
{
tool: "mesh.exec",
status: "completed",
input: {
target: "blade",
script:
"foreach ($t in '\\opencode-mesh\\sync', '\\opencode-mesh\\run') { schtasks /delete /tn $t /f 2>&1 | Out-Null; \"deleted $t\" }",
},
},
],
},
}),
],
}),
] satisfies SessionMessageInfo[])
@@ -1932,7 +1932,7 @@ ToolRegistry.register({
const subtitle = createMemo(() => executeToolSubtitle(calls()))
const code = createMemo(() => (toolInput(props.input).code ?? "").trimEnd())
// Like the TUI, only a runtime failure shows its output; a failed nested call is marked on its row.
// A runtime failure shows its output on the failing row; a successful script shows its result below the calls.
const error = createMemo(() => {
if (props.status !== "error" && props.metadata.error !== true) return undefined
const fallback = i18n.t("ui.toolErrorCard.failed")
@@ -1941,6 +1941,14 @@ ToolRegistry.register({
return text.replace(/^Error:\s*/, "").trim() || fallback
})
const output = createMemo(() =>
error()
? ""
: stripAnsi(props.output ?? "")
.replace(/\r\n?/g, "\n")
.trim(),
)
// A runtime failure belongs to the call that raised it; scripts can also fail outside any call.
const owner = createMemo(() => calls().findLastIndex((call) => call.status === "error"))
@@ -1953,7 +1961,7 @@ ToolRegistry.register({
dialog.show(() => (
<ExecuteDetails
code={code()}
output={stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n").trim()}
output={output()}
error={error()}
calls={calls().length}
status={props.status}
@@ -1965,7 +1973,7 @@ ToolRegistry.register({
{...props}
icon="console"
rail={false}
hasContent={calls().length > 0 || !!error()}
hasContent={calls().length > 0 || !!error() || !!output()}
compact
allowOpenWhilePending
trigger={(open) => (
@@ -1998,6 +2006,7 @@ ToolRegistry.register({
{(call, index) => <ExecuteCallRow call={call()} error={index === owner() ? error() : undefined} />}
</Index>
<Show when={owner() === -1 && error()}>{(text) => <ExecuteCallRow error={text()} />}</Show>
<Show when={output()}>{(value) => <ExecuteOutputRow output={value()} />}</Show>
</div>
</BasicTool>
)
@@ -2120,6 +2129,49 @@ function ExecuteDetailsSection(props: {
)
}
function ExecuteOutputRow(props: { output: string }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const toggle = () => {
if (window.getSelection()?.toString()) return
setOpen((value) => !value)
}
return (
<div data-slot="execute-tool-call-item">
<div
data-slot="execute-tool-call"
data-kind="output"
role="button"
tabIndex={0}
aria-expanded={open()}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span data-slot="execute-tool-call-marker">
<span data-slot="execute-tool-call-arrow">›</span>
</span>
<span data-slot="execute-tool-call-name">{i18n.t("ui.tool.execute.output")}</span>
<span data-slot="execute-tool-call-args">
{i18n.plural("ui.tool.execute.lines", props.output.split("\n").length)}
</span>
</div>
<Show when={open()}>
<div data-slot="execute-tool-output">
<ConsoleOutput copy={props.output} variant="shell">
<span data-slot="bash-result">{props.output}</span>
</ConsoleOutput>
</div>
</Show>
</div>
)
}
function ExecuteCallRow(props: { call?: ExecuteToolCall; error?: string }) {
const [open, setOpen] = createSignal(false)
const args = createMemo(() => (props.call ? executeToolCallArgs(props.call) : []))