This commit is contained in:
Ramzi 2026-04-20 19:04:53 -07:00 committed by GitHub
commit 14361c25f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 986 additions and 38 deletions

View file

@ -13,7 +13,12 @@ import type {
RuntimeTerminalListResult,
RuntimeTerminalShow,
RuntimeTerminalSend,
RuntimeTerminalWait
RuntimeTerminalWait,
RuntimeTerminalCreate,
RuntimeTerminalSplit,
RuntimeTerminalRename,
RuntimeTerminalFocus,
RuntimeTerminalClose
} from '../shared/runtime-types'
import {
RuntimeClient,
@ -148,8 +153,16 @@ const COMMAND_SPECS: CommandSpec[] = [
{
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: [
'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 --terminal term_abc123 --json',
'orca terminal read --terminal term_abc123 --cursor 42 --json'
]
},
{
path: ['terminal', 'send'],
@ -161,7 +174,8 @@ const COMMAND_SPECS: CommandSpec[] = [
{
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']
},
{
@ -169,6 +183,53 @@ const COMMAND_SPECS: CommandSpec[] = [
summary: 'Stop terminals for a worktree',
usage: 'orca terminal stop --worktree <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['terminal', 'create'],
summary: 'Create a new terminal tab in a worktree',
usage:
'orca terminal create --worktree <selector> [--title <name>] [--command <text>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title'],
examples: [
'orca terminal create --worktree active --json',
'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"'
]
},
{
path: ['terminal', 'focus'],
summary: 'Bring a terminal tab to the foreground in the UI',
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"'
]
}
]
@ -262,8 +323,17 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
}
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: getRequiredStringFlag(parsed.flags, 'terminal'),
...(cursor !== undefined ? { cursor } : {})
})
return printResult(result, json, formatTerminalRead)
}
@ -304,6 +374,57 @@ 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: getRequiredStringFlag(parsed.flags, 'terminal'),
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 getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client),
command: getOptionalStringFlag(parsed.flags, 'command'),
title: getOptionalStringFlag(parsed.flags, 'title')
})
return printResult(result, json, formatTerminalCreate)
}
if (matches(commandPath, ['terminal', 'focus'])) {
const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', {
terminal: getRequiredStringFlag(parsed.flags, 'terminal')
})
return printResult(result, json, formatTerminalFocus)
}
if (matches(commandPath, ['terminal', 'close'])) {
const result = await client.call<{ close: RuntimeTerminalClose }>('terminal.close', {
terminal: getRequiredStringFlag(parsed.flags, 'terminal')
})
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: getRequiredStringFlag(parsed.flags, 'terminal'),
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')
@ -655,15 +776,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 `Terminal tab created in worktree ${result.terminal.worktreeId}${titleNote}.\nRun 'orca terminal list' to get the handle.`
}
function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): string {
return `Pane split in tab ${result.split.tabId} (pane ${result.split.paneRuntimeId}).`
}
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}`,
@ -782,8 +930,13 @@ 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 focus Bring a terminal tab to the foreground
terminal close Close a terminal tab
Common Commands:
orca open [--json]
@ -799,8 +952,10 @@ Common Commands:
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 wait --terminal <handle> --for exit|tui-idle [--timeout-ms <ms>] [--json]
orca terminal stop --worktree <selector> [--json]
orca terminal focus --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]
@ -883,8 +1038,13 @@ 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> Split direction: horizontal or vertical (default: horizontal)',
'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',

View file

@ -317,7 +317,7 @@ describe('OrcaRuntimeService', () => {
status: 'running',
tail: ['hello', 'world'],
truncated: false,
nextCursor: null
nextCursor: expect.any(String)
})
const send = await runtime.sendTerminal(terminal.handle, {
@ -369,6 +369,55 @@ 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)
const firstRead = await runtime.readTerminal(terminal.handle)
expect(firstRead.tail).toEqual(['hel'])
expect(firstRead.nextCursor).toBe('0')
runtime.onPtyData('pty-1', 'lo', 101)
const secondRead = await runtime.readTerminal(terminal.handle, {
cursor: Number(firstRead.nextCursor)
})
expect(secondRead.tail).toEqual(['hello'])
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)
@ -401,6 +450,49 @@ describe('OrcaRuntimeService', () => {
await expect(waitPromise).rejects.toThrow('terminal_handle_stale')
})
it('does not treat a stable but non-idle preview as tui-idle', 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('builds a compact worktree summary from persisted and live runtime state', async () => {
const runtime = new OrcaRuntimeService(store)
@ -592,7 +684,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)
@ -663,7 +760,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')

View file

@ -12,11 +12,17 @@ import type {
RuntimeGraphStatus,
RuntimeRepoSearchRefs,
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalSend,
RuntimeTerminalCreate,
RuntimeTerminalSplit,
RuntimeTerminalFocus,
RuntimeTerminalClose,
RuntimeTerminalListResult,
RuntimeTerminalState,
RuntimeStatus,
RuntimeTerminalWait,
RuntimeTerminalWaitCondition,
RuntimeWorktreePsSummary,
RuntimeTerminalShow,
RuntimeTerminalSummary,
@ -81,6 +87,7 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & {
tailBuffer: string[]
tailPartialLine: string
tailTruncated: boolean
tailLinesTotal: number
preview: string
}
@ -93,6 +100,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): void
}
type TerminalHandleRecord = {
@ -108,9 +124,11 @@ type TerminalHandleRecord = {
type TerminalWaiter = {
handle: string
condition: RuntimeTerminalWaitCondition
resolve: (result: RuntimeTerminalWait) => void
reject: (error: Error) => void
timeout: NodeJS.Timeout | null
pollInterval: ReturnType<typeof setInterval> | null
}
type ResolvedWorktree = {
@ -224,6 +242,7 @@ 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,
tailLinesTotal: existing?.ptyId === leaf.ptyId ? existing.tailLinesTotal : 0,
preview: existing?.ptyId === leaf.ptyId ? existing.preview : ''
})
@ -269,6 +288,7 @@ 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)
}
}
@ -329,19 +349,39 @@ 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)
tail = allLines.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)
}
}
@ -375,20 +415,29 @@ 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)
}
if (condition === 'tui-idle' && isTUIIdle(leaf.preview)) {
return buildTerminalWaitResult(handle, condition, leaf)
}
return await new Promise<RuntimeTerminalWait>((resolve, reject) => {
const waiter: TerminalWaiter = {
handle,
condition,
resolve,
reject,
timeout: null
timeout: null,
pollInterval: null
}
if (typeof options?.timeoutMs === 'number' && options.timeoutMs > 0) {
@ -405,13 +454,34 @@ export class OrcaRuntimeService {
}
waiters.add(waiter)
if (condition === 'tui-idle') {
// Why: `tui-idle` is meant to detect known prompt/ready states, not
// "no new bytes arrived for a while". Long-running TUIs can sit on a
// stable screen for many seconds while still being busy or waiting on
// human input inside a modal state, so silent stabilization is not a
// safe substitute for an explicit idle prompt match.
waiter.pollInterval = setInterval(() => {
try {
const live = this.getLiveLeafForHandle(handle)
const preview = live.leaf.preview
if (isTUIIdle(preview)) {
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf))
}
} catch {
this.removeWaiter(waiter)
reject(new Error('terminal_gone'))
}
}, TUI_IDLE_POLL_MS)
return
}
// Why: the handle may go stale or exit in the small gap between the first
// validation and waiter registration. Re-checking here keeps wait --for
// exit honest instead of hanging on a terminal that already changed.
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))
}
} catch (error) {
this.removeWaiter(waiter)
@ -765,6 +835,54 @@ 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 worktree = await this.resolveWorktreeSelector(worktreeSelector)
this.notifier?.createTerminal(worktree.id, opts)
return { worktreeId: worktree.id, title: opts.title ?? 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
}
this.notifier?.closeTerminal(leaf.tabId)
return { handle, tabId: leaf.tabId, ptyKilled }
}
async splitTerminal(
handle: string,
opts: { direction?: 'horizontal' | 'vertical'; command?: string } = {}
): Promise<RuntimeTerminalSplit> {
const { leaf } = this.getLiveLeafForHandle(handle)
const direction = opts.direction ?? 'horizontal'
this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, {
direction,
command: opts.command
})
return { tabId: leaf.tabId, paneRuntimeId: leaf.paneRuntimeId }
}
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.
@ -1066,7 +1184,9 @@ 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))
}
}
}
@ -1096,6 +1216,9 @@ export class OrcaRuntimeService {
if (waiter.timeout) {
clearTimeout(waiter.timeout)
}
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
}
const waiters = this.waitersByHandle.get(waiter.handle)
if (!waiters) {
return
@ -1139,18 +1262,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
@ -1168,7 +1294,8 @@ function appendToTailBuffer(
return {
lines: nextLines,
partialLine: nextPartialLine.slice(-MAX_TAIL_CHARS),
truncated
truncated,
newCompleteLines
}
}
@ -1204,10 +1331,27 @@ function buildSendPayload(action: {
return payload.length > 0 ? payload : null
}
function buildTerminalWaitResult(handle: string, leaf: RuntimeLeafRecord): RuntimeTerminalWait {
const TUI_IDLE_POLL_MS = 3000
const TUI_IDLE_PATTERNS = [
/Ask anything/i,
/What is the tech stack/i,
/How can I help/i,
/Enter a prompt/i,
/Type .* to get started/i
]
function isTUIIdle(preview: string): boolean {
return TUI_IDLE_PATTERNS.some((re) => re.test(preview))
}
function buildTerminalWaitResult(
handle: string,
condition: RuntimeTerminalWaitCondition,
leaf: RuntimeLeafRecord
): RuntimeTerminalWait {
return {
handle,
condition: 'exit',
condition,
satisfied: true,
status: getTerminalState(leaf),
exitCode: leaf.lastExitCode

View file

@ -281,16 +281,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 +321,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 +407,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 +421,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 +733,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 = params?.worktree
if (typeof worktreeSelector !== 'string' || worktreeSelector.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector')
}
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 =
@ -701,6 +811,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}`)
}

View file

@ -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) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('ui:closeTerminal', { tabId })
}
}
})
// Why: the runtime must fail closed while the renderer graph is being torn

View file

@ -605,6 +605,22 @@ 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
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 }) => void) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
readClipboardText: () => Promise<string>
saveClipboardImageAsTempFile: () => Promise<string | null>

View file

@ -1108,6 +1108,62 @@ 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)
},
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 }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { tabId: string }) =>
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)

View file

@ -1,8 +1,10 @@
import { useEffect, useRef } from 'react'
import {
FOCUS_TERMINAL_PANE_EVENT,
SPLIT_TERMINAL_PANE_EVENT,
TOGGLE_TERMINAL_PANE_EXPAND_EVENT,
type FocusTerminalPaneDetail
type FocusTerminalPaneDetail,
type SplitTerminalPaneDetail
} from '@/constants/terminal'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { shellEscapePath } from './pane-helpers'
@ -231,6 +233,25 @@ export function useTerminalPaneGlobalEffects({
return () => window.removeEventListener(FOCUS_TERMINAL_PANE_EVENT, onFocusPane)
}, [tabId, managerRef])
useEffect(() => {
function onSplitPane(event: Event): void {
const detail = (event as CustomEvent<SplitTerminalPaneDetail>).detail
if (!detail?.tabId || detail.tabId !== tabId) {
return
}
const manager = managerRef.current
if (!manager) {
return
}
const newPane = manager.splitPane(detail.paneRuntimeId, detail.direction)
if (newPane && detail.command) {
newPane.terminal.paste(`${detail.command}\r`)
}
}
window.addEventListener(SPLIT_TERMINAL_PANE_EVENT, onSplitPane)
return () => window.removeEventListener(SPLIT_TERMINAL_PANE_EVENT, onSplitPane)
}, [tabId, managerRef])
useEffect(() => {
if (!isVisible) {
return

View file

@ -1,5 +1,6 @@
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 type ToggleTerminalPaneExpandDetail = {
tabId: string
@ -9,3 +10,10 @@ export type FocusTerminalPaneDetail = {
tabId: string
paneId: number
}
export type SplitTerminalPaneDetail = {
tabId: string
paneRuntimeId: number
direction: 'horizontal' | 'vertical'
command?: string
}

View file

@ -151,6 +151,11 @@ describe('useIpcEvents updater integration', () => {
onOpenQuickOpen: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},
onSplitTerminal: () => () => {},
onRenameTerminal: () => () => {},
onFocusTerminal: () => () => {},
onCloseTerminal: () => () => {},
onNewBrowserTab: () => () => {},
onNewTerminalTab: () => () => {},
onCloseActiveTab: () => () => {},
@ -313,6 +318,11 @@ describe('useIpcEvents updater integration', () => {
onOpenQuickOpen: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},
onSplitTerminal: () => () => {},
onRenameTerminal: () => () => {},
onFocusTerminal: () => () => {},
onCloseTerminal: () => () => {},
onNewBrowserTab: () => () => {},
onNewTerminalTab: () => () => {},
onCloseActiveTab: () => () => {},
@ -370,6 +380,172 @@ 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: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: (
listener: (data: { worktreeId: string; command?: string; title?: string }) => void
) => {
createTerminalListenerRef.current = listener
return () => {}
},
onSplitTerminal: () => () => {},
onRenameTerminal: () => () => {},
onFocusTerminal: () => () => {},
onCloseTerminal: () => () => {},
onNewBrowserTab: () => () => {},
onNewTerminalTab: () => () => {},
onCloseActiveTab: () => () => {},
onSwitchTab: () => () => {},
onToggleStatusBar: () => () => {},
onFullscreenChanged: () => () => {},
onTerminalZoom: () => () => {},
getZoomLevel: () => 0,
set: vi.fn()
},
updater: {
getStatus: () => Promise.resolve({ state: 'idle' }),
onStatus: () => () => {},
onClearDismissal: () => () => {}
},
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {}
},
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 shortcut hint clearing', () => {

View file

@ -6,6 +6,8 @@ import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal
} from '@/lib/worktree-activation'
import { SPLIT_TERMINAL_PANE_EVENT } from '@/constants/terminal'
import type { SplitTerminalPaneDetail } from '@/constants/terminal'
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom'
import type { UpdateStatus } from '../../../shared/types'
@ -121,6 +123,53 @@ 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 })
}
})
)
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 }) => {
useAppStore.getState().closeTab(tabId)
})
)
// Hydrate initial update status then subscribe to changes
window.api.updater.getStatus().then((status) => {
useAppStore.getState().setUpdateStatus(status as UpdateStatus)

View file

@ -91,13 +91,41 @@ 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 = {
worktreeId: string
title: string | null
}
export type RuntimeTerminalSplit = {
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