diff --git a/src/cli/format.test.ts b/src/cli/format.test.ts index 07e1879f6..c696b183c 100644 --- a/src/cli/format.test.ts +++ b/src/cli/format.test.ts @@ -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({ diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 5920336d3..42f3ff736 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -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: '}` ) .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 { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 538144a37..7e5a7db8c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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, + 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() + 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 + ): RuntimeTerminalVisualGroupNode[] { + const terminalTabs = snapshot.tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + if (terminalTabs.length === 0) { + return [] + } + const tabsByParentId = new Map() + 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 + ): 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 + ): 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 + ): 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 { @@ -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 { const pty = this.getLivePtyForHandle(handle) if (pty) { diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 123012991..f040a6f32 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index ca932fbf0..59aca46d4 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 9912a7bc0..5383c140e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -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({ {translate('auto.components.terminal.pane.TerminalContextMenu.39809d152f', 'Set Title…')} + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.copyTerminalId', + 'Copy Terminal ID' + )} + {translate( diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 7488699e7..0c012342c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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. */} diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts new file mode 100644 index 000000000..bdba5ca6c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts b/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts new file mode 100644 index 000000000..aaf79719b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts @@ -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> + writeClipboardText: (text: string) => Promise +} + +export async function copyTerminalHandleForPane({ + tabId, + leafId, + callRuntime, + writeClipboardText +}: CopyTerminalHandleDeps): Promise { + 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 { + return typeof value === 'object' && value !== null +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 2ea2b137d..e9230e324 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -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) => void onCopy: () => Promise + onCopyTerminalId: () => Promise onCopyPaneId: () => Promise onPaste: () => Promise onSplitRight: () => void @@ -161,6 +163,36 @@ export function useTerminalPaneContextMenu({ pane.terminal.focus() } + const onCopyTerminalId = async (): Promise => { + 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 => { const pane = resolveMenuPane() if (!pane) { @@ -369,6 +401,7 @@ export function useTerminalPaneContextMenu({ menuPaneId, onContextMenuCapture, onCopy, + onCopyTerminalId, onCopyPaneId, onPaste, onSplitRight, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 7a8575b52..4782f673d 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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['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['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