fix: expose terminal visual layout and copyable handles (#5845)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
c442f3aa00
commit
3afed388fc
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue