feat(cli): add terminal create, split, rename, focus, close, tui-idle wait (#734)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
parent
c07db38dfe
commit
4f7b488d22
|
|
@ -141,11 +141,26 @@ orca terminal show --terminal <handle> --json
|
|||
orca terminal read --terminal <handle> --json
|
||||
orca terminal send --terminal <handle> --text "continue" --enter --json
|
||||
orca terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 30000 --json
|
||||
orca terminal stop --worktree id:<worktreeId> --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 <handle> --direction vertical --json
|
||||
orca terminal split --terminal <handle> --direction horizontal --command "npm run dev" --json
|
||||
orca terminal rename --terminal <handle> --title "New Name" --json
|
||||
orca terminal switch --terminal <handle> --json
|
||||
orca terminal close --terminal <handle> --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 <handle>` 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 <handle> --for exit` only when the task actually depends on process completion.
|
||||
- Use `terminal wait --terminal <handle> --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.
|
||||
|
||||
|
|
|
|||
227
src/cli/index.ts
227
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 <handle> [--json]',
|
||||
usage: 'orca terminal show [--terminal <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'read'],
|
||||
summary: 'Read bounded terminal output',
|
||||
usage: 'orca terminal read --terminal <handle> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal']
|
||||
usage: 'orca terminal read [--terminal <handle>] [--cursor <n>] [--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 <prev> 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 <handle> [--text <text>] [--enter] [--interrupt] [--json]',
|
||||
'orca terminal send [--terminal <handle>] [--text <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 <handle> --for exit [--timeout-ms <ms>] [--json]',
|
||||
usage:
|
||||
'orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'for', 'timeout-ms']
|
||||
},
|
||||
{
|
||||
|
|
@ -211,6 +226,60 @@ export const COMMAND_SPECS: CommandSpec[] = [
|
|||
usage: 'orca terminal stop --worktree <selector> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'create'],
|
||||
summary: 'Create a new terminal tab in the current worktree',
|
||||
usage:
|
||||
'orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--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 <handle>] [--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 <handle>] [--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 <handle>] [--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 <handle>] [--title <text>] [--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 <handle>] [--direction horizontal|vertical] [--command <text>] [--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<RuntimeWorktreePsResult>('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<string, string | boolean>,
|
||||
cwd: string,
|
||||
client: RuntimeClient
|
||||
): Promise<string> {
|
||||
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<string, string | boolean>,
|
||||
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 <selector> [--force] [--json]
|
||||
orca worktree ps [--limit <n>] [--json]
|
||||
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
|
||||
orca terminal show --terminal <handle> [--json]
|
||||
orca terminal read --terminal <handle> [--json]
|
||||
orca terminal send --terminal <handle> [--text <text>] [--enter] [--interrupt] [--json]
|
||||
orca terminal wait --terminal <handle> --for exit [--timeout-ms <ms>] [--json]
|
||||
orca terminal show [--terminal <handle>] [--json]
|
||||
orca terminal read [--terminal <handle>] [--json]
|
||||
orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]
|
||||
orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]
|
||||
orca terminal stop --worktree <selector> [--json]
|
||||
orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--json]
|
||||
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
|
||||
orca terminal switch [--terminal <handle>] [--json]
|
||||
orca terminal close [--terminal <handle>] [--json]
|
||||
orca repo list [--json]
|
||||
orca repo add --path <path> [--json]
|
||||
orca repo show --repo <selector> [--json]
|
||||
|
|
@ -2356,11 +2542,15 @@ function formatGroupHelp(group: string): string {
|
|||
function formatFlagHelp(flag: string): string {
|
||||
const helpByFlag: Record<string, string> = {
|
||||
'base-branch': '--base-branch <ref> Base branch/ref to create the worktree from',
|
||||
command: '--command <text> Command to run in the terminal on startup',
|
||||
comment: '--comment <text> Comment stored in Orca metadata',
|
||||
cursor: '--cursor <n> Line cursor from a previous read (returns only new output)',
|
||||
direction: '--direction <dir> Direction: horizontal|vertical (split) or up|down (scroll)',
|
||||
'display-name': '--display-name <name> Override the Orca display name',
|
||||
title: '--title <text> 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 <number|null> Linked GitHub issue number',
|
||||
|
|
@ -2382,7 +2572,6 @@ function formatFlagHelp(flag: string): string {
|
|||
value: '--value <text> Value to fill or select',
|
||||
input: '--input <text> Text to type at current focus',
|
||||
expression: '--expression <js> JavaScript expression to evaluate',
|
||||
direction: '--direction <up|down> Scroll direction',
|
||||
amount: '--amount <pixels> Scroll distance in pixels',
|
||||
index: '--index <n> Tab index to switch to',
|
||||
page: '--page <id> Stable browser page id from `orca tab list --json`',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<string, RuntimeLeafRecord>()
|
||||
private handles = new Map<string, TerminalHandleRecord>()
|
||||
private handleByLeafKey = new Map<string, string>()
|
||||
private graphSyncCallbacks: (() => void)[] = []
|
||||
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
|
||||
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<string> {
|
||||
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<RuntimeTerminalShow> {
|
||||
const graphEpoch = this.captureReadyGraphEpoch()
|
||||
const worktreesById = await this.getResolvedWorktreeMap()
|
||||
|
|
@ -386,19 +471,43 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
async readTerminal(handle: string): Promise<RuntimeTerminalRead> {
|
||||
async readTerminal(handle: string, opts: { cursor?: number } = {}): Promise<RuntimeTerminalRead> {
|
||||
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<RuntimeTerminalWait> {
|
||||
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<RuntimeTerminalWait>((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<RuntimeTerminalRename> {
|
||||
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<RuntimeTerminalCreate> {
|
||||
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<string> {
|
||||
const existing = this.resolveHandleForTab(tabId)
|
||||
if (existing) {
|
||||
return Promise.resolve(existing)
|
||||
}
|
||||
|
||||
return new Promise<string>((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<RuntimeTerminalFocus> {
|
||||
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<RuntimeTerminalClose> {
|
||||
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<RuntimeTerminalSplit> {
|
||||
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<string>()
|
||||
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<string>,
|
||||
timeoutMs = 10_000
|
||||
): Promise<string> {
|
||||
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<string>((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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
saveClipboardImageAsTempFile: () => Promise<string | null>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<SplitTerminalPaneDetail>).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<CloseTerminalPaneDetail>).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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof ReactModule>('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: () => {},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue