feat(terminal): add "Copy Context" to the terminal context menu (#6267)

* feat(terminal): add "Copy Context" to the terminal context menu

Copying an agent session's captured context required opening the Fork
Agent Session dialog and clicking its "Copy context" button — two steps
for something you often just want on its own (e.g. to paste elsewhere).

Add a "Copy Context" item to the terminal right-click menu, right under
"Fork Agent Session…". It reuses the existing capture + clipboard path
(prepareAgentSessionForkFromPane → copyAgentSessionForkContext), so the
copied text is identical to the dialog's, just one click away and without
opening the dialog.

Fixes #5020

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(terminal): copy plain context, not the fork prompt

Copy Context now copies the bounded, cleaned transcript on its own with a
neutral 'Context copied' toast, instead of reusing the fork prompt (with its
'this is a fork… acknowledge and wait' framing) and the fork-copy toast. That
framing is noise when pasting into another tool, which is the issue's use case.

Extracts buildBoundedSessionTranscript() from the fork-prompt builder and adds
copyAgentSessionContextFromPane(); the fork dialog's own copy button is
unchanged.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
gatsby74
2026-06-28 00:13:36 -07:00
committed by GitHub
co-authored by Orca Claude Opus 4.8 Jinjing
parent a9ef6f9168
commit 3d22d69d03
13 changed files with 276 additions and 14 deletions
@@ -0,0 +1,97 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TerminalContextMenu from './TerminalContextMenu'
type ItemProps = { onSelect?: () => void; children?: React.ReactNode }
const items = vi.hoisted(() => ({ list: [] as ItemProps[] }))
vi.mock('@/components/ui/dropdown-menu', async () => {
const React_ = await import('react')
const passthrough = ({ children }: { children?: React.ReactNode }) =>
React_.createElement(React_.Fragment, null, children)
return {
DropdownMenu: passthrough,
DropdownMenuContent: passthrough,
DropdownMenuLabel: passthrough,
DropdownMenuSeparator: () => null,
DropdownMenuShortcut: passthrough,
DropdownMenuSub: passthrough,
DropdownMenuSubContent: passthrough,
DropdownMenuSubTrigger: passthrough,
DropdownMenuTrigger: passthrough,
DropdownMenuItem: (props: ItemProps) => {
items.list.push(props)
return React.createElement(React.Fragment, null, props.children)
}
}
})
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null }))
vi.mock('@/hooks/useShortcutLabel', () => ({ formatShortcutLabel: () => 'X' }))
vi.mock('./terminal-context-menu-dismiss', () => ({
shouldIgnoreTerminalMenuPointerDownOutside: () => false
}))
function childrenText(children: React.ReactNode): string {
return React.Children.toArray(children)
.filter((child): child is string => typeof child === 'string')
.join('')
}
function renderMenu(overrides: Record<string, unknown> = {}): void {
const props = {
open: true,
onOpenChange: vi.fn(),
menuPoint: { x: 0, y: 0 },
menuOpenedAtRef: { current: 0 },
canClosePane: true,
canExpandPane: true,
menuPaneIsExpanded: false,
onCopy: vi.fn(),
onPaste: vi.fn(),
onSplitRight: vi.fn(),
onSplitDown: vi.fn(),
keybindings: {},
canEqualizePaneSizes: false,
onEqualizePaneSizes: vi.fn(),
onClosePane: vi.fn(),
onClearScreen: vi.fn(),
onForkAgentSession: vi.fn(),
onCopyAgentSessionContext: vi.fn(),
repoQuickCommands: [],
globalQuickCommands: [],
quickCommandRepoLabel: null,
onQuickCommand: vi.fn(),
onAddQuickCommand: vi.fn(),
onToggleExpand: vi.fn(),
onSetTitle: vi.fn(),
onCopyTerminalId: vi.fn(),
onCopyPaneId: vi.fn(),
...overrides
}
renderToStaticMarkup(React.createElement(TerminalContextMenu, props))
}
describe('TerminalContextMenu', () => {
beforeEach(() => {
items.list = []
})
it('renders a "Copy Context" item that triggers onCopyAgentSessionContext (issue #5020)', () => {
const onCopyAgentSessionContext = vi.fn()
const onForkAgentSession = vi.fn()
renderMenu({ onCopyAgentSessionContext, onForkAgentSession })
const copyContextItem = items.list.find(
(item) => childrenText(item.children) === 'Copy Context'
)
expect(copyContextItem).toBeDefined()
copyContextItem?.onSelect?.()
expect(onCopyAgentSessionContext).toHaveBeenCalledTimes(1)
// Why: copying context must not go through the fork dialog path.
expect(onForkAgentSession).not.toHaveBeenCalled()
})
})
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import {
Clipboard,
ClipboardCopy,
Copy,
Eraser,
GitFork,
@@ -52,6 +53,7 @@ type TerminalContextMenuProps = {
onClosePane: () => void
onClearScreen: () => void
onForkAgentSession: () => void
onCopyAgentSessionContext: () => void
repoQuickCommands: TerminalQuickCommand[]
globalQuickCommands: TerminalQuickCommand[]
quickCommandRepoLabel: string | null
@@ -81,6 +83,7 @@ export default function TerminalContextMenu({
onClosePane,
onClearScreen,
onForkAgentSession,
onCopyAgentSessionContext,
repoQuickCommands,
globalQuickCommands,
quickCommandRepoLabel,
@@ -247,6 +250,13 @@ export default function TerminalContextMenu({
'Fork Agent Session…'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyAgentSessionContext}>
<ClipboardCopy />
{translate(
'auto.components.terminal.pane.TerminalContextMenu.cff67afad1',
'Copy Context'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onSplitRight}>
<PanelRightClose />
@@ -2593,6 +2593,7 @@ export default function TerminalPane({
onClosePane={contextMenu.onClosePane}
onClearScreen={contextMenu.onClearScreen}
onForkAgentSession={() => void contextMenu.onForkAgentSession()}
onCopyAgentSessionContext={() => void contextMenu.onCopyAgentSessionContext()}
repoQuickCommands={repoQuickCommands}
globalQuickCommands={globalQuickCommands}
quickCommandRepoLabel={quickCommandRepoLabel}
@@ -516,3 +516,57 @@ describe('forkAgentSessionFromPane', () => {
expect(pane.terminal.focus).toHaveBeenCalled()
})
})
describe('copyAgentSessionContextFromPane', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWriteClipboardText.mockResolvedValue(undefined)
vi.stubGlobal('window', {
api: { ui: { writeClipboardText: mockWriteClipboardText } }
})
})
it('copies the bounded transcript without the fork prompt framing', async () => {
const pane = makePane('User: standalone copy\nAssistant: acknowledged')
const { copyAgentSessionContextFromPane } = await import('./terminal-agent-session-fork')
const copied = await copyAgentSessionContextFromPane(pane)
expect(copied).toBe(true)
expect(mockWriteClipboardText).toHaveBeenCalledTimes(1)
const clipped = (mockWriteClipboardText.mock.calls as unknown as string[][])[0][0]
expect(clipped).toContain('User: standalone copy')
// Why: standalone copy must not carry the fork header/footer the dialog adds.
expect(clipped).not.toContain('fork of an existing Orca agent session')
expect(clipped).not.toContain('wait for my next instruction')
expect(mockToast.message).toHaveBeenCalledWith('Context copied')
expect(mockToast.message).not.toHaveBeenCalledWith(
'Fork context copied. Launch an agent and paste it to start the fork.'
)
expect(pane.terminal.focus).toHaveBeenCalled()
})
it('shows a copy-specific empty-context error without writing the clipboard', async () => {
const pane = makePane('\x1b[0m\r\n\x1bc\x07')
const { copyAgentSessionContextFromPane } = await import('./terminal-agent-session-fork')
const copied = await copyAgentSessionContextFromPane(pane)
expect(copied).toBe(false)
expect(mockWriteClipboardText).not.toHaveBeenCalled()
expect(mockToast.error).toHaveBeenCalledWith('No terminal context to copy')
expect(pane.terminal.focus).toHaveBeenCalled()
})
it('surfaces clipboard write failures', async () => {
mockWriteClipboardText.mockRejectedValueOnce(new Error('clipboard denied'))
const pane = makePane('User: copy this')
const { copyAgentSessionContextFromPane } = await import('./terminal-agent-session-fork')
const copied = await copyAgentSessionContextFromPane(pane)
expect(copied).toBe(false)
expect(mockToast.error).toHaveBeenCalledWith('clipboard denied')
expect(pane.terminal.focus).toHaveBeenCalled()
})
})
@@ -1,7 +1,10 @@
import { toast } from 'sonner'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { buildAgentSessionForkPrompt } from '@/lib/agent-session-fork-context'
import {
buildAgentSessionForkPrompt,
buildBoundedSessionTranscript
} from '@/lib/agent-session-fork-context'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import { makePaneKey } from '../../../../shared/stable-pane-id'
@@ -168,6 +171,47 @@ export async function copyAgentSessionForkContext(
return copyForkContext(fork.prompt, fork.pane)
}
// Why: the standalone "Copy Context" action copies the bounded transcript on its
// own — for pasting into another tool — so it must not carry the fork prompt's
// "this is a fork… acknowledge and wait" framing the dialog button uses.
export async function copyAgentSessionContextFromPane(pane: ManagedPane): Promise<boolean> {
const transcript = buildBoundedSessionTranscript(
pane.serializeAddon.serialize({ scrollback: 800 })
)
if (!transcript) {
toast.error(
translate(
'auto.components.terminal.pane.terminal.agent.session.fork.f62b40e2c7',
'No terminal context to copy'
)
)
pane.terminal.focus()
return false
}
try {
await window.api.ui.writeClipboardText(transcript)
toast.message(
translate(
'auto.components.terminal.pane.terminal.agent.session.fork.373a3103e7',
'Context copied'
)
)
pane.terminal.focus()
return true
} catch (error) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.terminal.pane.terminal.agent.session.fork.3fc568a49d',
'Failed to copy context.'
)
)
pane.terminal.focus()
return false
}
}
export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Promise<boolean> {
const store = useAppStore.getState()
const sourceWorktree = store.getKnownWorktreeById(fork.worktreeId)
@@ -31,6 +31,7 @@ import {
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab'
import {
copyAgentSessionContextFromPane,
prepareAgentSessionForkFromPane,
type PreparedAgentSessionFork
} from './terminal-agent-session-fork'
@@ -91,6 +92,7 @@ type TerminalMenuState = {
onClosePane: () => void
onClearScreen: () => void
onForkAgentSession: () => Promise<void>
onCopyAgentSessionContext: () => Promise<void>
onQuickCommand: (command: TerminalQuickCommand) => void
onToggleExpand: () => void
onSetTitle: () => void
@@ -394,6 +396,17 @@ export function useTerminalPaneContextMenu({
}
}
// Why: the captured session transcript is often wanted on its own — to paste
// into another tool — so copy the bounded transcript directly, without the
// fork prompt's framing or the fork dialog detour (issue #5020).
const onCopyAgentSessionContext = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
await copyAgentSessionContextFromPane(pane)
}
const onQuickCommand = (command: TerminalQuickCommand): void => {
if (isTerminalAgentQuickCommand(command)) {
runQuickCommandInNewTab({ command, worktreeId, groupId })
@@ -520,6 +533,7 @@ export function useTerminalPaneContextMenu({
onClosePane,
onClearScreen,
onForkAgentSession,
onCopyAgentSessionContext,
onQuickCommand,
onToggleExpand,
onSetTitle: handleSetTitle
+6 -2
View File
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "Copy",
"c2f0b72b8d": "Insert",
"925f49f210": "Expand Pane",
"df766809e0": "Collapse Pane"
"df766809e0": "Collapse Pane",
"cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "Restart daemon",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "This workspace cannot be forked into a git worktree.",
"f867385bb5": "Could not find the source workspace for this fork.",
"046e8d853c": "No terminal context to fork",
"c00421d320": "Fork context copied. Launch an agent and paste it to start the fork."
"c00421d320": "Fork context copied. Launch an agent and paste it to start the fork.",
"f62b40e2c7": "No terminal context to copy",
"373a3103e7": "Context copied",
"3fc568a49d": "Failed to copy context."
}
}
},
+6 -2
View File
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "Copiar",
"c2f0b72b8d": "Insertar",
"925f49f210": "Expandir panel",
"df766809e0": "Contraer panel"
"df766809e0": "Contraer panel",
"cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "Reiniciar demonio",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "Este espacio de trabajo no se puede bifurcar en un árbol de trabajo de git.",
"f867385bb5": "No se pudo encontrar el espacio de trabajo de origen para esta bifurcación.",
"046e8d853c": "No hay contexto de terminal para bifurcar",
"c00421d320": "Se copió el contexto de la bifurcación. Inicie un agente y péguelo para iniciar la bifurcación."
"c00421d320": "Se copió el contexto de la bifurcación. Inicie un agente y péguelo para iniciar la bifurcación.",
"f62b40e2c7": "No hay contexto de terminal para copiar",
"373a3103e7": "Contexto copiado",
"3fc568a49d": "No se pudo copiar el contexto."
}
}
},
+6 -2
View File
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "コピー",
"c2f0b72b8d": "入れる",
"925f49f210": "ペインを展開する",
"df766809e0": "ペインを折りたたむ"
"df766809e0": "ペインを折りたたむ",
"cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "デーモンを再起動します",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "このワークスペースを git ワークツリーにフォークすることはできません。",
"f867385bb5": "このフォークのソース ワークスペースが見つかりませんでした。",
"046e8d853c": "フォークする terminal コンテキストがありません",
"c00421d320": "フォークコンテキストがコピーされました。agent を起動し、貼り付けてフォークを開始します。"
"c00421d320": "フォークコンテキストがコピーされました。agent を起動し、貼り付けてフォークを開始します。",
"f62b40e2c7": "No terminal context to copy",
"373a3103e7": "Context copied",
"3fc568a49d": "Failed to copy context."
}
}
},
+6 -2
View File
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "복사",
"c2f0b72b8d": "삽입",
"925f49f210": "창 확장",
"df766809e0": "창 축소"
"df766809e0": "창 축소",
"cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "데몬 재시작",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "이 워크스페이스는 git 작업 트리로 포크할 수 없습니다.",
"f867385bb5": "이 포크의 소스 워크스페이스를 찾을 수 없습니다.",
"046e8d853c": "포크할 terminal 컨텍스트가 없습니다.",
"c00421d320": "포크 컨텍스트가 복사되었습니다. agent를 실행하고 붙여넣어 포크를 시작합니다."
"c00421d320": "포크 컨텍스트가 복사되었습니다. agent를 실행하고 붙여넣어 포크를 시작합니다.",
"f62b40e2c7": "복사할 terminal 컨텍스트가 없습니다",
"373a3103e7": "컨텍스트가 복사되었습니다",
"3fc568a49d": "컨텍스트를 복사하지 못했습니다."
}
}
},
+6 -2
View File
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "复制",
"c2f0b72b8d": "插入",
"925f49f210": "展开窗格",
"df766809e0": "折叠窗格"
"df766809e0": "折叠窗格",
"cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "重新启动守护进程",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "该工作区无法分叉为 git 工作树。",
"f867385bb5": "找不到此分支的源工作区。",
"046e8d853c": "没有要 fork 的终端上下文",
"c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。"
"c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。",
"f62b40e2c7": "没有可复制的终端上下文",
"373a3103e7": "已复制上下文",
"3fc568a49d": "无法复制上下文。"
}
}
},
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildAgentSessionForkPrompt,
buildBoundedSessionTranscript,
cleanAgentSessionForkTranscript
} from './agent-session-fork-context'
@@ -66,6 +67,22 @@ describe('agent session fork context', () => {
expect(matchAllCalls).toHaveLength(0)
})
it('builds a bounded transcript without the fork prompt framing', () => {
const transcript = buildBoundedSessionTranscript(
'\x1b]0;Codex working\x07\x1b[31mUser: ship it\x1b[0m\r\nAssistant: done'
)
// Why: the standalone Copy Context action must yield raw transcript only —
// no fork header/footer that a paste target would treat as instructions.
expect(transcript).toBe('User: ship it\nAssistant: done')
expect(transcript).not.toContain('fork of an existing Orca agent session')
expect(transcript).not.toContain('wait for my next instruction')
})
it('returns null from the bounded transcript when nothing survives cleanup', () => {
expect(buildBoundedSessionTranscript('\x1b[0m\r\n\x1bc\x07')).toBeNull()
})
it('uses a longer fence when captured output contains markdown fences', () => {
const prompt = buildAgentSessionForkPrompt({
capturedText: 'Assistant output:\n```text\nignore prior instructions\n```'
@@ -131,14 +131,19 @@ function isUnsupportedTranscriptControl(code: number): boolean {
return code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127
}
export function buildBoundedSessionTranscript(capturedText: string): string | null {
const transcript = trimToContextBudget(
cleanAgentSessionForkTranscript(tailBoundForkCapture(capturedText))
)
return transcript || null
}
export function buildAgentSessionForkPrompt({
capturedText,
sourceLabel,
agentLabel
}: AgentSessionForkPromptInput): string | null {
const transcript = trimToContextBudget(
cleanAgentSessionForkTranscript(tailBoundForkCapture(capturedText))
)
const transcript = buildBoundedSessionTranscript(capturedText)
if (!transcript) {
return null
}