From 4f7b488d22a19ebc4d97f4b0e00aacf50edb65fd Mon Sep 17 00:00:00 2001 From: Ramzi <79337754+heyramzi@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:51:32 +0100 Subject: [PATCH] feat(cli): add terminal create, split, rename, focus, close, tui-idle wait (#734) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Jinwoo-H --- skills/orca-cli/SKILL.md | 22 +- src/cli/index.ts | 227 +++++++++- src/main/runtime/orca-runtime.test.ts | 154 ++++++- src/main/runtime/orca-runtime.ts | 427 +++++++++++++++++- src/main/runtime/runtime-rpc.ts | 190 +++++++- .../window/attach-main-window-services.ts | 34 ++ src/preload/api-types.d.ts | 32 ++ src/preload/index.ts | 83 ++++ .../use-terminal-pane-lifecycle.ts | 55 +++ src/renderer/src/constants/terminal.ts | 14 + src/renderer/src/hooks/useIpcEvents.test.ts | 216 +++++++++ src/renderer/src/hooks/useIpcEvents.ts | 98 ++++ src/shared/runtime-types.ts | 32 +- 13 files changed, 1534 insertions(+), 50 deletions(-) diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index 623f5fc24..2e3305f95 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -141,11 +141,26 @@ orca terminal show --terminal --json orca terminal read --terminal --json orca terminal send --terminal --text "continue" --enter --json orca terminal wait --terminal --for exit --timeout-ms 5000 --json +orca terminal wait --terminal --for tui-idle --timeout-ms 30000 --json orca terminal stop --worktree id: --json +orca terminal create --json +orca terminal create --title "My Terminal" --json +orca terminal create --worktree path:/projects/myapp --command "npm test" --json +orca terminal split --terminal --direction vertical --json +orca terminal split --terminal --direction horizontal --command "npm run dev" --json +orca terminal rename --terminal --title "New Name" --json +orca terminal switch --terminal --json +orca terminal close --terminal --json +orca terminal send --text "echo hello" --enter --json +orca terminal read --json ``` +Why: `--terminal` is optional for most commands. When omitted, Orca auto-resolves to the active terminal in the current worktree (same as browser commands target the active tab). Use explicit `--terminal ` when operating on a specific pane. + Why: terminal handles are runtime-scoped and may go stale after reloads. If Orca returns `terminal_handle_stale`, reacquire a fresh handle with `terminal list`. +Why: `--direction horizontal` splits the pane **left and right** (new pane appears to the right). `--direction vertical` splits the pane **top and bottom** (new pane appears below). This matches VS Code's split convention. Default is horizontal. + ## Agent Guidance - If the user says to create/manage an Orca worktree, use `orca worktree ...`, not raw `git worktree ...`. @@ -162,7 +177,10 @@ Why: terminal handles are runtime-scoped and may go stale after reloads. If Orca - Orca only injects `ORCA_WORKTREE_PATH`-style variables for some setup-hook flows, so they are not a general detection contract for agents. - Use `terminal list` to reacquire handles after Orca reloads. - Use `terminal read` before `terminal send` unless the next input is obvious. -- Use `terminal wait --for exit` only when the task actually depends on process completion. +- Use `terminal wait --terminal --for exit` only when the task actually depends on process completion. +- Use `terminal wait --terminal --for tui-idle` to wait for an agent CLI (Claude Code, Gemini, Codex, etc.) to finish its current task. This detects the working→idle OSC title transition. Always pass `--timeout-ms` as a safety net — unsupported CLIs will hang until timeout. +- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup and `--title` for labeling. +- Use `terminal split` to create split panes within an existing terminal tab. Pass `--command` to run a command in the new pane. - Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists. - If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals. - If a command fails, prefer retrying with the public `orca` command before concluding the CLI is broken, unless the failure already came from `orca` itself. @@ -539,7 +557,7 @@ When `orca tab create` opens a new tab, it is automatically set as the active ta - Orca CLI only talks to a running Orca editor. - Terminal handles are ephemeral and tied to the current Orca runtime. -- `terminal wait` in focused v1 supports only `--for exit`. +- `terminal wait` supports `--for exit` (wait for process exit) and `--for tui-idle` (wait for a recognized agent CLI like Claude Code, Gemini, or Codex to finish its current task, detected via OSC title transitions). `tui-idle` defaults to a 5-minute timeout if `--timeout-ms` is not specified. - Orca is the source of truth for worktree/terminal orchestration; do not duplicate that state with manual assumptions. - The public `orca` command is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints. diff --git a/src/cli/index.ts b/src/cli/index.ts index f41fc823b..bd815488b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,6 +14,11 @@ import type { RuntimeTerminalShow, RuntimeTerminalSend, RuntimeTerminalWait, + RuntimeTerminalCreate, + RuntimeTerminalSplit, + RuntimeTerminalRename, + RuntimeTerminalFocus, + RuntimeTerminalClose, BrowserSnapshotResult, BrowserClickResult, BrowserGotoResult, @@ -183,26 +188,36 @@ export const COMMAND_SPECS: CommandSpec[] = [ { path: ['terminal', 'show'], summary: 'Show terminal metadata and preview', - usage: 'orca terminal show --terminal [--json]', + usage: 'orca terminal show [--terminal ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'terminal'] }, { path: ['terminal', 'read'], summary: 'Read bounded terminal output', - usage: 'orca terminal read --terminal [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'] + usage: 'orca terminal read [--terminal ] [--cursor ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'cursor'], + notes: [ + 'Omit --terminal to target the active terminal in the current worktree.', + 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', + 'Useful for capturing the response to a command: read before sending, then read --cursor after waiting.' + ], + examples: [ + 'orca terminal read --json', + 'orca terminal read --terminal term_abc123 --cursor 42 --json' + ] }, { path: ['terminal', 'send'], summary: 'Send input to a live terminal', usage: - 'orca terminal send --terminal [--text ] [--enter] [--interrupt] [--json]', + 'orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'text', 'enter', 'interrupt'] }, { path: ['terminal', 'wait'], summary: 'Wait for a terminal condition', - usage: 'orca terminal wait --terminal --for exit [--timeout-ms ] [--json]', + usage: + 'orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'for', 'timeout-ms'] }, { @@ -211,6 +226,60 @@ export const COMMAND_SPECS: CommandSpec[] = [ usage: 'orca terminal stop --worktree [--json]', allowedFlags: [...GLOBAL_FLAGS, 'worktree'] }, + { + path: ['terminal', 'create'], + summary: 'Create a new terminal tab in the current worktree', + usage: + 'orca terminal create [--worktree ] [--title ] [--command ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title'], + examples: [ + 'orca terminal create --json', + 'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"' + ] + }, + { + path: ['terminal', 'switch'], + summary: 'Switch to a terminal tab in the UI', + usage: 'orca terminal switch [--terminal ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal'], + examples: ['orca terminal switch --terminal term_abc123'] + }, + { + path: ['terminal', 'focus'], + summary: 'Switch to a terminal tab in the UI (alias for terminal switch)', + usage: 'orca terminal focus [--terminal ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal'], + examples: ['orca terminal focus --terminal term_abc123'] + }, + { + path: ['terminal', 'close'], + summary: 'Close a terminal tab (kills PTY if running)', + usage: 'orca terminal close [--terminal ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal'], + examples: ['orca terminal close --terminal term_abc123'] + }, + { + path: ['terminal', 'rename'], + summary: 'Set or clear the title of a terminal tab', + usage: 'orca terminal rename [--terminal ] [--title ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'title'], + notes: ['Omit --title or pass an empty string to reset to the auto-generated title.'], + examples: [ + 'orca terminal rename --terminal term_abc123 --title "RUNNER"', + 'orca terminal rename --terminal term_abc123 --json' + ] + }, + { + path: ['terminal', 'split'], + summary: 'Split an existing terminal pane', + usage: + 'orca terminal split [--terminal ] [--direction horizontal|vertical] [--command ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'direction', 'command'], + examples: [ + 'orca terminal split --terminal term_abc123 --direction horizontal --json', + 'orca terminal split --terminal term_abc123 --command "codex"' + ] + }, // ── Browser automation ── { path: ['snapshot'], @@ -751,21 +820,30 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P if (matches(commandPath, ['terminal', 'show'])) { const result = await client.call<{ terminal: RuntimeTerminalShow }>('terminal.show', { - terminal: getRequiredStringFlag(parsed.flags, 'terminal') + terminal: await getTerminalHandle(parsed.flags, cwd, client) }) return printResult(result, json, formatTerminalShow) } if (matches(commandPath, ['terminal', 'read'])) { + const cursorFlag = getOptionalStringFlag(parsed.flags, 'cursor') + const cursor = + cursorFlag !== undefined && /^\d+$/.test(cursorFlag) + ? Number.parseInt(cursorFlag, 10) + : undefined + if (cursorFlag !== undefined && cursor === undefined) { + throw new RuntimeClientError('invalid_argument', '--cursor must be a non-negative integer') + } const result = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { - terminal: getRequiredStringFlag(parsed.flags, 'terminal') + terminal: await getTerminalHandle(parsed.flags, cwd, client), + ...(cursor !== undefined ? { cursor } : {}) }) return printResult(result, json, formatTerminalRead) } if (matches(commandPath, ['terminal', 'send'])) { const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', { - terminal: getRequiredStringFlag(parsed.flags, 'terminal'), + terminal: await getTerminalHandle(parsed.flags, cwd, client), text: getOptionalStringFlag(parsed.flags, 'text'), enter: parsed.flags.get('enter') === true, interrupt: parsed.flags.get('interrupt') === true @@ -778,7 +856,7 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P const result = await client.call<{ wait: RuntimeTerminalWait }>( 'terminal.wait', { - terminal: getRequiredStringFlag(parsed.flags, 'terminal'), + terminal: await getTerminalHandle(parsed.flags, cwd, client), for: getRequiredStringFlag(parsed.flags, 'for'), timeoutMs }, @@ -799,6 +877,60 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P return printResult(result, json, (value) => `Stopped ${value.stopped} terminals.`) } + if (matches(commandPath, ['terminal', 'rename'])) { + const result = await client.call<{ rename: RuntimeTerminalRename }>('terminal.rename', { + terminal: await getTerminalHandle(parsed.flags, cwd, client), + title: getOptionalStringFlag(parsed.flags, 'title') ?? null + }) + return printResult(result, json, formatTerminalRename) + } + + if (matches(commandPath, ['terminal', 'create'])) { + const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { + worktree: await getBrowserWorktreeSelector(parsed.flags, cwd, client), + command: getOptionalStringFlag(parsed.flags, 'command'), + title: getOptionalStringFlag(parsed.flags, 'title') + }) + return printResult(result, json, formatTerminalCreate) + } + + if ( + matches(commandPath, ['terminal', 'focus']) || + matches(commandPath, ['terminal', 'switch']) + ) { + const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', { + terminal: await getTerminalHandle(parsed.flags, cwd, client) + }) + return printResult(result, json, formatTerminalFocus) + } + + if (matches(commandPath, ['terminal', 'close'])) { + const result = await client.call<{ close: RuntimeTerminalClose }>('terminal.close', { + terminal: await getTerminalHandle(parsed.flags, cwd, client) + }) + return printResult(result, json, formatTerminalClose) + } + + if (matches(commandPath, ['terminal', 'split'])) { + const directionFlag = getOptionalStringFlag(parsed.flags, 'direction') + if ( + directionFlag !== undefined && + directionFlag !== 'horizontal' && + directionFlag !== 'vertical' + ) { + throw new RuntimeClientError( + 'invalid_argument', + '--direction must be horizontal or vertical' + ) + } + const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', { + terminal: await getTerminalHandle(parsed.flags, cwd, client), + direction: directionFlag, + command: getOptionalStringFlag(parsed.flags, 'command') + }) + return printResult(result, json, formatTerminalSplit) + } + if (matches(commandPath, ['worktree', 'ps'])) { const result = await client.call('worktree.ps', { limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit') @@ -1831,6 +1963,23 @@ async function getBrowserWorktreeSelector( } } +// Why: mirrors browser's implicit active-tab targeting. When --terminal is +// omitted, resolve the active terminal in the current worktree so commands +// like `orca terminal send --text "hello" --enter` Just Work. +async function getTerminalHandle( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + const explicit = getOptionalStringFlag(flags, 'terminal') + if (explicit) { + return explicit + } + const worktree = await getBrowserWorktreeSelector(flags, cwd, client) + const response = await client.call<{ handle: string }>('terminal.resolveActive', { worktree }) + return response.result.handle +} + async function getBrowserCommandTarget( flags: Map, cwd: string, @@ -2010,15 +2159,42 @@ function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string { function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string { const terminal = result.terminal - return [`handle: ${terminal.handle}`, `status: ${terminal.status}`, '', ...terminal.tail].join( - '\n' - ) + const header = [ + `handle: ${terminal.handle}`, + `status: ${terminal.status}`, + ...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []) + ] + return [...header, '', ...terminal.tail].join('\n') } function formatTerminalSend(result: { send: RuntimeTerminalSend }): string { return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.` } +function formatTerminalRename(result: { rename: RuntimeTerminalRename }): string { + return result.rename.title + ? `Renamed terminal ${result.rename.handle} to "${result.rename.title}".` + : `Cleared title for terminal ${result.rename.handle}.` +} + +function formatTerminalCreate(result: { terminal: RuntimeTerminalCreate }): string { + const titleNote = result.terminal.title ? ` (title: "${result.terminal.title}")` : '' + return `Created terminal ${result.terminal.handle}${titleNote}` +} + +function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): string { + return `Split pane ${result.split.handle} in tab ${result.split.tabId}` +} + +function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): string { + return `Focused terminal ${result.focus.handle} (tab ${result.focus.tabId}).` +} + +function formatTerminalClose(result: { close: RuntimeTerminalClose }): string { + const ptyNote = result.close.ptyKilled ? ' PTY killed.' : '' + return `Closed terminal ${result.close.handle}.${ptyNote}` +} + function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string { return [ `handle: ${result.wait.handle}`, @@ -2158,8 +2334,14 @@ Terminals: terminal show Show terminal metadata and preview terminal read Read bounded terminal output terminal send Send input to a live terminal - terminal wait Wait for a terminal condition + terminal wait Wait for a terminal condition (exit, tui-idle) terminal stop Stop terminals for a worktree + terminal create Create a new terminal tab in a worktree + terminal rename Set or clear the title of a terminal tab + terminal split Split an existing terminal pane + terminal switch Bring a terminal tab to the foreground + terminal focus Alias for terminal switch + terminal close Close a terminal pane (or tab if last pane) Browser Automation: tab create Create a new browser tab (navigates to --url) @@ -2227,11 +2409,15 @@ Common Commands: orca worktree rm --worktree [--force] [--json] orca worktree ps [--limit ] [--json] orca terminal list [--worktree ] [--limit ] [--json] - orca terminal show --terminal [--json] - orca terminal read --terminal [--json] - orca terminal send --terminal [--text ] [--enter] [--interrupt] [--json] - orca terminal wait --terminal --for exit [--timeout-ms ] [--json] + orca terminal show [--terminal ] [--json] + orca terminal read [--terminal ] [--json] + orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json] + orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json] orca terminal stop --worktree [--json] + orca terminal create [--worktree ] [--title ] [--command ] [--json] + orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] + orca terminal switch [--terminal ] [--json] + orca terminal close [--terminal ] [--json] orca repo list [--json] orca repo add --path [--json] orca repo show --repo [--json] @@ -2356,11 +2542,15 @@ function formatGroupHelp(group: string): string { function formatFlagHelp(flag: string): string { const helpByFlag: Record = { 'base-branch': '--base-branch Base branch/ref to create the worktree from', + command: '--command Command to run in the terminal on startup', comment: '--comment Comment stored in Orca metadata', + cursor: '--cursor Line cursor from a previous read (returns only new output)', + direction: '--direction Direction: horizontal|vertical (split) or up|down (scroll)', 'display-name': '--display-name Override the Orca display name', + title: '--title Custom title for the terminal tab (omit to reset)', enter: '--enter Append Enter after sending text', force: '--force Force worktree removal when supported', - for: '--for exit Wait condition to satisfy', + for: '--for exit|tui-idle Wait condition to satisfy', help: '--help Show this help message', interrupt: '--interrupt Send as an interrupt-style input when supported', issue: '--issue Linked GitHub issue number', @@ -2382,7 +2572,6 @@ function formatFlagHelp(flag: string): string { value: '--value Value to fill or select', input: '--input Text to type at current focus', expression: '--expression JavaScript expression to evaluate', - direction: '--direction Scroll direction', amount: '--amount Scroll distance in pixels', index: '--index Tab index to switch to', page: '--page Stable browser page id from `orca tab list --json`', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d5a4ce73d..fa6147570 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -332,7 +332,7 @@ describe('OrcaRuntimeService', () => { status: 'running', tail: ['hello', 'world'], truncated: false, - nextCursor: null + nextCursor: expect.any(String) }) const send = await runtime.sendTerminal(terminal.handle, { @@ -384,6 +384,59 @@ describe('OrcaRuntimeService', () => { }) }) + it('keeps partial-line output readable across cursor-based pagination', async () => { + const runtime = new OrcaRuntimeService(store) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'hel', 100) + + // Non-cursor reads include the partial line for UI display + const firstRead = await runtime.readTerminal(terminal.handle) + expect(firstRead.tail).toEqual(['hel']) + expect(firstRead.nextCursor).toBe('0') + + runtime.onPtyData('pty-1', 'lo', 101) + + // Cursor-based reads exclude partial lines to prevent duplication: + // without this, the consumer would see "hello" now as a partial, then + // see "hello" again as a completed line on the next read. + const secondRead = await runtime.readTerminal(terminal.handle, { + cursor: Number(firstRead.nextCursor) + }) + expect(secondRead.tail).toEqual([]) + expect(secondRead.nextCursor).toBe('0') + + runtime.onPtyData('pty-1', '\nworld\n', 102) + + const thirdRead = await runtime.readTerminal(terminal.handle, { + cursor: Number(secondRead.nextCursor) + }) + expect(thirdRead.tail).toEqual(['hello', 'world']) + expect(thirdRead.nextCursor).toBe('2') + }) + it('fails terminal waits closed when the handle goes stale during reload', async () => { const runtime = new OrcaRuntimeService(store) @@ -416,6 +469,91 @@ describe('OrcaRuntimeService', () => { await expect(waitPromise).rejects.toThrow('terminal_handle_stale') }) + it('tui-idle times out when PTY data has no agent OSC title transitions', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + runtime.onPtyData('pty-1', 'running migration step 4/9\n', 123) + + const [terminal] = (await runtime.listTerminals()).terminals + const waitPromise = runtime.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 1_000 + }) + const timeoutAssertion = expect(waitPromise).rejects.toThrow('timeout') + + await vi.advanceTimersByTimeAsync(12_000) + + await timeoutAssertion + } finally { + vi.useRealTimers() + } + }) + + it('tui-idle resolves on agent working→idle OSC title transition', async () => { + const runtime = new OrcaRuntimeService(store) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + // Simulate agent starting work (braille spinner = working) + runtime.onPtyData('pty-1', '\x1b]0;\u280b Working on task\x07output\n', 100) + + const [terminal] = (await runtime.listTerminals()).terminals + const waitPromise = runtime.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 5_000 + }) + + // Simulate agent finishing (✳ = Claude Code idle) + runtime.onPtyData('pty-1', '\x1b]0;\u2733 Task complete\x07done\n', 200) + + const result = await waitPromise + expect(result.condition).toBe('tui-idle') + expect(result.satisfied).toBe(true) + }) + it('builds a compact worktree summary from persisted and live runtime state', async () => { const runtime = new OrcaRuntimeService(store) @@ -607,7 +745,12 @@ describe('OrcaRuntimeService', () => { runtime.setNotifier({ worktreesChanged: vi.fn(), reposChanged: vi.fn(), - activateWorktree + activateWorktree, + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn() }) runtime.attachWindow(1) @@ -678,7 +821,12 @@ describe('OrcaRuntimeService', () => { runtime.setNotifier({ worktreesChanged: vi.fn(), reposChanged: vi.fn(), - activateWorktree: vi.fn() + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn() }) computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-worktree') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3ad0588f5..a9fa0024c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,6 +1,8 @@ /* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ +import { extractLastOscTitle, detectAgentStatusFromTitle } from '../../shared/agent-detection' +import type { AgentStatus } from '../../shared/agent-detection' import { gitExecFileAsync, gitExecFileSync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { randomUUID } from 'crypto' @@ -12,11 +14,17 @@ import type { RuntimeGraphStatus, RuntimeRepoSearchRefs, RuntimeTerminalRead, + RuntimeTerminalRename, RuntimeTerminalSend, + RuntimeTerminalCreate, + RuntimeTerminalSplit, + RuntimeTerminalFocus, + RuntimeTerminalClose, RuntimeTerminalListResult, RuntimeTerminalState, RuntimeStatus, RuntimeTerminalWait, + RuntimeTerminalWaitCondition, RuntimeWorktreePsSummary, RuntimeTerminalShow, RuntimeTerminalSummary, @@ -119,7 +127,9 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { tailBuffer: string[] tailPartialLine: string tailTruncated: boolean + tailLinesTotal: number preview: string + lastAgentStatus: AgentStatus | null } type RuntimePtyController = { @@ -131,6 +141,15 @@ type RuntimeNotifier = { worktreesChanged(repoId: string): void reposChanged(): void activateWorktree(repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup']): void + createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void + splitTerminal( + tabId: string, + paneRuntimeId: number, + opts: { direction: 'horizontal' | 'vertical'; command?: string } + ): void + renameTerminal(tabId: string, title: string | null): void + focusTerminal(tabId: string, worktreeId: string): void + closeTerminal(tabId: string, paneRuntimeId?: number): void } type TerminalHandleRecord = { @@ -146,6 +165,7 @@ type TerminalHandleRecord = { type TerminalWaiter = { handle: string + condition: RuntimeTerminalWaitCondition resolve: (result: RuntimeTerminalWait) => void reject: (error: Error) => void timeout: NodeJS.Timeout | null @@ -194,6 +214,7 @@ export class OrcaRuntimeService { private leaves = new Map() private handles = new Map() private handleByLeafKey = new Map() + private graphSyncCallbacks: (() => void)[] = [] private waitersByHandle = new Map>() private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null @@ -281,7 +302,9 @@ export class OrcaRuntimeService { tailBuffer: existing?.ptyId === leaf.ptyId ? existing.tailBuffer : [], tailPartialLine: existing?.ptyId === leaf.ptyId ? existing.tailPartialLine : '', tailTruncated: existing?.ptyId === leaf.ptyId ? existing.tailTruncated : false, - preview: existing?.ptyId === leaf.ptyId ? existing.preview : '' + tailLinesTotal: existing?.ptyId === leaf.ptyId ? existing.tailLinesTotal : 0, + preview: existing?.ptyId === leaf.ptyId ? existing.preview : '', + lastAgentStatus: existing?.ptyId === leaf.ptyId ? existing.lastAgentStatus : null }) if (existing && (existing.ptyId !== leaf.ptyId || existing.ptyGeneration !== ptyGeneration)) { @@ -298,6 +321,13 @@ export class OrcaRuntimeService { this.leaves = nextLeaves this.graphStatus = 'ready' this.refreshWritableFlags() + + // Why: createTerminal waits for the renderer's graph sync to populate the + // new leaf so it can return a handle. Drain callbacks after leaves update. + for (const cb of [...this.graphSyncCallbacks]) { + cb() + } + return this.getStatus() } @@ -315,6 +345,13 @@ export class OrcaRuntimeService { // tail buffer logic normalizes away the OSC sequences we need. this.agentDetector?.onData(ptyId, data, at) + // Why: extract OSC title from raw PTY data before tail-buffer processing + // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) + // announce status via OSC 0/1/2 title sequences — this is the same + // detection path the renderer uses for notifications and sidebar badges. + const oscTitle = extractLastOscTitle(data) + const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null + for (const leaf of this.leaves.values()) { if (leaf.ptyId !== ptyId) { continue @@ -326,7 +363,20 @@ export class OrcaRuntimeService { leaf.tailBuffer = nextTail.lines leaf.tailPartialLine = nextTail.partialLine leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated + leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) + + if (agentStatus !== null) { + const prevStatus = leaf.lastAgentStatus + leaf.lastAgentStatus = agentStatus + // Why: resolve tui-idle waiters only on working→idle, not working→permission. + // Permission means the agent is blocked on user approval (e.g. Gemini's + // "y/n" prompt) — it hasn't finished its task. Resolving tui-idle here + // would cause the CLI consumer to proceed while the agent is still waiting. + if (prevStatus === 'working' && agentStatus === 'idle') { + this.resolveTuiIdleWaiters(leaf) + } + } } } @@ -372,6 +422,41 @@ export class OrcaRuntimeService { } } + // 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 { + this.assertGraphReady() + + const targetWorktreeId = worktreeSelector + ? (await this.resolveWorktreeSelector(worktreeSelector)).id + : null + + // Prefer the tab's activeLeafId — this is the pane the user last focused + for (const tab of this.tabs.values()) { + if (targetWorktreeId && tab.worktreeId !== targetWorktreeId) { + continue + } + if (!tab.activeLeafId) { + continue + } + const leafKey = this.getLeafKey(tab.tabId, tab.activeLeafId) + const leaf = this.leaves.get(leafKey) + if (leaf) { + return this.issueHandle(leaf) + } + } + + // Fallback: any leaf in the target worktree + for (const leaf of this.leaves.values()) { + if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { + continue + } + return this.issueHandle(leaf) + } + + throw new Error('no_active_terminal') + } + async showTerminal(handle: string): Promise { const graphEpoch = this.captureReadyGraphEpoch() const worktreesById = await this.getResolvedWorktreeMap() @@ -386,19 +471,43 @@ export class OrcaRuntimeService { } } - async readTerminal(handle: string): Promise { + async readTerminal(handle: string, opts: { cursor?: number } = {}): Promise { const { leaf } = this.getLiveLeafForHandle(handle) - const tail = buildTailLines(leaf.tailBuffer, leaf.tailPartialLine) - return { - handle, - status: getTerminalState(leaf), + const allLines = buildTailLines(leaf.tailBuffer, leaf.tailPartialLine) + + let tail: string[] + let truncated: boolean + + if (typeof opts.cursor === 'number' && opts.cursor >= 0) { + // Why: the buffer only retains the last MAX_TAIL_LINES lines. If the + // caller's cursor points to lines that were already evicted, we can only + // return what's still in memory and mark truncated=true to signal the gap. + const bufferStart = leaf.tailLinesTotal - leaf.tailBuffer.length + const sliceFrom = Math.max(0, opts.cursor - bufferStart) + // Why: cursor-based reads return only completed lines, excluding the + // trailing partial line. Including the partial would cause duplication: + // the consumer sees "hel" now, then "hello\n" on the next read after + // the line completes — same content delivered twice. + tail = leaf.tailBuffer.slice(sliceFrom) + truncated = opts.cursor < bufferStart + } else { + tail = allLines // Why: Orca does not have a truthful main-owned screen model yet, // especially for hidden panes. Focused v1 therefore returns the bounded // tail lines directly instead of duplicating the same text in a fake // screen field that would waste agent tokens. + truncated = leaf.tailTruncated + } + + return { + handle, + status: getTerminalState(leaf), tail, - truncated: leaf.tailTruncated, - nextCursor: null + truncated, + // Why: cursors advance by completed lines only. If we count the current + // partial line here, later reads can skip continued output on that same + // line because no new complete line was emitted yet. + nextCursor: String(leaf.tailLinesTotal) } } @@ -432,27 +541,54 @@ export class OrcaRuntimeService { async waitForTerminal( handle: string, options?: { + condition?: RuntimeTerminalWaitCondition timeoutMs?: number } ): Promise { + const condition = options?.condition ?? 'exit' const { leaf } = this.getLiveLeafForHandle(handle) - if (getTerminalState(leaf) === 'exited') { - return buildTerminalWaitResult(handle, leaf) + + if (condition === 'exit' && getTerminalState(leaf) === 'exited') { + return buildTerminalWaitResult(handle, condition, leaf) + } + + // Why: if the agent already transitioned to idle (or permission) before the + // waiter was registered, resolve immediately. This uses the same OSC title + // detection that powers the renderer's "Task complete" notifications. + // Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the + // agent is blocked on user approval, not finished with its task. + if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') { + // Why: reset so the next `wait --for tui-idle` blocks until a fresh + // working→idle transition instead of resolving instantly from a stale + // status left over from a previous agent session. + leaf.lastAgentStatus = null + return buildTerminalWaitResult(handle, condition, leaf) } return await new Promise((resolve, reject) => { + // Why: tui-idle depends on OSC title transitions from a recognized agent. + // If no agent is detected, the waiter would hang forever. Enforce a default + // timeout so unsupported CLIs fail predictably instead of silently blocking. + const effectiveTimeoutMs = + typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 + ? options.timeoutMs + : condition === 'tui-idle' + ? TUI_IDLE_DEFAULT_TIMEOUT_MS + : 0 + const waiter: TerminalWaiter = { handle, + condition, resolve, reject, timeout: null } - if (typeof options?.timeoutMs === 'number' && options.timeoutMs > 0) { + if (effectiveTimeoutMs > 0) { waiter.timeout = setTimeout(() => { this.removeWaiter(waiter) reject(new Error('timeout')) - }, options.timeoutMs) + }, effectiveTimeoutMs) } let waiters = this.waitersByHandle.get(handle) @@ -468,7 +604,10 @@ export class OrcaRuntimeService { try { const live = this.getLiveLeafForHandle(handle) if (getTerminalState(live.leaf) === 'exited') { - this.resolveWaiter(waiter, buildTerminalWaitResult(handle, live.leaf)) + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) + } else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === 'idle') { + live.leaf.lastAgentStatus = null + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) } } catch (error) { this.removeWaiter(waiter) @@ -831,6 +970,220 @@ export class OrcaRuntimeService { this.notifier?.worktreesChanged(repo.id) } + async renameTerminal(handle: string, title: string | null): Promise { + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + this.notifier?.renameTerminal(leaf.tabId, title) + return { handle, tabId: leaf.tabId, title } + } + + async createTerminal( + worktreeSelector?: string, + opts: { command?: string; title?: string } = {} + ): Promise { + this.assertGraphReady() + const win = this.getAuthoritativeWindow() + // Why: mirrors browserTabCreate — when no worktree is specified, pass + // undefined so the renderer uses its current active worktree. + const worktreeId = worktreeSelector + ? (await this.resolveWorktreeSelector(worktreeSelector)).id + : undefined + const requestId = randomUUID() + + // Why: terminal creation is a renderer-side Zustand store operation (like + // browser tab creation). The main process sends a request, the renderer + // creates the tab and replies with the tabId so we can resolve the handle. + const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('terminal:tabCreateReply', handler) + reject(new Error('Terminal creation timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + r: { requestId: string; tabId?: string; title?: string; error?: string } + ): void => { + if (r.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('terminal:tabCreateReply', handler) + if (r.error) { + reject(new Error(r.error)) + } else { + resolve({ tabId: r.tabId!, title: r.title ?? opts.title ?? '' }) + } + } + ipcMain.on('terminal:tabCreateReply', handler) + win.webContents.send('terminal:requestTabCreate', { + requestId, + worktreeId, + command: opts.command, + title: opts.title + }) + }) + + // Why: the renderer created the tab immediately, but the graph sync that + // populates this.leaves may not have arrived yet. Wait for the leaf to + // appear so we can return a valid handle the caller can use right away. + const handle = await this.waitForTerminalHandle(reply.tabId) + return { handle, worktreeId: worktreeId ?? '', title: reply.title } + } + + private waitForTerminalHandle(tabId: string, timeoutMs = 10_000): Promise { + const existing = this.resolveHandleForTab(tabId) + if (existing) { + return Promise.resolve(existing) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + reject(new Error('Timed out waiting for terminal handle after creation')) + }, timeoutMs) + + const check = (): void => { + const handle = this.resolveHandleForTab(tabId) + if (handle) { + clearTimeout(timer) + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + resolve(handle) + } + } + this.graphSyncCallbacks.push(check) + // Why: the graph sync may have fired between the initial check and + // callback registration. Re-check immediately to avoid a missed wake-up. + check() + }) + } + + // Why: a leaf appears in the graph before its PTY spawns. If we issue a + // handle while ptyId is null, the next graph sync after PTY spawn will + // change ptyId and invalidate the handle. Wait for a connected PTY so + // the handle is stable and immediately usable for send/read/wait. + private countLeavesInTab(tabId: string): number { + let count = 0 + for (const leaf of this.leaves.values()) { + if (leaf.tabId === tabId) { + count++ + } + } + return count + } + + private resolveHandleForTab(tabId: string): string | null { + for (const leaf of this.leaves.values()) { + if (leaf.tabId === tabId && leaf.ptyId !== null) { + return this.issueHandle(leaf) + } + } + return null + } + + async focusTerminal(handle: string): Promise { + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId) + return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId } + } + + async closeTerminal(handle: string): Promise { + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + let ptyKilled = false + if (leaf.ptyId) { + ptyKilled = this.ptyController?.kill(leaf.ptyId) ?? false + } + // Why: killing the PTY in a multi-pane tab is sufficient — the renderer's + // PTY exit handler already calls PaneManager.closePane() for split layouts. + // Sending an additional IPC close would race with the exit handler and + // incorrectly close the entire tab (the pane count drops to 1 before the + // IPC arrives, triggering the single-pane fallback path). + // We only send the notifier close when the PTY wasn't killed (e.g. PTY not + // yet spawned) or when this is the only pane in the tab. + const siblingCount = this.countLeavesInTab(leaf.tabId) + if (!ptyKilled || siblingCount <= 1) { + this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId) + } + return { handle, tabId: leaf.tabId, ptyKilled } + } + + async splitTerminal( + handle: string, + opts: { direction?: 'horizontal' | 'vertical'; command?: string } = {} + ): Promise { + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + const direction = opts.direction ?? 'horizontal' + + // Why: snapshot current leaf keys for this tab so we can detect the new + // pane that appears after the split via graph sync delta. + const leafKeysBefore = new Set() + for (const [key, l] of this.leaves) { + if (l.tabId === leaf.tabId) { + leafKeysBefore.add(key) + } + } + + this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, { + direction, + command: opts.command + }) + + const newHandle = await this.waitForNewLeafInTab(leaf.tabId, leafKeysBefore) + return { handle: newHandle, tabId: leaf.tabId, paneRuntimeId: leaf.paneRuntimeId } + } + + private waitForNewLeafInTab( + tabId: string, + existingLeafKeys: Set, + timeoutMs = 10_000 + ): Promise { + const tryResolve = (): string | null => { + for (const [key, leaf] of this.leaves) { + if (leaf.tabId === tabId && !existingLeafKeys.has(key) && leaf.ptyId !== null) { + return this.issueHandle(leaf) + } + } + return null + } + + const existing = tryResolve() + if (existing) { + return Promise.resolve(existing) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + reject(new Error('Timed out waiting for split pane handle')) + }, timeoutMs) + + const check = (): void => { + const handle = tryResolve() + if (handle) { + clearTimeout(timer) + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + resolve(handle) + } + } + this.graphSyncCallbacks.push(check) + check() + }) + } + async stopTerminalsForWorktree(worktreeSelector: string): Promise<{ stopped: number }> { // Why: this mutates live PTYs, so the runtime must reject it while the // renderer graph is reloading instead of acting on cached leaf ownership. @@ -1132,7 +1485,31 @@ export class OrcaRuntimeService { return } for (const waiter of [...waiters]) { - this.resolveWaiter(waiter, buildTerminalWaitResult(handle, leaf)) + if (waiter.condition === 'exit') { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'exit', leaf)) + } else { + // Why: if the terminal exited, conditions like tui-idle can never be + // satisfied. Reject immediately instead of letting the poll interval + // spin until timeout on a dead process. + this.removeWaiter(waiter) + waiter.reject(new Error('terminal_exited')) + } + } + } + + private resolveTuiIdleWaiters(leaf: RuntimeLeafRecord): void { + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (!handle) { + return + } + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + if (waiter.condition === 'tui-idle') { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf)) + } } } @@ -2264,18 +2641,21 @@ function appendToTailBuffer( lines: string[] partialLine: string truncated: boolean + newCompleteLines: number } { const normalizedChunk = normalizeTerminalChunk(chunk) if (normalizedChunk.length === 0) { return { lines: previousLines, partialLine: previousPartialLine, - truncated: false + truncated: false, + newCompleteLines: 0 } } const pieces = `${previousPartialLine}${normalizedChunk}`.split('\n') const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '') + const newCompleteLines = pieces.length const nextLines = [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))] let truncated = false @@ -2293,7 +2673,8 @@ function appendToTailBuffer( return { lines: nextLines, partialLine: nextPartialLine.slice(-MAX_TAIL_CHARS), - truncated + truncated, + newCompleteLines } } @@ -2329,10 +2710,20 @@ function buildSendPayload(action: { return payload.length > 0 ? payload : null } -function buildTerminalWaitResult(handle: string, leaf: RuntimeLeafRecord): RuntimeTerminalWait { +// Why: tui-idle relies on recognized agent CLIs setting OSC titles. If the +// terminal runs an unsupported CLI (or a plain shell), no title transition +// will ever fire. A 5-minute ceiling prevents indefinite hangs while still +// giving real agent tasks plenty of time to complete. +const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 + +function buildTerminalWaitResult( + handle: string, + condition: RuntimeTerminalWaitCondition, + leaf: RuntimeLeafRecord +): RuntimeTerminalWait { return { handle, - condition: 'exit', + condition, satisfied: true, status: getTerminalState(leaf), exitCode: leaf.lastExitCode diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 83a989368..1d81375de 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -254,6 +254,25 @@ export class OrcaRuntimeRpcServer { } } + if (request.method === 'terminal.resolveActive') { + try { + const params = + request.params && typeof request.params === 'object' && request.params !== null + ? (request.params as { worktree?: unknown }) + : null + const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined + const handle = await this.runtime.resolveActiveTerminal(worktree) + return { + id: request.id, + ok: true, + result: { handle }, + _meta: { runtimeId: this.runtime.getRuntimeId() } + } + } catch (error) { + return this.runtimeErrorResponse(request.id, error) + } + } + if (request.method === 'terminal.show') { try { const terminalHandle = @@ -281,16 +300,33 @@ export class OrcaRuntimeRpcServer { if (request.method === 'terminal.read') { try { - const terminalHandle = + const params = request.params && typeof request.params === 'object' && request.params !== null - ? ((request.params as { terminal?: unknown }).terminal ?? null) + ? (request.params as { terminal?: unknown; cursor?: unknown }) : null + const terminalHandle = params?.terminal ?? null if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') } - const result = await this.runtime.readTerminal(terminalHandle) + if ( + params?.cursor !== undefined && + (!Number.isInteger(params.cursor) || (params.cursor as number) < 0) + ) { + return this.errorResponse( + request.id, + 'invalid_argument', + 'Cursor must be a non-negative integer' + ) + } + + const cursor = + typeof params?.cursor === 'number' && Number.isFinite(params.cursor) + ? params.cursor + : undefined + + const result = await this.runtime.readTerminal(terminalHandle, { cursor }) return { id: request.id, ok: true, @@ -304,6 +340,41 @@ export class OrcaRuntimeRpcServer { } } + if (request.method === 'terminal.rename') { + try { + const params = + request.params && typeof request.params === 'object' && request.params !== null + ? (request.params as { terminal?: unknown; title?: unknown }) + : null + const terminalHandle = params?.terminal ?? null + if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { + return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') + } + const title = + params?.title === null + ? null + : typeof params?.title === 'string' + ? params.title + : undefined + if (title === undefined) { + return this.errorResponse( + request.id, + 'invalid_argument', + 'Missing --title (pass empty string or null to reset)' + ) + } + const result = await this.runtime.renameTerminal(terminalHandle, title || null) + return { + id: request.id, + ok: true, + result: { rename: result }, + _meta: { runtimeId: this.runtime.getRuntimeId() } + } + } catch (error) { + return this.runtimeErrorResponse(request.id, error) + } + } + if (request.method === 'terminal.send') { try { const params = @@ -355,11 +426,12 @@ export class OrcaRuntimeRpcServer { return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') } - if (params?.for !== 'exit') { + const forCondition = params?.for + if (forCondition !== 'exit' && forCondition !== 'tui-idle') { return this.errorResponse( request.id, - 'not_supported_in_v1', - 'Only terminal wait --for exit is supported in focused v1' + 'invalid_argument', + 'Invalid --for value. Supported: exit, tui-idle' ) } @@ -368,7 +440,10 @@ export class OrcaRuntimeRpcServer { ? params.timeoutMs : undefined - const result = await this.runtime.waitForTerminal(terminalHandle, { timeoutMs }) + const result = await this.runtime.waitForTerminal(terminalHandle, { + condition: forCondition, + timeoutMs + }) return { id: request.id, ok: true, @@ -677,6 +752,60 @@ export class OrcaRuntimeRpcServer { } } + if (request.method === 'terminal.create') { + try { + const params = + request.params && typeof request.params === 'object' && request.params !== null + ? (request.params as { worktree?: unknown; command?: unknown; title?: unknown }) + : null + const worktreeSelector = + typeof params?.worktree === 'string' && params.worktree.length > 0 + ? params.worktree + : undefined + const result = await this.runtime.createTerminal(worktreeSelector, { + command: typeof params?.command === 'string' ? params.command : undefined, + title: typeof params?.title === 'string' ? params.title : undefined + }) + return { + id: request.id, + ok: true, + result: { terminal: result }, + _meta: { runtimeId: this.runtime.getRuntimeId() } + } + } catch (error) { + return this.runtimeErrorResponse(request.id, error) + } + } + + if (request.method === 'terminal.split') { + try { + const params = + request.params && typeof request.params === 'object' && request.params !== null + ? (request.params as { terminal?: unknown; direction?: unknown; command?: unknown }) + : null + const terminalHandle = params?.terminal + if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { + return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') + } + const direction = + params?.direction === 'vertical' || params?.direction === 'horizontal' + ? params.direction + : undefined + const result = await this.runtime.splitTerminal(terminalHandle, { + direction, + command: typeof params?.command === 'string' ? params.command : undefined + }) + return { + id: request.id, + ok: true, + result: { split: result }, + _meta: { runtimeId: this.runtime.getRuntimeId() } + } + } catch (error) { + return this.runtimeErrorResponse(request.id, error) + } + } + if (request.method === 'terminal.stop') { try { const params = @@ -1871,6 +2000,50 @@ export class OrcaRuntimeRpcServer { } } + if (request.method === 'terminal.focus') { + try { + const params = + request.params && typeof request.params === 'object' && request.params !== null + ? (request.params as { terminal?: unknown }) + : null + const terminalHandle = params?.terminal + if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { + return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') + } + const result = await this.runtime.focusTerminal(terminalHandle) + return { + id: request.id, + ok: true, + result: { focus: result }, + _meta: { runtimeId: this.runtime.getRuntimeId() } + } + } catch (error) { + return this.runtimeErrorResponse(request.id, error) + } + } + + if (request.method === 'terminal.close') { + try { + const params = + request.params && typeof request.params === 'object' && request.params !== null + ? (request.params as { terminal?: unknown }) + : null + const terminalHandle = params?.terminal + if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { + return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') + } + const result = await this.runtime.closeTerminal(terminalHandle) + return { + id: request.id, + ok: true, + result: { close: result }, + _meta: { runtimeId: this.runtime.getRuntimeId() } + } + } catch (error) { + return this.runtimeErrorResponse(request.id, error) + } + } + return this.errorResponse(request.id, 'method_not_found', `Unknown method: ${request.method}`) } @@ -1938,6 +2111,9 @@ export class OrcaRuntimeRpcServer { message === 'selector_ambiguous' || message === 'terminal_handle_stale' || message === 'terminal_not_writable' || + message === 'terminal_exited' || + message === 'terminal_gone' || + message === 'no_active_terminal' || message === 'repo_not_found' || message === 'timeout' || message === 'invalid_limit' diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index fbee21bb3..3acc53691 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -144,6 +144,40 @@ function registerRuntimeWindowLifecycle( if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('ui:activateWorktree', { repoId, worktreeId, setup }) } + }, + createTerminal: (worktreeId, opts) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('ui:createTerminal', { + worktreeId, + command: opts.command, + title: opts.title + }) + } + }, + splitTerminal: (tabId, paneRuntimeId, opts) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('ui:splitTerminal', { + tabId, + paneRuntimeId, + direction: opts.direction, + command: opts.command + }) + } + }, + renameTerminal: (tabId, title) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('ui:renameTerminal', { tabId, title }) + } + }, + focusTerminal: (tabId, worktreeId) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('ui:focusTerminal', { tabId, worktreeId }) + } + }, + closeTerminal: (tabId, paneRuntimeId) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('ui:closeTerminal', { tabId, paneRuntimeId }) + } } }) // Why: the runtime must fail closed while the renderer graph is being torn diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 194960dc0..2196f4c04 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -640,6 +640,38 @@ export type PreloadApi = { onActivateWorktree: ( callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void ) => () => void + onCreateTerminal: ( + callback: (data: { worktreeId: string; command?: string; title?: string }) => void + ) => () => void + onRequestTerminalCreate: ( + callback: (data: { + requestId: string + worktreeId?: string + command?: string + title?: string + }) => void + ) => () => void + replyTerminalCreate: (reply: { + requestId: string + tabId?: string + title?: string + error?: string + }) => void + onSplitTerminal: ( + callback: (data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + }) => void + ) => () => void + onRenameTerminal: ( + callback: (data: { tabId: string; title: string | null }) => void + ) => () => void + onFocusTerminal: (callback: (data: { tabId: string; worktreeId: string }) => void) => () => void + onCloseTerminal: ( + callback: (data: { tabId: string; paneRuntimeId?: number }) => void + ) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void readClipboardText: () => Promise saveClipboardImageAsTempFile: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c95c360f..72c09ee40 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1188,6 +1188,89 @@ const api = { ipcRenderer.on('ui:activateWorktree', listener) return () => ipcRenderer.removeListener('ui:activateWorktree', listener) }, + onCreateTerminal: ( + callback: (data: { worktreeId: string; command?: string; title?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string; command?: string; title?: string } + ) => callback(data) + ipcRenderer.on('ui:createTerminal', listener) + return () => ipcRenderer.removeListener('ui:createTerminal', listener) + }, + onRequestTerminalCreate: ( + callback: (data: { + requestId: string + worktreeId?: string + command?: string + title?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { requestId: string; worktreeId?: string; command?: string; title?: string } + ) => callback(data) + ipcRenderer.on('terminal:requestTabCreate', listener) + return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) + }, + replyTerminalCreate: (reply: { + requestId: string + tabId?: string + title?: string + error?: string + }): void => { + ipcRenderer.send('terminal:tabCreateReply', reply) + }, + onSplitTerminal: ( + callback: (data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + } + ) => callback(data) + ipcRenderer.on('ui:splitTerminal', listener) + return () => ipcRenderer.removeListener('ui:splitTerminal', listener) + }, + onRenameTerminal: ( + callback: (data: { tabId: string; title: string | null }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; title: string | null } + ) => callback(data) + ipcRenderer.on('ui:renameTerminal', listener) + return () => ipcRenderer.removeListener('ui:renameTerminal', listener) + }, + onFocusTerminal: ( + callback: (data: { tabId: string; worktreeId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; worktreeId: string } + ) => callback(data) + ipcRenderer.on('ui:focusTerminal', listener) + return () => ipcRenderer.removeListener('ui:focusTerminal', listener) + }, + onCloseTerminal: ( + callback: (data: { tabId: string; paneRuntimeId?: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; paneRuntimeId?: number } + ) => callback(data) + ipcRenderer.on('ui:closeTerminal', listener) + return () => ipcRenderer.removeListener('ui:closeTerminal', listener) + }, onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => callback(direction) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 63399b1c8..1b75ba45f 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -32,6 +32,12 @@ import type { PtyTransport } from './pty-transport' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { e2eConfig } from '@/lib/e2e-config' +import { + SPLIT_TERMINAL_PANE_EVENT, + CLOSE_TERMINAL_PANE_EVENT, + type SplitTerminalPaneDetail, + type CloseTerminalPaneDetail +} from '@/constants/terminal' type UseTerminalPaneLifecycleDeps = { tabId: string @@ -668,7 +674,56 @@ export function useTerminalPaneLifecycle({ persistLayoutSnapshot() scheduleRuntimeGraphSync() + // Why: CLI-driven splits go through splitPaneWithOneShotStartup so the + // startup command is delivered via the PTY connection path (which waits + // for shell readiness) instead of terminal.paste() which can lose input + // if the shell hasn't started reading stdin yet. + function onCliSplitPane(event: Event): void { + const detail = (event as CustomEvent).detail + if (!detail?.tabId || detail.tabId !== tabId) { + return + } + const mgr = managerRef.current + if (!mgr) { + return + } + if (detail.command) { + splitPaneWithOneShotStartup(ptyDeps, { command: detail.command }, () => + mgr.splitPane(detail.paneRuntimeId, detail.direction) + ) + } else { + mgr.splitPane(detail.paneRuntimeId, detail.direction) + } + } + window.addEventListener(SPLIT_TERMINAL_PANE_EVENT, onCliSplitPane) + + // Why: CLI-driven pane close dispatches a CustomEvent so PaneManager handles + // sibling promotion in split layouts. Falls back to closing the whole tab + // when the target pane is the only one remaining. + function onCliClosePane(event: Event): void { + const detail = (event as CustomEvent).detail + if (!detail?.tabId || detail.tabId !== tabId) { + return + } + const mgr = managerRef.current + if (!mgr) { + return + } + if (mgr.getPanes().length <= 1) { + useAppStore.getState().closeTab(tabId) + } else { + mgr.closePane(detail.paneRuntimeId) + scheduleRuntimeGraphSync() + syncCanExpandState() + queueResizeAll(isActive) + persistLayoutSnapshot() + } + } + window.addEventListener(CLOSE_TERMINAL_PANE_EVENT, onCliClosePane) + return () => { + window.removeEventListener(SPLIT_TERMINAL_PANE_EVENT, onCliSplitPane) + window.removeEventListener(CLOSE_TERMINAL_PANE_EVENT, onCliClosePane) const tabStillExists = Boolean( useAppStore .getState() diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index 7b2f379eb..e9a382b56 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -1,5 +1,7 @@ export const TOGGLE_TERMINAL_PANE_EXPAND_EVENT = 'orca-toggle-terminal-pane-expand' export const FOCUS_TERMINAL_PANE_EVENT = 'orca-focus-terminal-pane' +export const SPLIT_TERMINAL_PANE_EVENT = 'orca-split-terminal-pane' +export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane' export type ToggleTerminalPaneExpandDetail = { tabId: string @@ -9,3 +11,15 @@ export type FocusTerminalPaneDetail = { tabId: string paneId: number } + +export type SplitTerminalPaneDetail = { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string +} + +export type CloseTerminalPaneDetail = { + tabId: string + paneRuntimeId: number +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 5531548c7..3b5f74836 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -153,6 +153,13 @@ describe('useIpcEvents updater integration', () => { onJumpToWorktreeIndex: () => () => {}, onWorktreeHistoryNavigate: () => () => {}, onActivateWorktree: () => () => {}, + onCreateTerminal: () => () => {}, + onRequestTerminalCreate: () => () => {}, + replyTerminalCreate: () => {}, + onSplitTerminal: () => () => {}, + onRenameTerminal: () => () => {}, + onFocusTerminal: () => () => {}, + onCloseTerminal: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -323,6 +330,13 @@ describe('useIpcEvents updater integration', () => { onJumpToWorktreeIndex: () => () => {}, onWorktreeHistoryNavigate: () => () => {}, onActivateWorktree: () => () => {}, + onCreateTerminal: () => () => {}, + onRequestTerminalCreate: () => () => {}, + replyTerminalCreate: () => {}, + onSplitTerminal: () => () => {}, + onRenameTerminal: () => () => {}, + onFocusTerminal: () => () => {}, + onCloseTerminal: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -386,6 +400,180 @@ describe('useIpcEvents updater integration', () => { expect(clearTabPtyId).toHaveBeenCalledWith('tab-1') expect(clearTabPtyId).not.toHaveBeenCalledWith('tab-2') }) + + it('activates the target worktree when CLI creates a terminal there', async () => { + const createTab = vi.fn(() => ({ id: 'tab-new' })) + const setActiveView = vi.fn() + const setActiveWorktree = vi.fn() + const setActiveTabType = vi.fn() + const setActiveTab = vi.fn() + const revealWorktreeInSidebar = vi.fn() + const setTabCustomTitle = vi.fn() + const queueTabStartupCommand = vi.fn() + const createTerminalListenerRef: { + current: ((data: { worktreeId: string; command?: string; title?: string }) => void) | null + } = { current: null } + + vi.resetModules() + vi.unstubAllGlobals() + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + effect() + } + } + }) + + vi.doMock('../store', () => ({ + useAppStore: { + getState: () => ({ + setUpdateStatus: vi.fn(), + createTab, + setActiveView, + setActiveWorktree, + setActiveTabType, + setActiveTab, + revealWorktreeInSidebar, + setTabCustomTitle, + queueTabStartupCommand, + fetchRepos: vi.fn(), + fetchWorktrees: vi.fn(), + activeModal: null, + closeModal: vi.fn(), + openModal: vi.fn(), + activeWorktreeId: 'wt-1', + activeView: 'terminal', + setActiveRepo: vi.fn(), + setIsFullScreen: vi.fn(), + updateBrowserPageState: vi.fn(), + activeTabType: 'terminal', + editorFontZoomLevel: 0, + setEditorFontZoomLevel: vi.fn(), + setRateLimitsFromPush: vi.fn(), + setSshConnectionState: vi.fn(), + setSshTargetLabels: vi.fn(), + enqueueSshCredentialRequest: vi.fn(), + removeSshCredentialRequest: vi.fn(), + clearTabPtyId: vi.fn(), + settings: { terminalFontSize: 13 } + }) + } + })) + + vi.doMock('@/lib/ui-zoom', () => ({ + applyUIZoom: vi.fn() + })) + vi.doMock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn() + })) + vi.doMock('@/components/sidebar/visible-worktrees', () => ({ + getVisibleWorktreeIds: () => [] + })) + vi.doMock('@/lib/editor-font-zoom', () => ({ + nextEditorFontZoomLevel: vi.fn(() => 0), + computeEditorFontSize: vi.fn(() => 13) + })) + vi.doMock('@/components/settings/SettingsConstants', () => ({ + zoomLevelToPercent: vi.fn(() => 100), + ZOOM_MIN: -3, + ZOOM_MAX: 3 + })) + vi.doMock('@/lib/zoom-events', () => ({ + dispatchZoomLevelChanged: vi.fn() + })) + + vi.stubGlobal('window', { + api: { + repos: { onChanged: () => () => {} }, + worktrees: { onChanged: () => () => {} }, + ui: { + onOpenSettings: () => () => {}, + onToggleLeftSidebar: () => () => {}, + onToggleRightSidebar: () => () => {}, + onToggleWorktreePalette: () => () => {}, + onOpenQuickOpen: () => () => {}, + onOpenNewWorkspace: () => () => {}, + onJumpToWorktreeIndex: () => () => {}, + onActivateWorktree: () => () => {}, + onWorktreeHistoryNavigate: () => () => {}, + onCreateTerminal: ( + listener: (data: { worktreeId: string; command?: string; title?: string }) => void + ) => { + createTerminalListenerRef.current = listener + return () => {} + }, + onRequestTerminalCreate: () => () => {}, + replyTerminalCreate: () => {}, + onSplitTerminal: () => () => {}, + onRenameTerminal: () => () => {}, + onFocusTerminal: () => () => {}, + onCloseTerminal: () => () => {}, + onNewBrowserTab: () => () => {}, + onRequestTabCreate: () => () => {}, + replyTabCreate: () => {}, + onRequestTabClose: () => () => {}, + replyTabClose: vi.fn(), + onNewTerminalTab: () => () => {}, + onCloseActiveTab: () => () => {}, + onSwitchTab: () => () => {}, + onToggleStatusBar: () => () => {}, + onFullscreenChanged: () => () => {}, + onTerminalZoom: () => () => {}, + getZoomLevel: () => 0, + set: vi.fn() + }, + updater: { + getStatus: () => Promise.resolve({ state: 'idle' }), + onStatus: () => () => {}, + onClearDismissal: () => () => {} + }, + browser: { + onGuestLoadFailed: () => () => {}, + onOpenLinkInOrcaTab: () => () => {}, + onNavigationUpdate: () => () => {}, + onActivateView: () => () => {} + }, + rateLimits: { + get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), + onUpdate: () => () => {} + }, + ssh: { + listTargets: () => Promise.resolve([]), + getState: () => Promise.resolve(null), + onStateChanged: () => () => {}, + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {} + } + } + }) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await Promise.resolve() + + if (typeof createTerminalListenerRef.current !== 'function') { + throw new Error('Expected create-terminal listener to be registered') + } + + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + title: 'Runner', + command: 'opencode' + }) + + expect(setActiveView).toHaveBeenCalledWith('terminal') + expect(setActiveWorktree).toHaveBeenCalledWith('wt-2') + expect(createTab).toHaveBeenCalledWith('wt-2') + expect(setActiveTabType).toHaveBeenCalledWith('terminal') + expect(setActiveTab).toHaveBeenCalledWith('tab-new') + expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-2') + expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Runner') + expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'opencode' }) + }) }) describe('useIpcEvents browser tab close routing', () => { @@ -496,6 +684,13 @@ describe('useIpcEvents browser tab close routing', () => { onJumpToWorktreeIndex: () => () => {}, onWorktreeHistoryNavigate: () => () => {}, onActivateWorktree: () => () => {}, + onCreateTerminal: () => () => {}, + onRequestTerminalCreate: () => () => {}, + replyTerminalCreate: () => {}, + onSplitTerminal: () => () => {}, + onRenameTerminal: () => () => {}, + onFocusTerminal: () => () => {}, + onCloseTerminal: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -662,6 +857,13 @@ describe('useIpcEvents browser tab close routing', () => { onJumpToWorktreeIndex: () => () => {}, onWorktreeHistoryNavigate: () => () => {}, onActivateWorktree: () => () => {}, + onCreateTerminal: () => () => {}, + onRequestTerminalCreate: () => () => {}, + replyTerminalCreate: () => {}, + onSplitTerminal: () => () => {}, + onRenameTerminal: () => () => {}, + onFocusTerminal: () => () => {}, + onCloseTerminal: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -823,6 +1025,13 @@ describe('useIpcEvents browser tab close routing', () => { onJumpToWorktreeIndex: () => () => {}, onWorktreeHistoryNavigate: () => () => {}, onActivateWorktree: () => () => {}, + onCreateTerminal: () => () => {}, + onRequestTerminalCreate: () => () => {}, + replyTerminalCreate: () => {}, + onSplitTerminal: () => () => {}, + onRenameTerminal: () => () => {}, + onFocusTerminal: () => () => {}, + onCloseTerminal: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -1002,6 +1211,13 @@ describe('useIpcEvents shortcut hint clearing', () => { }, onWorktreeHistoryNavigate: () => () => {}, onActivateWorktree: () => () => {}, + onCreateTerminal: () => () => {}, + onRequestTerminalCreate: () => () => {}, + replyTerminalCreate: () => {}, + onSplitTerminal: () => () => {}, + onRenameTerminal: () => () => {}, + onFocusTerminal: () => () => {}, + onCloseTerminal: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 426373d47..dad9dec56 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -6,6 +6,8 @@ import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from '@/lib/worktree-activation' +import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom' import type { UpdateStatus } from '../../../shared/types' @@ -155,6 +157,102 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onCreateTerminal(({ worktreeId, command, title }) => { + const store = useAppStore.getState() + store.setActiveView('terminal') + store.setActiveWorktree(worktreeId) + const tab = store.createTab(worktreeId) + store.setActiveTabType('terminal') + store.setActiveTab(tab.id) + store.revealWorktreeInSidebar(worktreeId) + if (title) { + store.setTabCustomTitle(tab.id, title) + } + if (command) { + store.queueTabStartupCommand(tab.id, { command }) + } + }) + ) + + // Why: CLI-driven terminal creation sends a request and waits for the + // tabId reply so it can resolve a handle the caller can use immediately. + // This mirrors the browser's onRequestTabCreate/replyTabCreate pattern. + unsubs.push( + window.api.ui.onRequestTerminalCreate((data) => { + try { + const store = useAppStore.getState() + const worktreeId = data.worktreeId ?? store.activeWorktreeId + if (!worktreeId) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: 'No active worktree' + }) + return + } + store.setActiveView('terminal') + store.setActiveWorktree(worktreeId) + const tab = store.createTab(worktreeId) + store.setActiveTabType('terminal') + store.setActiveTab(tab.id) + store.revealWorktreeInSidebar(worktreeId) + if (data.title) { + store.setTabCustomTitle(tab.id, data.title) + } + if (data.command) { + store.queueTabStartupCommand(tab.id, { command: data.command }) + } + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + tabId: tab.id, + title: data.title ?? tab.title + }) + } catch (err) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: err instanceof Error ? err.message : 'Terminal creation failed' + }) + } + }) + ) + + unsubs.push( + window.api.ui.onSplitTerminal(({ tabId, paneRuntimeId, direction, command }) => { + const detail: SplitTerminalPaneDetail = { tabId, paneRuntimeId, direction, command } + window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail })) + }) + ) + + unsubs.push( + window.api.ui.onRenameTerminal(({ tabId, title }) => { + useAppStore.getState().setTabCustomTitle(tabId, title) + }) + ) + + unsubs.push( + window.api.ui.onFocusTerminal(({ tabId, worktreeId }) => { + const store = useAppStore.getState() + store.setActiveWorktree(worktreeId) + store.setActiveView('terminal') + store.setActiveTab(tabId) + store.revealWorktreeInSidebar(worktreeId) + }) + ) + + unsubs.push( + window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => { + if (paneRuntimeId != null) { + // Why: when targeting a specific pane in a split layout, dispatch to the + // lifecycle hook so PaneManager.closePane() handles sibling promotion. + // The lifecycle hook falls through to closeTab() if this is the last pane. + const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId } + window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) + } else { + useAppStore.getState().closeTab(tabId) + } + }) + ) + // Hydrate initial update status then subscribe to changes window.api.updater.getStatus().then((status) => { useAppStore.getState().setUpdateStatus(status as UpdateStatus) diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 6162f9216..1d0835c80 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -92,13 +92,43 @@ export type RuntimeTerminalRead = { nextCursor: string | null } +export type RuntimeTerminalRename = { + handle: string + tabId: string + title: string | null +} + export type RuntimeTerminalSend = { handle: string accepted: boolean bytesWritten: number } -export type RuntimeTerminalWaitCondition = 'exit' +export type RuntimeTerminalCreate = { + handle: string + worktreeId: string + title: string | null +} + +export type RuntimeTerminalSplit = { + handle: string + tabId: string + paneRuntimeId: number +} + +export type RuntimeTerminalFocus = { + handle: string + tabId: string + worktreeId: string +} + +export type RuntimeTerminalClose = { + handle: string + tabId: string + ptyKilled: boolean +} + +export type RuntimeTerminalWaitCondition = 'exit' | 'tui-idle' export type RuntimeTerminalWait = { handle: string