fix: expose terminal visual layout and copyable handles (#5845)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-19 15:42:13 -07:00 committed by GitHub
parent c442f3aa00
commit 3afed388fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 864 additions and 3 deletions

View File

@ -9,6 +9,7 @@ import {
formatAutomationShow,
formatComputerAction,
formatGetAppState,
formatTerminalList,
formatTerminalRead,
formatWorktreeList,
printResult
@ -195,6 +196,135 @@ describe('formatAutomationShow', () => {
})
})
describe('formatTerminalList', () => {
it('prints visual split groups and nested terminal panes', () => {
const output = formatTerminalList({
terminals: [
{
handle: 'term_left',
ptyId: 'pty-left',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'main',
tabId: 'tab-left',
leafId: 'leaf-left',
title: 'Left',
connected: true,
writable: true,
lastOutputAt: null,
preview: ''
},
{
handle: 'term_top',
ptyId: 'pty-top',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'main',
tabId: 'tab-right',
leafId: 'leaf-top',
title: 'Right top',
connected: true,
writable: true,
lastOutputAt: null,
preview: ''
},
{
handle: 'term_bottom',
ptyId: 'pty-bottom',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'main',
tabId: 'tab-right',
leafId: 'leaf-bottom',
title: 'Right bottom',
connected: true,
writable: true,
lastOutputAt: null,
preview: ''
}
],
totalCount: 3,
truncated: false,
visualLayouts: [
{
worktreeId: 'wt-1',
worktreePath: '/repo',
root: {
type: 'split',
direction: 'horizontal',
first: {
type: 'group',
groupId: 'group-left',
activeTabId: 'tab-left',
tabs: [
{
tabId: 'tab-left',
title: 'Left',
activeLeafId: 'leaf-left',
panes: {
type: 'terminal',
handle: 'term_left',
tabId: 'tab-left',
leafId: 'leaf-left',
title: 'Left',
connected: true,
active: true
}
}
]
},
second: {
type: 'group',
groupId: 'group-right',
activeTabId: 'tab-right',
tabs: [
{
tabId: 'tab-right',
title: 'Right',
activeLeafId: 'leaf-bottom',
panes: {
type: 'pane-split',
direction: 'vertical',
first: {
type: 'terminal',
handle: 'term_top',
tabId: 'tab-right',
leafId: 'leaf-top',
title: 'Right top',
connected: true,
active: false
},
second: {
type: 'terminal',
handle: 'term_bottom',
tabId: 'tab-right',
leafId: 'leaf-bottom',
title: 'Right bottom',
connected: true,
active: true
}
}
}
]
}
}
}
]
} as never)
expect(output).toContain('visual layout:')
expect(output).toContain('/repo')
expect(output).toContain('split horizontal')
expect(output).toContain('group group-left')
expect(output).toContain('tab tab-left Left')
expect(output).toContain('* term_left Left tab=tab-left leaf=leaf-left')
expect(output).toContain('group group-right')
expect(output).toContain('pane split vertical')
expect(output).toContain(' term_top Right top tab=tab-right leaf=leaf-top')
expect(output).toContain('* term_bottom Right bottom tab=tab-right leaf=leaf-bottom')
})
})
describe('formatTerminalRead', () => {
it('warns limited cursor reads to continue with the next cursor', () => {
const output = formatTerminalRead({

View File

@ -3,6 +3,10 @@ import type {
RuntimeTerminalCreate,
RuntimeTerminalFocus,
RuntimeTerminalListResult,
RuntimeTerminalVisualLayout,
RuntimeTerminalVisualLayoutNode,
RuntimeTerminalVisualPaneNode,
RuntimeTerminalVisualTab,
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalSend,
@ -21,9 +25,65 @@ export function formatTerminalList(result: RuntimeTerminalListResult): string {
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
)
.join('\n\n')
const visualLayout = formatTerminalVisualLayouts(result.visualLayouts)
const bodyWithLayout = visualLayout ? `${body}\n\nvisual layout:\n${visualLayout}` : body
return result.truncated
? `${body}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
: body
? `${bodyWithLayout}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
: bodyWithLayout
}
function formatTerminalVisualLayouts(
layouts: readonly RuntimeTerminalVisualLayout[] | undefined
): string | null {
if (!layouts || layouts.length === 0) {
return null
}
return layouts
.map((layout) =>
[
`worktree: ${layout.worktreePath || layout.worktreeId}`,
...formatVisualLayoutNode(layout.root, 0)
].join('\n')
)
.join('\n\n')
}
function formatVisualLayoutNode(node: RuntimeTerminalVisualLayoutNode, depth: number): string[] {
const indent = ' '.repeat(depth)
if (node.type === 'split') {
return [
`${indent}split ${node.direction}`,
...formatVisualLayoutNode(node.first, depth + 1),
...formatVisualLayoutNode(node.second, depth + 1)
]
}
return [
`${indent}group ${node.groupId ?? '(default)'}`,
...node.tabs.flatMap((tab) => formatVisualTab(tab, depth + 1))
]
}
function formatVisualTab(tab: RuntimeTerminalVisualTab, depth: number): string[] {
const indent = ' '.repeat(depth)
return [
`${indent}tab ${tab.tabId} ${tab.title ?? '(untitled)'}`,
...formatVisualPaneNode(tab.panes, depth + 1)
]
}
function formatVisualPaneNode(node: RuntimeTerminalVisualPaneNode, depth: number): string[] {
const indent = ' '.repeat(depth)
if (node.type === 'pane-split') {
return [
`${indent}pane split ${node.direction}`,
...formatVisualPaneNode(node.first, depth + 1),
...formatVisualPaneNode(node.second, depth + 1)
]
}
const marker = node.active ? '* ' : ' '
return [
`${indent}${marker}${node.handle} ${node.title ?? '(untitled)'} tab=${node.tabId} leaf=${node.leafId}`
]
}
export function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string {

View File

@ -223,6 +223,7 @@ import type {
RuntimeTerminalFocus,
RuntimeTerminalClose,
RuntimeTerminalListResult,
RuntimeTerminalResolvePane,
RuntimeTerminalState,
RuntimeStatus,
RuntimeSyncWindowGraphResult,
@ -236,6 +237,11 @@ import type {
RuntimeSpeechSetupState,
RuntimeTerminalShow,
RuntimeTerminalSummary,
RuntimeTerminalVisualGroupNode,
RuntimeTerminalVisualLayout,
RuntimeTerminalVisualLayoutNode,
RuntimeTerminalVisualPaneNode,
RuntimeTerminalVisualTab,
RuntimeSyncedLeaf,
RuntimeSyncedTab,
RuntimeMarkdownReadTabResult,
@ -7492,13 +7498,209 @@ export class OrcaRuntimeService {
terminals.push(this.buildPtyTerminalSummary(pty, worktreesById))
}
const listedTerminals = terminals.slice(0, limit)
const visualLayouts = this.buildTerminalVisualLayouts(
listedTerminals,
worktreesById,
targetWorktreeId
)
return {
terminals: terminals.slice(0, limit),
terminals: listedTerminals,
...(visualLayouts.length > 0 ? { visualLayouts } : {}),
totalCount: terminals.length,
truncated: terminals.length > limit
}
}
private buildTerminalVisualLayouts(
terminals: RuntimeTerminalSummary[],
worktreesById: Map<string, ResolvedWorktree>,
targetWorktreeId: string | null
): RuntimeTerminalVisualLayout[] {
if (terminals.length === 0) {
return []
}
// Why: the mobile/session snapshot supplies topology, but terminal.list
// must print the same handles in both the flat list and visual tree.
const summariesByLeafKey = new Map(
terminals.map((terminal) => [this.getLeafKey(terminal.tabId, terminal.leafId), terminal])
)
const summariesByWorktree = new Map<string, RuntimeTerminalSummary[]>()
for (const terminal of terminals) {
const existing = summariesByWorktree.get(terminal.worktreeId)
if (existing) {
existing.push(terminal)
} else {
summariesByWorktree.set(terminal.worktreeId, [terminal])
}
}
const snapshots = targetWorktreeId
? [this.mobileSessionTabsByWorktree.get(targetWorktreeId)].filter(
(snapshot): snapshot is RuntimeMobileSessionTabsSnapshot => snapshot !== undefined
)
: [...this.mobileSessionTabsByWorktree.values()]
const layouts: RuntimeTerminalVisualLayout[] = []
for (const snapshot of snapshots) {
const worktreeTerminals = summariesByWorktree.get(snapshot.worktree)
if (!worktreeTerminals || worktreeTerminals.length === 0) {
continue
}
const groups = this.buildTerminalVisualGroups(snapshot, summariesByLeafKey)
if (groups.length === 0) {
continue
}
const groupsById = new Map(
groups
.filter((group): group is RuntimeTerminalVisualGroupNode & { groupId: string } =>
Boolean(group.groupId)
)
.map((group) => [group.groupId, group])
)
const root =
this.buildTerminalVisualGroupLayout(snapshot.tabGroupLayout, groupsById) ?? groups[0]
if (!root) {
continue
}
const worktree = worktreesById.get(snapshot.worktree)
layouts.push({
worktreeId: snapshot.worktree,
worktreePath: worktree?.path ?? worktreeTerminals[0]?.worktreePath ?? '',
root
})
}
return layouts
}
private buildTerminalVisualGroups(
snapshot: RuntimeMobileSessionTabsSnapshot,
summariesByLeafKey: ReadonlyMap<string, RuntimeTerminalSummary>
): RuntimeTerminalVisualGroupNode[] {
const terminalTabs = snapshot.tabs.filter(
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
)
if (terminalTabs.length === 0) {
return []
}
const tabsByParentId = new Map<string, RuntimeMobileSessionTerminalTab[]>()
const parentOrder: string[] = []
for (const tab of terminalTabs) {
const existing = tabsByParentId.get(tab.parentTabId)
if (existing) {
existing.push(tab)
} else {
parentOrder.push(tab.parentTabId)
tabsByParentId.set(tab.parentTabId, [tab])
}
}
const groupSources =
snapshot.tabGroups && snapshot.tabGroups.length > 0
? snapshot.tabGroups
: [{ id: null, activeTabId: snapshot.activeTabId, tabOrder: parentOrder }]
return groupSources
.map((group): RuntimeTerminalVisualGroupNode | null => {
const tabs = group.tabOrder
.map((tabId) => {
const surfaces =
tabsByParentId.get(tabId) ?? terminalTabs.filter((tab) => tab.id === tabId)
return this.buildTerminalVisualTab(tabId, surfaces, summariesByLeafKey)
})
.filter((tab): tab is RuntimeTerminalVisualTab => tab !== null)
if (tabs.length === 0) {
return null
}
return {
type: 'group',
groupId: group.id,
activeTabId: group.activeTabId,
tabs
}
})
.filter((group): group is RuntimeTerminalVisualGroupNode => group !== null)
}
private buildTerminalVisualTab(
tabId: string,
surfaces: RuntimeMobileSessionTerminalTab[],
summariesByLeafKey: ReadonlyMap<string, RuntimeTerminalSummary>
): RuntimeTerminalVisualTab | null {
const firstSurface = surfaces[0]
if (!firstSurface) {
return null
}
const parentTabId = firstSurface.parentTabId
const activeLeafId =
firstSurface.parentLayout?.activeLeafId ??
surfaces.find((surface) => surface.isActive)?.leafId ??
firstSurface.leafId
const root = firstSurface.parentLayout?.root ?? {
type: 'leaf' as const,
leafId: firstSurface.leafId
}
const panes = this.buildTerminalVisualPane(root, parentTabId, activeLeafId, summariesByLeafKey)
if (!panes) {
return null
}
return {
tabId: parentTabId || tabId,
title: this.tabs.get(parentTabId)?.title ?? firstSurface.title ?? null,
activeLeafId,
panes
}
}
private buildTerminalVisualPane(
node: TerminalPaneLayoutNode,
tabId: string,
activeLeafId: string | null,
summariesByLeafKey: ReadonlyMap<string, RuntimeTerminalSummary>
): RuntimeTerminalVisualPaneNode | null {
if (node.type === 'leaf') {
const summary = summariesByLeafKey.get(this.getLeafKey(tabId, node.leafId))
if (!summary) {
return null
}
return {
type: 'terminal',
handle: summary.handle,
tabId: summary.tabId,
leafId: summary.leafId,
title: summary.title,
connected: summary.connected,
active: summary.leafId === activeLeafId
}
}
const first = this.buildTerminalVisualPane(node.first, tabId, activeLeafId, summariesByLeafKey)
const second = this.buildTerminalVisualPane(
node.second,
tabId,
activeLeafId,
summariesByLeafKey
)
if (first && second) {
return { type: 'pane-split', direction: node.direction, first, second }
}
return first ?? second
}
private buildTerminalVisualGroupLayout(
node: TabGroupLayoutNode | null | undefined,
groupsById: ReadonlyMap<string, RuntimeTerminalVisualGroupNode>
): RuntimeTerminalVisualLayoutNode | null {
if (!node) {
return null
}
if (node.type === 'leaf') {
return groupsById.get(node.groupId) ?? null
}
const first = this.buildTerminalVisualGroupLayout(node.first, groupsById)
const second = this.buildTerminalVisualGroupLayout(node.second, groupsById)
if (first && second) {
return { type: 'split', direction: node.direction, first, second }
}
return first ?? second
}
// Why: when --terminal is omitted, the CLI auto-resolves to the active
// terminal in the current worktree — matching browser's implicit active tab.
async resolveActiveTerminal(worktreeSelector?: string): Promise<string> {
@ -7560,6 +7762,23 @@ export class OrcaRuntimeService {
throw new Error('no_active_terminal')
}
resolveTerminalPane(paneKey: string): RuntimeTerminalResolvePane {
// Why: the renderer context menu only knows the stable pane key; main owns
// the runtime terminal handle that agents and CLI commands can address.
const handle = this.getTerminalHandleForPaneKey(paneKey)
if (!handle) {
throw new Error('terminal_not_found')
}
const record = this.handles.get(handle)
const parsed = parsePaneKey(paneKey)
return {
handle,
tabId: record?.tabId ?? parsed?.tabId ?? '',
leafId: record?.leafId ?? parsed?.leafId ?? '',
ptyId: record?.ptyId ?? null
}
}
async showTerminal(handle: string): Promise<RuntimeTerminalShow> {
const pty = this.getLivePtyForHandle(handle)
if (pty) {

View File

@ -447,6 +447,10 @@ const TerminalResolveActive = z.object({
worktree: OptionalString
})
const TerminalResolvePane = z.object({
paneKey: requiredString('Missing pane key')
})
const TerminalRead = TerminalHandle.extend({
cursor: z
.unknown()
@ -687,6 +691,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
handle: await runtime.resolveActiveTerminal(params.worktree)
})
}),
defineMethod({
name: 'terminal.resolvePane',
params: TerminalResolvePane,
handler: async (params, { runtime }) => ({
terminal: runtime.resolveTerminalPane(params.paneKey)
})
}),
defineMethod({
name: 'terminal.show',
params: TerminalHandle,

View File

@ -2235,6 +2235,242 @@ describe('OrcaRuntimeRpcServer', () => {
await server.stop()
})
it('serves terminal.list with visual split-group and pane nesting', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService(makeStore() as never)
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
const worktreeId = 'repo-1::/tmp/worktree-a'
const leftLeaf = '11111111-1111-4111-8111-111111111111'
const topLeaf = '22222222-2222-4222-8222-222222222222'
const bottomLeaf = '33333333-3333-4333-8333-333333333333'
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-left',
worktreeId,
title: 'Left',
activeLeafId: leftLeaf,
layout: { type: 'leaf', leafId: leftLeaf }
},
{
tabId: 'tab-right',
worktreeId,
title: 'Right',
activeLeafId: bottomLeaf,
layout: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: topLeaf },
second: { type: 'leaf', leafId: bottomLeaf }
}
}
],
leaves: [
{
tabId: 'tab-left',
worktreeId,
leafId: leftLeaf,
paneRuntimeId: 1,
ptyId: 'pty-left',
title: 'Left'
},
{
tabId: 'tab-right',
worktreeId,
leafId: topLeaf,
paneRuntimeId: 1,
ptyId: 'pty-top',
title: 'Right top'
},
{
tabId: 'tab-right',
worktreeId,
leafId: bottomLeaf,
paneRuntimeId: 2,
ptyId: 'pty-bottom',
title: 'Right bottom'
}
],
mobileSessionTabs: [
{
worktree: worktreeId,
publicationEpoch: 'test',
snapshotVersion: 1,
activeGroupId: 'group-right',
activeTabId: `tab-right::${bottomLeaf}`,
activeTabType: 'terminal',
tabGroups: [
{ id: 'group-left', activeTabId: 'tab-left', tabOrder: ['tab-left'] },
{ id: 'group-right', activeTabId: 'tab-right', tabOrder: ['tab-right'] }
],
tabGroupLayout: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
},
tabs: [
{
type: 'terminal',
id: `tab-left::${leftLeaf}`,
title: 'Left',
parentTabId: 'tab-left',
leafId: leftLeaf,
ptyId: 'pty-left',
parentLayout: {
root: { type: 'leaf', leafId: leftLeaf },
activeLeafId: leftLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [leftLeaf]: 'pty-left' }
},
isActive: false
},
{
type: 'terminal',
id: `tab-right::${topLeaf}`,
title: 'Right top',
parentTabId: 'tab-right',
leafId: topLeaf,
ptyId: 'pty-top',
parentLayout: {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: topLeaf },
second: { type: 'leaf', leafId: bottomLeaf }
},
activeLeafId: bottomLeaf,
expandedLeafId: null,
ptyIdsByLeafId: {
[topLeaf]: 'pty-top',
[bottomLeaf]: 'pty-bottom'
}
},
isActive: false
},
{
type: 'terminal',
id: `tab-right::${bottomLeaf}`,
title: 'Right bottom',
parentTabId: 'tab-right',
leafId: bottomLeaf,
ptyId: 'pty-bottom',
parentLayout: {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: topLeaf },
second: { type: 'leaf', leafId: bottomLeaf }
},
activeLeafId: bottomLeaf,
expandedLeafId: null,
ptyIdsByLeafId: {
[topLeaf]: 'pty-top',
[bottomLeaf]: 'pty-bottom'
}
},
isActive: true
}
]
}
]
})
await server.start()
try {
const metadata = readRuntimeMetadata(userDataPath)
const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_list_layout',
authToken: metadata!.authToken,
method: 'terminal.list',
params: { worktree: `id:${worktreeId}` }
})
const result = listResponse.result as {
visualLayouts?: unknown[]
terminals: { handle: string; tabId: string; leafId: string }[]
}
const handleByLeaf = new Map(
result.terminals.map((terminal) => [terminal.leafId, terminal.handle])
)
expect(listResponse).toMatchObject({
id: 'req_list_layout',
ok: true
})
expect(result.visualLayouts).toMatchObject([
{
worktreeId,
worktreePath: '/tmp/worktree-a',
root: {
type: 'split',
direction: 'horizontal',
first: {
type: 'group',
groupId: 'group-left',
tabs: [
{
tabId: 'tab-left',
panes: {
type: 'terminal',
handle: handleByLeaf.get(leftLeaf),
leafId: leftLeaf
}
}
]
},
second: {
type: 'group',
groupId: 'group-right',
tabs: [
{
tabId: 'tab-right',
panes: {
type: 'pane-split',
direction: 'vertical',
first: {
type: 'terminal',
handle: handleByLeaf.get(topLeaf),
leafId: topLeaf
},
second: {
type: 'terminal',
handle: handleByLeaf.get(bottomLeaf),
leafId: bottomLeaf,
active: true
}
}
}
]
}
}
}
])
const resolvePaneResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_resolve_pane',
authToken: metadata!.authToken,
method: 'terminal.resolvePane',
params: { paneKey: `tab-right:${bottomLeaf}` }
})
expect(resolvePaneResponse).toMatchObject({
id: 'req_resolve_pane',
ok: true,
result: {
terminal: {
handle: handleByLeaf.get(bottomLeaf),
tabId: 'tab-right',
leafId: bottomLeaf,
ptyId: 'pty-bottom'
}
}
})
} finally {
await server.stop()
}
})
it('mirrors laptop-created remote runtime terminals into phone session tabs over RPC', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService(makeStore() as never)

View File

@ -59,6 +59,7 @@ type TerminalContextMenuProps = {
onAddQuickCommand: () => void
onToggleExpand: () => void
onSetTitle: () => void
onCopyTerminalId: () => void
onCopyPaneId: () => void
}
@ -87,6 +88,7 @@ export default function TerminalContextMenu({
onAddQuickCommand,
onToggleExpand,
onSetTitle,
onCopyTerminalId,
onCopyPaneId
}: TerminalContextMenuProps): React.JSX.Element {
const shortcuts = useMemo(
@ -294,6 +296,13 @@ export default function TerminalContextMenu({
<Pencil />
{translate('auto.components.terminal.pane.TerminalContextMenu.39809d152f', 'Set Title…')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyTerminalId}>
<Copy />
{translate(
'auto.components.terminal.pane.TerminalContextMenu.copyTerminalId',
'Copy Terminal ID'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyPaneId}>
<Copy />
{translate(

View File

@ -2127,6 +2127,7 @@ export default function TerminalPane({
}
onToggleExpand={contextMenu.onToggleExpand}
onSetTitle={contextMenu.onSetTitle}
onCopyTerminalId={() => void contextMenu.onCopyTerminalId()}
onCopyPaneId={contextMenu.onCopyPaneId}
/>
{/* Why: repos is a broad store slice; only subscribe while the editor is visible. */}

View File

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { copyTerminalHandleForPane } from './terminal-handle-copy'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
describe('copyTerminalHandleForPane', () => {
it('copies the runtime terminal handle for a pane key', async () => {
const callRuntime = vi.fn().mockResolvedValue({
id: 'req-1',
ok: true,
result: {
terminal: {
handle: 'term_worker',
tabId: 'tab-1',
leafId: LEAF_ID,
ptyId: 'pty-1'
}
},
_meta: { runtimeId: 'runtime-1' }
})
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
await expect(
copyTerminalHandleForPane({
tabId: 'tab-1',
leafId: LEAF_ID,
callRuntime,
writeClipboardText
})
).resolves.toBe('term_worker')
expect(callRuntime).toHaveBeenCalledWith({
method: 'terminal.resolvePane',
params: { paneKey: `tab-1:${LEAF_ID}` }
})
expect(writeClipboardText).toHaveBeenCalledWith('term_worker')
})
it('surfaces runtime lookup failures without writing the clipboard', async () => {
const callRuntime = vi.fn().mockResolvedValue({
id: 'req-1',
ok: false,
error: {
code: 'terminal_not_found',
message: 'terminal not found'
}
})
const writeClipboardText = vi.fn()
await expect(
copyTerminalHandleForPane({
tabId: 'tab-1',
leafId: LEAF_ID,
callRuntime,
writeClipboardText
})
).rejects.toThrow('terminal not found')
expect(writeClipboardText).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,45 @@
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import { makePaneKey } from '../../../../shared/stable-pane-id'
type CopyTerminalHandleDeps = {
tabId: string
leafId: string
callRuntime: (request: {
method: 'terminal.resolvePane'
params: { paneKey: string }
}) => Promise<RuntimeRpcResponse<unknown>>
writeClipboardText: (text: string) => Promise<void>
}
export async function copyTerminalHandleForPane({
tabId,
leafId,
callRuntime,
writeClipboardText
}: CopyTerminalHandleDeps): Promise<string> {
const paneKey = makePaneKey(tabId, leafId)
const response = await callRuntime({
method: 'terminal.resolvePane',
params: { paneKey }
})
if (!response.ok) {
throw new Error(response.error.message)
}
const handle = readResolvedTerminalHandle(response.result)
if (!handle) {
throw new Error('Terminal ID unavailable')
}
await writeClipboardText(handle)
return handle
}
function readResolvedTerminalHandle(result: unknown): string | null {
if (!isRecord(result) || !isRecord(result.terminal)) {
return null
}
return typeof result.terminal.handle === 'string' ? result.terminal.handle : null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

View File

@ -27,6 +27,7 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import { copyTerminalHandleForPane } from './terminal-handle-copy'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
@ -67,6 +68,7 @@ type TerminalMenuState = {
menuPaneId: number | null
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onCopy: () => Promise<void>
onCopyTerminalId: () => Promise<void>
onCopyPaneId: () => Promise<void>
onPaste: () => Promise<void>
onSplitRight: () => void
@ -161,6 +163,36 @@ export function useTerminalPaneContextMenu({
pane.terminal.focus()
}
const onCopyTerminalId = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
try {
await copyTerminalHandleForPane({
tabId,
leafId: pane.leafId,
callRuntime: window.api.runtime.call,
writeClipboardText: window.api.ui.writeClipboardText
})
toast.success(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.terminal.id.copied',
'Terminal ID copied'
)
)
} catch {
toast.error(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.terminal.id.copy.failed',
'Unable to copy terminal ID'
)
)
} finally {
pane.terminal.focus()
}
}
const onPaste = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
@ -369,6 +401,7 @@ export function useTerminalPaneContextMenu({
menuPaneId,
onContextMenuCapture,
onCopy,
onCopyTerminalId,
onCopyPaneId,
onPaste,
onSplitRight,

View File

@ -336,8 +336,57 @@ export type RuntimeTerminalSummary = {
preview: string
}
export type RuntimeTerminalVisualTerminalNode = {
type: 'terminal'
handle: string
tabId: string
leafId: string
title: string | null
connected: boolean
active: boolean
}
export type RuntimeTerminalVisualPaneNode =
| RuntimeTerminalVisualTerminalNode
| {
type: 'pane-split'
direction: Extract<TerminalPaneLayoutNode, { type: 'split' }>['direction']
first: RuntimeTerminalVisualPaneNode
second: RuntimeTerminalVisualPaneNode
}
export type RuntimeTerminalVisualTab = {
tabId: string
title: string | null
activeLeafId: string | null
panes: RuntimeTerminalVisualPaneNode
}
export type RuntimeTerminalVisualGroupNode = {
type: 'group'
groupId: string | null
activeTabId: string | null
tabs: RuntimeTerminalVisualTab[]
}
export type RuntimeTerminalVisualLayoutNode =
| RuntimeTerminalVisualGroupNode
| {
type: 'split'
direction: Extract<TabGroupLayoutNode, { type: 'split' }>['direction']
first: RuntimeTerminalVisualLayoutNode
second: RuntimeTerminalVisualLayoutNode
}
export type RuntimeTerminalVisualLayout = {
worktreeId: string
worktreePath: string
root: RuntimeTerminalVisualLayoutNode
}
export type RuntimeTerminalListResult = {
terminals: RuntimeTerminalSummary[]
visualLayouts?: RuntimeTerminalVisualLayout[]
totalCount: number
truncated: boolean
}
@ -388,6 +437,13 @@ export type RuntimeTerminalSplit = {
paneRuntimeId: number
}
export type RuntimeTerminalResolvePane = {
handle: string
tabId: string
leafId: string
ptyId: string | null
}
export type RuntimeTerminalFocus = {
handle: string
tabId: string