Skip to content

Commit 5be4b16

Browse files
feat(session-ui): add a copy button to shell output (#58974)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
1 parent efb6b1d commit 5be4b16

7 files changed

Lines changed: 100 additions & 49 deletions

File tree

‎packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ test.describe("timeline visual lifecycle stability", () => {
9393
),
9494
)
9595
await expect(
96-
page.locator(`[data-timeline-part-id="${renderedPartID(ids[2])}"] [data-slot="bash-pre"]`),
96+
page.locator(`[data-timeline-part-id="${renderedPartID(ids[2])}"] [data-slot="bash-result"]`),
9797
).toContainText("line 50")
9898

9999
const short = page.locator(`[data-timeline-part-id="${renderedPartID(ids[1])}"]`)

‎packages/session-ui/component-tests/session-lifecycle.spec.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { expect, story } from "../../storybook/playwright/story"
22

3-
for (const tool of ["shell", "execute", "subagent"]) {
3+
for (const [tool, title] of [
4+
["shell", "Shell"],
5+
["execute", "Execute"],
6+
["subagent", "Agent"],
7+
]) {
48
for (const open of [false, true]) {
59
story(`keeps ${tool} inside an existing ${open ? "open" : "closed"} group through execution`, async ({ mount }) => {
610
const timeline = await mount("current-session-terminal-work--terminal-commands", {
@@ -16,7 +20,6 @@ for (const tool of ["shell", "execute", "subagent"]) {
1620
await timeline.getByRole("button", { name: "Start tool", exact: true }).click()
1721
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
1822
const original = await group.elementHandle()
19-
const title = tool === "subagent" ? "Agent" : tool === "execute" ? "Execute" : "Shell"
2023

2124
for (const action of [undefined, "Complete input", "Run command", "Complete command"]) {
2225
if (action) await timeline.getByRole("button", { name: action, exact: true }).click()
@@ -109,7 +112,7 @@ story("shimmers and expands a running shell command", async ({ mount }) => {
109112
await expect(trigger).toHaveAttribute("aria-expanded", "false")
110113
await trigger.click()
111114
await expect(trigger).toHaveAttribute("aria-expanded", "true")
112-
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
115+
await expect(tool.locator('[data-slot="bash-result"]')).toContainText("still running")
113116
})
114117

115118
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts

‎packages/session-ui/component-tests/session-shell.spec.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,20 @@ for (const [id, output] of [
5555
await expect(root.locator('[data-component="session-shell-message"] [data-slot="bash-result"]')).toHaveText(output)
5656
})
5757
}
58+
59+
story("copies the shell command and its output separately", async ({ mount, page }) => {
60+
const root = await mount("current-session-terminal-work--expanded-shell")
61+
const shell = root.locator('[data-component="bash-output"]')
62+
const command = (await shell.locator('[data-slot="bash-command"]').textContent())!
63+
const output = (await shell.locator('[data-slot="bash-result"]').textContent())!
64+
const clipboard = async () => (await page.evaluate(() => navigator.clipboard.readText())).replaceAll("\r\n", "\n")
65+
await page.context().grantPermissions(["clipboard-read", "clipboard-write"])
66+
await shell.hover()
67+
await shell.locator('[data-slot="bash-result"]').getByRole("button", { name: "Copy output", exact: true }).click()
68+
await expect(shell.locator('[data-slot="bash-result"]').getByRole("button", { name: "Copied" })).toBeVisible()
69+
expect(await clipboard()).toBe(output)
70+
await shell.locator('[data-slot="bash-command"]').getByRole("button", { name: "Copy", exact: true }).click()
71+
expect(await clipboard()).toBe(command)
72+
expect(command).toContain("opencode2 api get /openapi.json")
73+
expect(output).toContain('"name":"OpenCode"')
74+
})

‎packages/session-ui/component-tests/session-timeline-mobile.spec.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ story.describe("touch timeline", () => {
3030
story("keeps shell copy visible without hover", async ({ mount }) => {
3131
const timeline = await mount("current-session-terminal-work--expanded-shell")
3232
const copy = timeline.locator('[data-slot="bash-copy"]')
33-
await expect(copy).toHaveCount(1)
34-
await expect(copy).toHaveCSS("opacity", "1")
35-
await expect(copy).toHaveCSS("pointer-events", "auto")
33+
await expect(copy).toHaveCount(2)
34+
35+
for (const button of await copy.all()) {
36+
await expect(button).toHaveCSS("opacity", "1")
37+
await expect(button).toHaveCSS("pointer-events", "auto")
38+
}
3639
})
3740

3841
story("keeps error copy visible without hover", async ({ mount, page }) => {

‎packages/session-ui/src/components/message-part.css‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -479,21 +479,34 @@
479479
&[data-variant="shell"] {
480480
background: var(--v2-background-bg-base);
481481

482-
[data-slot="bash-pre"] {
483-
padding: 0;
484-
}
485-
486482
[data-slot="bash-pre"] code {
483+
display: block;
487484
font-family: var(--v2-font-family-code);
488485
font-weight: 440;
489486
line-height: var(--line-height-base);
490487
letter-spacing: -0.04px;
491488
}
492489

490+
/* Each section stacks its copy button over its text in one grid cell, so the sticky button stays in view
491+
while that section scrolls and never leaves its own section. */
493492
[data-slot="bash-command"],
494493
[data-slot="bash-result"] {
495-
display: block;
496-
padding: 12px;
494+
display: grid;
495+
grid-template-columns: minmax(0, 1fr);
496+
}
497+
498+
[data-slot="bash-pre"],
499+
[data-slot="bash-copy"] {
500+
grid-area: 1 / 1;
501+
}
502+
503+
[data-slot="bash-copy"] {
504+
position: sticky;
505+
top: 4px;
506+
z-index: 1;
507+
justify-self: end;
508+
align-self: start;
509+
margin: 4px;
497510
}
498511

499512
[data-slot="bash-result"] {

‎packages/session-ui/src/tools/tool-renderer.tsx‎

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,47 +1875,69 @@ ToolRegistry.register({
18751875
},
18761876
})
18771877

1878-
function ConsoleOutput(props: { copy: string; children: JSX.Element; variant?: "shell" }) {
1878+
function ConsoleOutput(props: { command: string; output: string }) {
18791879
const i18n = useI18n()
1880-
const [copied, setCopied] = createSignal(false)
1881-
1882-
const copy = async () => {
1883-
if (!props.copy) return
1884-
1885-
if (!(await writeClipboard(props.copy))) return
1886-
setCopied(true)
1887-
setTimeout(() => setCopied(false), 2000)
1888-
}
18891880

18901881
return (
1891-
<div data-component="bash-output" data-variant={props.variant} dir="ltr">
1892-
<div data-slot="bash-copy">
1893-
<Tooltip value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
1894-
<IconButton
1895-
icon={<Icon name={copied() ? "check" : "outline-copy"} size="small" />}
1896-
size="normal"
1897-
variant="ghost-muted"
1898-
onMouseDown={(event) => event.preventDefault()}
1899-
onClick={copy}
1900-
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
1901-
/>
1902-
</Tooltip>
1903-
</div>
1882+
<div data-component="bash-output" data-variant="shell" dir="ltr">
19041883
<div
19051884
data-slot="bash-scroll"
19061885
data-scrollable
19071886
tabIndex={0}
19081887
role="region"
19091888
aria-label={i18n.t("ui.scrollView.ariaLabel")}
19101889
>
1911-
<pre data-slot="bash-pre">
1912-
<code>{props.children}</code>
1913-
</pre>
1890+
<div data-slot="bash-command">
1891+
<ConsoleCopy text={props.command} label={i18n.t("ui.message.copy")} />
1892+
<pre data-slot="bash-pre">
1893+
<code>{props.command}</code>
1894+
</pre>
1895+
</div>
1896+
<Show when={props.output}>
1897+
{(output) => (
1898+
<div data-slot="bash-result">
1899+
<ConsoleCopy text={output()} label={i18n.t("ui.message.copyOutput")} />
1900+
<pre data-slot="bash-pre">
1901+
<code>{output()}</code>
1902+
</pre>
1903+
</div>
1904+
)}
1905+
</Show>
19141906
</div>
19151907
</div>
19161908
)
19171909
}
19181910

1911+
// The button sits beside its section's <pre>, not inside it, so selecting and copying text never picks it up.
1912+
function ConsoleCopy(props: { text: string; label: string }) {
1913+
const i18n = useI18n()
1914+
const [copied, setCopied] = createSignal(false)
1915+
const label = () => (copied() ? i18n.t("ui.message.copied") : props.label)
1916+
1917+
const copy = async () => {
1918+
if (!props.text) return
1919+
1920+
if (!(await writeClipboard(props.text))) return
1921+
setCopied(true)
1922+
setTimeout(() => setCopied(false), 2000)
1923+
}
1924+
1925+
return (
1926+
<div data-slot="bash-copy">
1927+
<Tooltip value={label()} placement="top">
1928+
<IconButton
1929+
icon={<Icon name={copied() ? "check" : "outline-copy"} size="small" />}
1930+
size="normal"
1931+
variant="ghost-muted"
1932+
onMouseDown={(event) => event.preventDefault()}
1933+
onClick={copy}
1934+
aria-label={label()}
1935+
/>
1936+
</Tooltip>
1937+
</div>
1938+
)
1939+
}
1940+
19191941
ToolRegistry.register({
19201942
name: "execute",
19211943
render(props) {
@@ -1946,10 +1968,7 @@ ToolRegistry.register({
19461968
</div>
19471969
)}
19481970
>
1949-
<ConsoleOutput copy={code()} variant="shell">
1950-
<span data-slot="bash-command">{code()}</span>
1951-
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
1952-
</ConsoleOutput>
1971+
<ConsoleOutput command={code()} output={output()} />
19531972
</BasicTool>
19541973
)
19551974
},
@@ -1998,12 +2017,7 @@ ToolRegistry.register({
19982017
),
19992018
)
20002019

2001-
return (
2002-
<ConsoleOutput copy={command()} variant="shell">
2003-
<span data-slot="bash-command">{command()}</span>
2004-
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
2005-
</ConsoleOutput>
2006-
)
2020+
return <ConsoleOutput command={command()} output={output()} />
20072021
}
20082022

20092023
return (

‎packages/ui/src/i18n/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ const source = {
252252
"ui.message.voice": "Voice",
253253
"ui.message.deletePending": "Delete",
254254
"ui.message.copyResponse": "Copy response",
255+
"ui.message.copyOutput": "Copy output",
255256
"ui.message.copied": "Copied",
256257
"ui.message.thought": "Thought",
257258
"ui.message.duration.seconds": "{{count}}s",

0 commit comments

Comments
 (0)