feat(cli): set the title of one terminal pane (#30485)

* feat(cli): set the title of one terminal pane

Adds `orca terminal set-pane-title --terminal <handle> --title <text>`; `--title ""`
clears it. The runtime resolves the live handle to its saved leaf and asks the
desktop window to name that pane through a new optional notifier hook
(`ui:setPaneTitle`). Mounted panes apply it through the same local setter the GUI
uses; parked panes get it in the saved layout their next mount reads. The tab's
own name is never touched.

Rebuilt on current main from #24755.

Co-authored-by: stevelliu <stevelliu@tencent.com>

* fix(renderer): a main-owned layout takes a CLI pane title before the window shows it

* fix(renderer): a CLI pane title reaches a never-mounted tab with a rootless layout

---------

Co-authored-by: stevelliu <stevelliu@tencent.com>
This commit is contained in:
Neil
2026-10-18 19:48:36 -07:00
committed by GitHub
co-authored by stevelliu
parent 76f9978880
commit 347d0e46a1
37 changed files with 680 additions and 11 deletions
+1
View File
@@ -174,6 +174,7 @@ orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 300000 --json
orca terminal create --worktree active --title "tests" --command "npm test" --json
orca terminal split --terminal <handle> --direction horizontal --command "npm run dev" --json
orca terminal rename --terminal <handle> --title "runner" --json
orca terminal set-pane-title --terminal <handle> --title "reviewer" --json
orca terminal switch --terminal <handle> --json
orca terminal close --terminal <handle> --json
orca terminal close --worktree active --all --json
+1
View File
@@ -202,6 +202,7 @@ ORCA terminal create --worktree active --command "pnpm dev" --json
ORCA terminal split --terminal <handle> --direction vertical --json
ORCA terminal split --terminal <handle> --direction horizontal --command "npm test" --json
ORCA terminal rename --terminal <handle> --title "New Name" --json
ORCA terminal set-pane-title --terminal <handle> --title "REVIEWER" --json
ORCA terminal switch --terminal <handle> --json
ORCA terminal close --terminal <handle> --json
ORCA terminal close --worktree id:<repoId>::<worktreePath> --all --json
File diff suppressed because one or more lines are too long
+1
View File
@@ -38,6 +38,7 @@ export {
formatTerminalList,
formatTerminalRead,
formatTerminalRename,
formatTerminalSetPaneTitle,
formatTerminalSend,
formatTerminalShow,
formatTerminalSplit,
+1
View File
@@ -115,6 +115,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
'terminal wait',
'terminal stop',
'terminal rename',
'terminal set-pane-title',
'terminal create',
'terminal switch',
'terminal close',
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RuntimeClient, RuntimeClientError } from '../runtime-client'
import { parseArgs } from '../args'
import { printHelp } from '../help'
import { COMMAND_SPECS } from '../specs'
import { TERMINAL_HANDLERS } from './terminal'
afterEach(() => vi.restoreAllMocks())
describe('terminal set-pane-title', () => {
it.each(['REVIEWER', ''])('sends an explicit title %j to the pane method', async (title) => {
const parsed = parseArgs([
'terminal',
'set-pane-title',
'--terminal',
'term-1',
'--title',
title
])
const client = new RuntimeClient()
const call = vi.spyOn(client, 'call').mockResolvedValue({
id: 'request',
ok: true,
result: {
paneTitle: { handle: 'term-1', tabId: 'tab-1', leafId: 'leaf-1', title: title || null }
},
_meta: { runtimeId: 'runtime-1' }
})
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal set-pane-title']({
flags: parsed.flags,
client,
cwd: '/tmp',
json: true
})
expect(call).toHaveBeenCalledExactlyOnceWith('terminal.setPaneTitle', {
terminal: 'term-1',
title
})
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
result: { paneTitle: { title: title || null } }
})
})
it.each([{ args: [] }, { args: ['--title'] }])(
'refuses a missing title before resolving a terminal (%j)',
async ({ args }) => {
const client = new RuntimeClient()
const call = vi.spyOn(client, 'call')
const parsed = parseArgs(['terminal', 'set-pane-title', ...args])
await expect(
TERMINAL_HANDLERS['terminal set-pane-title']({
flags: parsed.flags,
client,
cwd: '/tmp',
json: true
})
).rejects.toMatchObject({ code: 'invalid_argument' })
expect(call).not.toHaveBeenCalled()
}
)
it('propagates an older host refusal without falling back to tab rename', async () => {
const client = new RuntimeClient()
const call = vi
.spyOn(client, 'call')
.mockRejectedValue(new RuntimeClientError('method_not_found', 'Unknown method'))
await expect(
TERMINAL_HANDLERS['terminal set-pane-title']({
flags: new Map([
['terminal', 'term-1'],
['title', 'REVIEWER']
]),
client,
cwd: '/tmp',
json: true
})
).rejects.toMatchObject({ code: 'method_not_found' })
expect(call).toHaveBeenCalledTimes(1)
})
it('documents a required pane-scoped title and explicit clearing', () => {
const spec = COMMAND_SPECS.find((entry) => entry.path.join(' ') === 'terminal set-pane-title')
expect(spec?.usage).toContain('--title <text>')
expect(spec?.usage).not.toContain('[--title')
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
printHelp(COMMAND_SPECS, ['terminal', 'set-pane-title'])
const help = log.mock.calls.flat().join('\n')
expect(help).toContain('Custom title for one pane (pass "" to reset)')
expect(help).not.toContain('omit or pass')
})
})
+12 -1
View File
@@ -4,6 +4,7 @@ import type {
RuntimeTerminalListResult,
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalSetPaneTitle,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait
@@ -16,6 +17,7 @@ import {
formatTerminalList,
formatTerminalRead,
formatTerminalRename,
formatTerminalSetPaneTitle,
formatTerminalShow,
formatTerminalSplit,
formatTerminalWait,
@@ -24,7 +26,8 @@ import {
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
getRequiredStringFlag,
getRequiredStringFlagAllowingEmpty
} from '../flags'
import {
annotateOmittedHostScope,
@@ -152,6 +155,14 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
})
printResult(result, json, formatTerminalRename)
},
'terminal set-pane-title': async ({ flags, client, cwd, json }) => {
const title = getRequiredStringFlagAllowingEmpty(flags, 'title')
const result = await client.call<{ paneTitle: RuntimeTerminalSetPaneTitle }>(
'terminal.setPaneTitle',
{ terminal: await getTerminalHandle(flags, cwd, client), title }
)
printResult(result, json, formatTerminalSetPaneTitle)
},
'terminal create': async ({ flags, client, cwd, json }) => {
if (client.isRemote && !flags.has('worktree')) {
throw new RuntimeClientError(
+4
View File
@@ -88,6 +88,10 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'terminal close' && flag === 'tab') {
return '--tab Close the whole tab and wait for durable persistence'
}
// Why: the shared --title help says "terminal tab" — correct for `rename`, wrong for one pane.
if (command === 'terminal set-pane-title' && flag === 'title') {
return '--title <text> Custom title for one pane (pass "" to reset)'
}
if (command === 'linear issue' && flag === 'id') {
return '--id <id> Linear issue key, id, or URL'
}
+1
View File
@@ -100,6 +100,7 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' terminal wait Wait for a terminal condition (exit, tui-idle)',
' terminal create Create a terminal session in a worktree',
' terminal rename Set or clear the title of a terminal tab',
' terminal set-pane-title Set or clear the title of one terminal pane',
' terminal split Split an existing terminal pane',
' terminal switch Bring a terminal tab to the foreground',
' terminal focus Alias for terminal switch',
+2
View File
@@ -6,6 +6,7 @@ import { SERVE_COMMAND_SPECS } from './serve'
import { REPO_COMMAND_SPECS } from './repo'
import { TERMINAL_SEND_COMMAND_SPEC } from './terminal-send'
import { TERMINAL_CLOSE_COMMAND_SPEC } from './terminal-close'
import { TERMINAL_SET_PANE_TITLE_COMMAND_SPEC } from './terminal-set-pane-title'
export const CORE_COMMAND_SPECS: CommandSpec[] = [
{
@@ -257,6 +258,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'orca terminal rename --terminal term_abc123 --json'
]
},
TERMINAL_SET_PANE_TITLE_COMMAND_SPEC,
{
path: ['terminal', 'split'],
summary: 'Split an existing terminal pane',
+18
View File
@@ -0,0 +1,18 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const TERMINAL_SET_PANE_TITLE_COMMAND_SPEC: CommandSpec = {
path: ['terminal', 'set-pane-title'],
summary: 'Set or clear the title of one terminal pane',
usage: 'orca terminal set-pane-title [--terminal <handle>] --title <text> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'title'],
notes: [
'Sets the title of ONE pane. Split panes share a tab, so terminal rename cannot address one of them; use this command for a single pane.',
'Requires the running Orca desktop; headless pane title changes are not supported.',
'Pass --title "" to clear the pane back to its automatic title.'
],
examples: [
'orca terminal set-pane-title --terminal term_abc123 --title "RUNNER"',
'orca terminal set-pane-title --terminal term_abc123 --title "" --json'
]
}
+9
View File
@@ -13,6 +13,7 @@ import type {
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalSend,
RuntimeTerminalSetPaneTitle,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait
@@ -228,6 +229,14 @@ export function formatTerminalRename(result: { rename: RuntimeTerminalRename }):
: `Cleared title for terminal ${result.rename.handle}.`
}
export function formatTerminalSetPaneTitle(result: {
paneTitle: RuntimeTerminalSetPaneTitle
}): string {
return result.paneTitle.title
? `Set title of pane ${result.paneTitle.leafId} to "${result.paneTitle.title}".`
: `Cleared title of pane ${result.paneTitle.leafId}.`
}
export function formatTerminalCreate(result: { terminal: RuntimeTerminalCreate }): string {
const titleNote = result.terminal.title ? ` (title: "${result.terminal.title}")` : ''
const surfaceNote = result.terminal.surface ? ` [${result.terminal.surface}]` : ''
@@ -19,6 +19,7 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [
['terminal.isRunningAgent', { terminal: 'term' }, false],
['terminal.agentStatus', { terminal: 'term' }, false],
['terminal.rename', { terminal: 'term', title: null }, false],
['terminal.setPaneTitle', { terminal: 'term', title: '' }, false],
['terminal.clearBuffer', { terminal: 'term' }, false],
['terminal.resetInputModes', { terminal: 'term' }, false],
['terminal.readKittyFile', { terminal: 'term', path: '/tmp/image.png' }, false],
@@ -70,11 +71,11 @@ async function invoke(name: string, params: unknown, runtime: Partial<OrcaRuntim
describe('terminal RPC manifest characterization', () => {
it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => {
expect(TERMINAL_METHODS).toHaveLength(39)
expect(TERMINAL_METHODS).toHaveLength(40)
expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual(
METHOD_CASES.map(([name, _params, stream]) => [name, stream])
)
expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(39)
expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(40)
for (const [name, params] of METHOD_CASES) {
expect(() => schemaFor(name).parse(params), name).not.toThrow()
}
@@ -10,7 +10,8 @@ import {
TerminalRecoverPane,
TerminalRename,
TerminalResolveActive,
TerminalResolvePane
TerminalResolvePane,
TerminalSetPaneTitle
} from '../../../../../shared/rpc-contract/terminal-unary-params'
export const TERMINAL_QUERY_METHODS = [
@@ -129,6 +130,14 @@ export const TERMINAL_QUERY_METHODS = [
rename: await runtime.renameTerminal(params.terminal, params.title || null)
})
}),
defineMethod({
name: 'terminal.setPaneTitle',
permission: 'workspace',
params: TerminalSetPaneTitle,
handler: async (params, { runtime }) => ({
paneTitle: await runtime.setPaneTitle(params.terminal, params.title)
})
}),
defineMethod({
name: 'terminal.clearBuffer',
permission: 'workspace',
@@ -119,6 +119,8 @@ export type RuntimeNotifier = {
): void
/** `recordInteraction: false` marks a title the host applied, not one the user typed. */
renameTerminal(tabId: string, title: string | null, options?: { recordInteraction: false }): void
/** False when the window could not be reached; absent on notifiers that predate pane titles. */
setPaneTitle?(tabId: string, leafId: string, title: string | null): boolean
focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void
/** Raises the window onto a pane it shows; resolves false when the window does not know the worktree. */
revealTerminalPane?(target: TerminalPaneRevealTarget): Promise<boolean>
@@ -0,0 +1,105 @@
import './orca-runtime-test-lifecycle.spec'
import { describe, expect, it, vi } from 'vitest'
import { createRuntime, syncSinglePty, TEST_WORKTREE_ID } from './orca-runtime-test-fixtures.spec'
import { electronMocks } from './orca-runtime-test-mocks.spec'
import { createMobileCreateTestNotifier } from './orca-runtime-test-scenario-builders.spec'
import { RpcDispatcher } from './rpc/dispatcher'
import { TERMINAL_QUERY_METHODS } from './rpc/methods/terminal/terminal-query-methods'
async function createPaneTitleRuntime() {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: runtime title routing only checks that the mocked desktop window is not destroyed.
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
const runtime = createRuntime()
syncSinglePty(runtime)
const setPaneTitle = vi.fn(() => true)
const renameTerminal = vi.fn()
const notifier = { ...createMobileCreateTestNotifier(vi.fn()), setPaneTitle, renameTerminal }
runtime.setNotifier(notifier)
const handle = (await runtime.listTerminals()).terminals[0].handle
return { runtime, handle, setPaneTitle, renameTerminal, notifier }
}
describe('pane-scoped terminal title', () => {
it('uses the durable leaf and trims or clears without changing the tab', async () => {
const { runtime, handle, setPaneTitle, renameTerminal } = await createPaneTitleRuntime()
await expect(runtime.setPaneTitle(handle, ' REVIEWER ')).resolves.toEqual({
handle,
tabId: 'tab-1',
leafId: 'pane:1',
title: 'REVIEWER'
})
expect(setPaneTitle).toHaveBeenCalledExactlyOnceWith('tab-1', 'pane:1', 'REVIEWER')
await runtime.setPaneTitle(handle, ' ')
expect(setPaneTitle).toHaveBeenLastCalledWith('tab-1', 'pane:1', null)
expect(renameTerminal).not.toHaveBeenCalled()
await runtime.renameTerminal(handle, 'Tab name')
expect(renameTerminal).toHaveBeenCalledWith('tab-1', 'Tab name')
expect(setPaneTitle).toHaveBeenCalledTimes(2)
})
it('rejects stale handles after a leaf is replaced, even if its numeric id is reused', async () => {
const { runtime, handle, setPaneTitle } = await createPaneTitleRuntime()
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
title: 'Codex',
activeLeafId: 'replacement',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
leafId: 'replacement',
paneRuntimeId: 1,
ptyId: 'pty-2',
paneTitle: null
}
]
})
await expect(runtime.setPaneTitle(handle, 'WRONG')).rejects.toThrow('terminal_handle_stale')
await expect(runtime.setPaneTitle('unknown-handle', 'WRONG')).rejects.toThrow(
'terminal_handle_stale'
)
expect(setPaneTitle).not.toHaveBeenCalled()
})
it('refuses a desktop without the new notification handler', async () => {
const { runtime, handle, notifier } = await createPaneTitleRuntime()
const { setPaneTitle: _unused, ...legacyNotifier } = notifier
runtime.setNotifier(legacyNotifier)
await expect(runtime.setPaneTitle(handle, 'REVIEWER')).rejects.toThrow(
'require a running Orca desktop'
)
})
it('does not report success when the desktop stops accepting notifications', async () => {
const { runtime, handle, setPaneTitle, renameTerminal } = await createPaneTitleRuntime()
setPaneTitle.mockReturnValue(false)
await expect(runtime.setPaneTitle(handle, 'REVIEWER')).rejects.toThrow('runtime_unavailable')
expect(setPaneTitle).toHaveBeenCalledExactlyOnceWith('tab-1', 'pane:1', 'REVIEWER')
expect(renameTerminal).not.toHaveBeenCalled()
})
it('returns an RPC failure receipt when title notification delivery fails', async () => {
const { runtime, handle, setPaneTitle } = await createPaneTitleRuntime()
setPaneTitle.mockReturnValue(false)
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_QUERY_METHODS })
await expect(
dispatcher.dispatch({
id: 'pane-title-request',
authToken: 'test',
method: 'terminal.setPaneTitle',
params: { terminal: handle, title: 'REVIEWER' }
})
).resolves.toMatchObject({
id: 'pane-title-request',
ok: false,
error: { code: 'runtime_unavailable' }
})
expect(setPaneTitle).toHaveBeenCalledTimes(1)
})
})
@@ -17,7 +17,10 @@ import type {
ForceDeleteWorktreeBranchResult,
RemoveWorktreeResult
} from '../../../shared/worktree/create-types'
import type { RuntimeTerminalRename } from '../../../shared/runtime-types'
import type {
RuntimeTerminalRename,
RuntimeTerminalSetPaneTitle
} from '../../../shared/runtime-types'
import type { TerminalWorkspaceLaunchScope } from '../runtime-legacy-worker-terminal-recovery-types'
import type { TerminalCreateOptions } from '../runtime-terminal-contracts'
import {
@@ -263,6 +266,19 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu
return { handle, tabId: leaf.tabId, title }
}
/** Names one pane of a split; the tab's own name is untouched. */
async setPaneTitle(handle: string, title: string | null): Promise<RuntimeTerminalSetPaneTitle> {
const { leaf } = this.getLiveLeafForHandle(handle)
if (!this.getAvailableAuthoritativeWindow() || !this.notifier?.setPaneTitle) {
throw new Error('Pane titles require a running Orca desktop with set-pane-title support')
}
const normalizedTitle = title?.trim() || null
if (!this.notifier.setPaneTitle(leaf.tabId, leaf.leafId, normalizedTitle)) {
throw new Error('runtime_unavailable')
}
return { handle, tabId: leaf.tabId, leafId: leaf.leafId, title: normalizedTitle }
}
async resolveAgentTerminalCreateOptions(
workspace: TerminalWorkspaceLaunchScope,
opts: TerminalCreateOptions
@@ -176,6 +176,7 @@ export function registerRuntimeWindowLifecycle(
},
renameTerminal: (tabId, title, options) =>
send('ui:renameTerminal', { tabId, title, ...options }),
setPaneTitle: (tabId, leafId, title) => send('ui:setPaneTitle', { tabId, leafId, title }),
focusTerminal: (tabId, worktreeId, leafId) =>
send('ui:focusTerminal', { tabId, worktreeId, leafId }),
revealTerminalPane: (target) => revealTerminalPaneInWindow(mainWindow, target),
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import type { BrowserWindow } from 'electron'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract'
vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } }))
vi.mock('../ipc/worktree-change-invalidators', () => ({ runWorktreeChangeInvalidators: vi.fn() }))
vi.mock('./mobile-markdown-request-relay', () => ({ requestMobileMarkdownFromRenderer: vi.fn() }))
vi.mock('./renderer-document-navigation', () => ({ registerRendererDocumentNavigation: vi.fn() }))
vi.mock('./session-tab-close-request-relay', () => ({
requestSessionTabCloseFromRenderer: vi.fn()
}))
vi.mock('./terminal-tab-close-request-relay', () => ({
requestTerminalTabCloseFromRenderer: vi.fn()
}))
import { registerRuntimeWindowLifecycle } from './runtime-window-lifecycle'
function attachNotifier() {
const send = vi.fn()
const markGraphReloadFailed = vi.fn()
const attached: { notifier: RuntimeNotifier | null } = { notifier: null }
const windowState = { destroyed: false }
const mainWindow = {
id: 1,
isDestroyed: () => windowState.destroyed,
on: vi.fn(),
webContents: { isDestroyed: () => false, send, on: vi.fn() }
}
const runtime = {
attachWindow: vi.fn(),
markGraphReloadFailed,
setNotifier: (next: RuntimeNotifier | null) => {
attached.notifier = next
}
}
registerRuntimeWindowLifecycle(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration reads only the supplied window lifecycle and webContents members.
mainWindow as unknown as BrowserWindow,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration calls only the supplied runtime attachment and notification members.
runtime as unknown as OrcaRuntimeService
)
if (!attached.notifier?.setPaneTitle) {
throw new Error('pane-title notifier was not attached')
}
return { setPaneTitle: attached.notifier.setPaneTitle, send, windowState, markGraphReloadFailed }
}
describe('pane title renderer emission receipts', () => {
it('returns true only after emitting the exact addressed leaf and title', () => {
const { setPaneTitle, send } = attachNotifier()
expect(setPaneTitle('tab-1', 'leaf-1', 'REVIEWER')).toBe(true)
expect(send).toHaveBeenCalledExactlyOnceWith('ui:setPaneTitle', {
tabId: 'tab-1',
leafId: 'leaf-1',
title: 'REVIEWER'
})
})
it('returns false without emitting to a destroyed desktop', () => {
const { setPaneTitle, send, windowState } = attachNotifier()
windowState.destroyed = true
expect(setPaneTitle('tab-1', 'leaf-1', null)).toBe(false)
expect(send).not.toHaveBeenCalled()
})
it('reports a disposed frame once and refuses subsequent emissions', () => {
const { setPaneTitle, send, markGraphReloadFailed } = attachNotifier()
send.mockImplementation(() => {
throw new Error('disposed frame')
})
expect(setPaneTitle('tab-1', 'leaf-1', 'REVIEWER')).toBe(false)
expect(setPaneTitle('tab-1', 'leaf-1', 'OTHER')).toBe(false)
expect(send).toHaveBeenCalledTimes(1)
expect(markGraphReloadFailed).toHaveBeenCalledExactlyOnceWith(1, 'renderer-frame-unavailable')
})
})
@@ -177,6 +177,16 @@ export const uiTerminalAndSessionTabsApi = {
ipcRenderer.on('ui:renameTerminal', listener)
return () => ipcRenderer.removeListener('ui:renameTerminal', listener)
},
onSetPaneTitle: (
callback: (data: { tabId: string; leafId: string; title: string | null }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { tabId: string; leafId: string; title: string | null }
) => callback(data)
ipcRenderer.on('ui:setPaneTitle', listener)
return () => ipcRenderer.removeListener('ui:setPaneTitle', listener)
},
onFocusTerminal: (
callback: (data: {
tabId: string
+3
View File
@@ -235,6 +235,9 @@ export type UiCommandEventApi = {
onRenameTerminal: (
callback: (data: { tabId: string; title: string | null; recordInteraction?: false }) => void
) => () => void
onSetPaneTitle?: (
callback: (data: { tabId: string; leafId: string; title: string | null }) => void
) => () => void
onFocusTerminal: (
callback: (data: {
tabId: string
@@ -103,6 +103,7 @@ export type UseTerminalPaneLifecycleDeps = {
setExpandedPane: (paneId: number | null) => void
syncExpandedLayout: () => void
persistLayoutSnapshot: (intent?: PaneLayoutEditIntent) => void
showPaneTitle: (paneId: number, title: string | null) => void
setPaneTitles: React.Dispatch<React.SetStateAction<Record<number, string>>>
paneTitlesRef: React.RefObject<Record<number, string>>
setRenamingPaneId: React.Dispatch<React.SetStateAction<number | null>>
@@ -62,6 +62,7 @@ function install(manager: PaneManager, ptyDeps: { startup?: unknown } = {}): ()
worktreeId: 'wt-1',
isActive: true,
managerRef: { current: manager },
showPaneTitle: vi.fn(),
persistLayoutSnapshot: vi.fn(),
syncCanExpandState: vi.fn(),
queueResizeAll: vi.fn()
@@ -1,5 +1,11 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { CLOSE_TERMINAL_PANE_EVENT, type CloseTerminalPaneDetail } from '@/constants/terminal'
import {
CLOSE_TERMINAL_PANE_EVENT,
SET_TERMINAL_PANE_TITLE_EVENT,
type CloseTerminalPaneDetail,
type SetTerminalPaneTitleDetail
} from '@/constants/terminal'
import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution'
import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-terminal-actions'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
@@ -23,6 +29,7 @@ export function installTerminalPaneMountEvents(args: {
worktreeId: string
isActive: boolean
managerRef: React.RefObject<PaneManager | null>
showPaneTitle: (paneId: number, title: string | null) => void
persistLayoutSnapshot: () => void
syncCanExpandState: () => void
queueResizeAll: (focusActive: boolean) => void
@@ -110,8 +117,24 @@ export function installTerminalPaneMountEvents(args: {
deps.persistLayoutSnapshot()
}
const onSetPaneTitle = (event: Event): void => {
if (!(event instanceof CustomEvent)) {
return
}
const detail: SetTerminalPaneTitleDetail | null = event.detail
if (!detail || detail.tabId !== deps.tabId) {
return
}
const resolution = resolveLeafIdForManager(deps.tabId, detail.leafId, deps.managerRef.current)
if (resolution.status === 'resolved') {
deps.showPaneTitle(resolution.numericPaneId, detail.title)
}
}
window.addEventListener(SET_TERMINAL_PANE_TITLE_EVENT, onSetPaneTitle)
window.addEventListener(CLOSE_TERMINAL_PANE_EVENT, onCliClosePane)
return () => {
window.removeEventListener(SET_TERMINAL_PANE_TITLE_EVENT, onSetPaneTitle)
unregisterTerminalPaneSplitRequestHandler()
window.removeEventListener(CLOSE_TERMINAL_PANE_EVENT, onCliClosePane)
}
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SET_TERMINAL_PANE_TITLE_EVENT } from '@/constants/terminal'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyConnectionDeps } from './pty-connection-types'
import { installTerminalPaneMountEvents } from './terminal-pane-mount-events'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SIBLING_ID = '22222222-2222-4222-8222-222222222222'
function createHarness() {
const listeners = new Map<string, (event: Event) => void>()
vi.stubGlobal('window', {
addEventListener: (type: string, listener: (event: Event) => void) =>
listeners.set(type, listener),
removeEventListener: (type: string) => listeners.delete(type)
})
const panes = [
{ id: 7, leafId: LEAF_ID },
{ id: 8, leafId: SIBLING_ID }
]
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: title events only read the two identity methods; other mount event listeners are not invoked.
const manager = {
getNumericIdForLeaf: (id: string) => (id === LEAF_ID ? 7 : id === SIBLING_ID ? 8 : null),
getPanes: () => panes
} as unknown as PaneManager
const showPaneTitle = vi.fn()
const uninstall = installTerminalPaneMountEvents({
manager,
deps: {
tabId: 'tab-1',
worktreeId: 'wt-1',
isActive: true,
managerRef: { current: manager },
showPaneTitle,
persistLayoutSnapshot: vi.fn(),
syncCanExpandState: vi.fn(),
queueResizeAll: vi.fn()
},
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: title and listener cleanup never use PTY connection dependencies.
ptyDeps: {} as PtyConnectionDeps
})
const dispatch = (tabId: string, leafId: string, title: string | null) =>
listeners.get(SET_TERMINAL_PANE_TITLE_EVENT)?.(
new CustomEvent(SET_TERMINAL_PANE_TITLE_EVENT, { detail: { tabId, leafId, title } })
)
return { panes, showPaneTitle, dispatch, uninstall, listeners }
}
afterEach(() => vi.unstubAllGlobals())
describe('pane title event identity', () => {
it('routes set and clear to the GUI setter for the addressed leaf only', () => {
const { dispatch, showPaneTitle } = createHarness()
dispatch('tab-1', LEAF_ID, 'REVIEWER')
dispatch('tab-1', LEAF_ID, null)
expect(showPaneTitle.mock.calls).toEqual([
[7, 'REVIEWER'],
[7, null]
])
})
it('rejects a stale leaf after the numeric pane id is reused, and another tab', () => {
const { dispatch, showPaneTitle, panes } = createHarness()
panes[0].leafId = SIBLING_ID
dispatch('tab-1', LEAF_ID, 'WRONG')
dispatch('other-tab', SIBLING_ID, 'WRONG')
dispatch('tab-1', 'pane:7', 'WRONG')
expect(showPaneTitle).not.toHaveBeenCalled()
})
it('removes its subscription on teardown', () => {
const { uninstall, listeners } = createHarness()
expect(listeners.has(SET_TERMINAL_PANE_TITLE_EVENT)).toBe(true)
uninstall()
expect(listeners.has(SET_TERMINAL_PANE_TITLE_EVENT)).toBe(false)
})
})
@@ -206,11 +206,16 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Refs are stable.
[persistLayoutSnapshot]
)
/** Shows a title main already holds, without asking main again. */
const showPaneTitle = useCallback(
(paneId: number, title: string | null): void =>
title === null ? removePaneTitleLocally(paneId) : setPaneTitleLocally(paneId, title),
[removePaneTitleLocally, setPaneTitleLocally]
)
/** A user's title for a pane, trimmed and non-empty; null clears it. */
const changePaneTitle = useCallback(
(paneId: number, title: string | null) => {
const showTitle = (): void =>
title === null ? removePaneTitleLocally(paneId) : setPaneTitleLocally(paneId, title)
const showTitle = (): void => showPaneTitle(paneId, title)
const leafId = managerRef.current?.getPanes().find((pane) => pane.id === paneId)?.leafId
// Switch point: main renames the pane first; the window then shows the title.
if (
@@ -227,7 +232,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup
showTitle()
},
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Refs are stable.
[removePaneTitleLocally, setPaneTitleLocally, tabId, worktreeId]
[showPaneTitle, tabId, worktreeId]
)
const removePaneTitle = useCallback(
(paneId: number) => changePaneTitle(paneId, null),
@@ -290,6 +295,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup
persistLayoutSnapshot,
clearPaneScrollback,
changePaneTitle,
showPaneTitle,
removePaneTitle,
handleClearPaneTitleShortcut
}
@@ -48,6 +48,7 @@ export function useTerminalPaneLifecycleStage(controller: TerminalPaneCloseContr
setPaneTitles,
setRenamingPaneId,
setRuntimePaneTitle,
showPaneTitle,
setTabCanExpandPane,
setTabPaneExpanded,
settings,
@@ -125,6 +126,7 @@ export function useTerminalPaneLifecycleStage(controller: TerminalPaneCloseContr
setExpandedPane,
syncExpandedLayout,
persistLayoutSnapshot,
showPaneTitle,
setPaneTitles,
paneTitlesRef,
setRenamingPaneId,
@@ -108,6 +108,7 @@ export function useTerminalPaneMountLifecycle(
worktreeId: deps.worktreeId,
isActive: deps.isActive,
managerRef: deps.managerRef,
showPaneTitle: deps.showPaneTitle,
persistLayoutSnapshot: deps.persistLayoutSnapshot,
syncCanExpandState: preparation.syncCanExpandState,
queueResizeAll
+9
View File
@@ -6,6 +6,7 @@ export const PASTE_TERMINAL_TEXT_EVENT = 'orca-paste-terminal-text'
export const SPLIT_TERMINAL_PANE_EVENT = 'orca-split-terminal-pane'
export const REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT = 'orca-request-active-terminal-pane-split'
export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane'
export const SET_TERMINAL_PANE_TITLE_EVENT = 'orca-set-terminal-pane-title'
export const REQUEST_ACTIVE_TERMINAL_PANE_CLOSE_EVENT = 'orca-request-active-terminal-pane-close'
export const BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT = 'orca-background-mount-terminal-worktree'
@@ -79,6 +80,14 @@ export type CloseTerminalPaneDetail = {
expectedPtyId?: string
}
export type SetTerminalPaneTitleDetail = {
tabId: string
/** Durable layout key; the mounted pane resolves it to its renderer-local numeric id. */
leafId: string
/** Trimmed title, or null to fall back to the pane's automatic title. */
title: string | null
}
export type BackgroundMountTerminalWorktreeDetail = {
worktreeId: string
/** When set, only these terminal tabs mount for the background worktree.
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SET_TERMINAL_PANE_TITLE_EVENT } from '@/constants/terminal'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
const { fakeStore } = vi.hoisted(() => {
const layouts: Record<string, TerminalLayoutSnapshot> = {}
const state = {
tabsByWorktree: {},
terminalLayoutsByTabId: layouts,
setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot) => {
state.terminalLayoutsByTabId = { ...state.terminalLayoutsByTabId, [tabId]: layout }
}
}
return { fakeStore: state }
})
vi.mock('../../store', () => ({ useAppStore: { getState: () => fakeStore } }))
import { applyRuntimePaneTitle } from './terminal-pane-title-ipc-bridge'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SIBLING_ID = '22222222-2222-4222-8222-222222222222'
const LAYOUT: TerminalLayoutSnapshot = {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SIBLING_ID }
},
activeLeafId: SIBLING_ID,
expandedLeafId: null,
titlesByLeafId: { [SIBLING_ID]: 'DEVELOPER' },
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1', [SIBLING_ID]: 'pty-2' }
}
const dispatchEvent = vi.fn()
beforeEach(() => {
fakeStore.terminalLayoutsByTabId = { 'tab-1': LAYOUT }
dispatchEvent.mockReset()
vi.stubGlobal('window', { dispatchEvent })
})
afterEach(() => vi.unstubAllGlobals())
const layout = (): TerminalLayoutSnapshot | undefined => fakeStore.terminalLayoutsByTabId['tab-1']
describe('a pane title from main', () => {
it('saves set and clear in the layout for parked panes, leaving siblings alone', () => {
applyRuntimePaneTitle({ tabId: 'tab-1', leafId: LEAF_ID, title: ' REVIEWER ' })
expect(layout()).toEqual({
...LAYOUT,
titlesByLeafId: { [SIBLING_ID]: 'DEVELOPER', [LEAF_ID]: 'REVIEWER' }
})
applyRuntimePaneTitle({ tabId: 'tab-1', leafId: LEAF_ID, title: null })
expect(layout()).toEqual(LAYOUT)
})
it('tells a mounted pane, and stays quiet when nothing changed', () => {
applyRuntimePaneTitle({ tabId: 'tab-1', leafId: LEAF_ID, title: 'REVIEWER' })
applyRuntimePaneTitle({ tabId: 'tab-1', leafId: LEAF_ID, title: 'REVIEWER' })
applyRuntimePaneTitle({ tabId: 'tab-1', leafId: SIBLING_ID, title: 'DEVELOPER' })
expect(dispatchEvent).toHaveBeenCalledTimes(1)
const event = dispatchEvent.mock.calls[0]?.[0]
expect(event).toBeInstanceOf(CustomEvent)
expect(event.type).toBe(SET_TERMINAL_PANE_TITLE_EVENT)
expect(event.detail).toEqual({ tabId: 'tab-1', leafId: LEAF_ID, title: 'REVIEWER' })
})
it("names a never-mounted tab's one pane from a rootless layout", () => {
const rootless: TerminalLayoutSnapshot = {
root: null,
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
}
fakeStore.terminalLayoutsByTabId = { 'tab-1': rootless }
applyRuntimePaneTitle({ tabId: 'tab-1', leafId: LEAF_ID, title: 'REVIEWER' })
expect(layout()).toEqual({ ...rootless, titlesByLeafId: { [LEAF_ID]: 'REVIEWER' } })
})
it('drops the field once the last title is cleared', () => {
applyRuntimePaneTitle({ tabId: 'tab-1', leafId: SIBLING_ID, title: '' })
expect(layout()).not.toHaveProperty('titlesByLeafId')
})
it('ignores unknown tabs, leaves outside the layout, and non-leaf ids', () => {
applyRuntimePaneTitle({ tabId: 'other-tab', leafId: LEAF_ID, title: 'WRONG' })
applyRuntimePaneTitle({
tabId: 'tab-1',
leafId: '33333333-3333-4333-8333-333333333333',
title: 'WRONG'
})
applyRuntimePaneTitle({ tabId: 'tab-1', leafId: 'pane:7', title: 'WRONG' })
expect(layout()).toEqual(LAYOUT)
expect(dispatchEvent).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,67 @@
import {
SET_TERMINAL_PANE_TITLE_EVENT,
type SetTerminalPaneTitleDetail
} from '@/constants/terminal'
import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids'
import { findTerminalTabWorktreeId } from '@/components/native-chat/native-chat-file-link'
import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
import { useAppStore } from '../../store'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { resolveRootlessTerminalLayoutLeafId } from '../../../../shared/workspace-layout/terminal-pane-tree'
function showRuntimePaneTitle(tabId: string, leafId: string, title: string | null): void {
const store = useAppStore.getState()
const layout = store.terminalLayoutsByTabId[tabId]
if (!layout) {
return
}
// Parked panes have no event listener; their next mount reads this same layout.
const titlesByLeafId = { ...layout.titlesByLeafId }
if (title) {
titlesByLeafId[leafId] = title
} else {
delete titlesByLeafId[leafId]
}
const { titlesByLeafId: _previousTitles, ...layoutWithoutTitles } = layout
const nextLayout: TerminalLayoutSnapshot =
Object.keys(titlesByLeafId).length > 0
? { ...layoutWithoutTitles, titlesByLeafId }
: layoutWithoutTitles
store.setTabLayout(tabId, nextLayout)
const detail: SetTerminalPaneTitleDetail = { tabId, leafId, title }
window.dispatchEvent(new CustomEvent(SET_TERMINAL_PANE_TITLE_EVENT, { detail }))
}
/** Applies main's title for one pane; the tab's own name is never touched. */
export function applyRuntimePaneTitle({ tabId, leafId, title }: SetTerminalPaneTitleDetail): void {
const store = useAppStore.getState()
const layout = store.terminalLayoutsByTabId[tabId]
// A never-mounted tab's layout can be rootless and name its one pane elsewhere.
const leafIds = layout?.root
? collectLeafIdsInOrder(layout.root)
: [layout && resolveRootlessTerminalLayoutLeafId(layout)]
if (!layout || !isTerminalLeafId(leafId) || !leafIds.includes(leafId)) {
return
}
const normalizedTitle = title?.trim() || null
if ((layout.titlesByLeafId?.[leafId] ?? null) === normalizedTitle) {
return
}
const show = (): void => showRuntimePaneTitle(tabId, leafId, normalizedTitle)
const worktreeId = findTerminalTabWorktreeId(store.tabsByWorktree, tabId)
// Switch point: a main-owned layout takes the rename first, as the GUI's rename does.
if (
ORCA_LAYOUT_OWNER_IS_MAIN &&
worktreeId &&
sendLayoutCommandToOwner(
worktreeId,
{ type: 'renamePane', tabId, leafId, title: normalizedTitle },
{ apply: show, asToday: show }
)
) {
return
}
show()
}
@@ -12,6 +12,7 @@ import { resolveBrowserSessionTabTarget } from './browser-session-tab-target'
import { resolveWindowTabIdForHostTab } from './host-session-tab-target'
import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal'
import { focusTerminalInitiatedTab } from './terminal-command-state'
import { applyRuntimePaneTitle } from './terminal-pane-title-ipc-bridge'
type RuntimeTerminalSplitRequest = SplitTerminalPaneDetail & { worktreeId?: string }
@@ -116,6 +117,11 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void
})
)
// Why: during an in-place renderer reload an older preload can linger; keep this listener additive at that seam.
if (window.api.ui.onSetPaneTitle) {
unsubs.push(window.api.ui.onSetPaneTitle(applyRuntimePaneTitle))
}
unsubs.push(
window.api.ui.onFocusTerminal(
({
@@ -269,6 +269,7 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
replyTerminalPaneReveal: () => {},
onSplitTerminal: () => noopUnsubscribe,
onRenameTerminal: () => noopUnsubscribe,
onSetPaneTitle: () => noopUnsubscribe,
onFocusTerminal: () => noopUnsubscribe,
onFocusEditorTab: () => noopUnsubscribe,
onCloseSessionTab: () => noopUnsubscribe,
+2
View File
@@ -586,6 +586,7 @@ import {
TerminalResolvePane,
TerminalReveal,
TerminalSend,
TerminalSetPaneTitle,
TerminalSetViewerColors,
TerminalSplit,
TerminalStopExact,
@@ -1336,6 +1337,7 @@ export const RPC_PARAMS_BY_METHOD = {
'terminal.send': TerminalSend,
'terminal.setAutoRestoreFit': TerminalSetAutoRestoreFit,
'terminal.setDisplayMode': TerminalSetDisplayMode,
'terminal.setPaneTitle': TerminalSetPaneTitle,
'terminal.setViewerColors': TerminalSetViewerColors,
'terminal.show': TerminalHandle,
'terminal.sleep': TerminalCloseAll,
@@ -131,6 +131,11 @@ export const TerminalRename = TerminalHandle.extend({
})
})
// An explicit empty string clears the pane; an omitted title must not clear it.
export const TerminalSetPaneTitle = TerminalHandle.extend({
title: z.string()
})
export const TerminalSend = TerminalHandle.extend({
text: OptionalString,
enter: z.unknown().optional(),
+7
View File
@@ -224,6 +224,13 @@ export type RuntimeTerminalRename = {
title: string | null
}
export type RuntimeTerminalSetPaneTitle = {
handle: string
tabId: string
leafId: string
title: string | null
}
export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null
export type RuntimeTerminalAgentStatus = {
+1
View File
@@ -165,6 +165,7 @@ export type {
RuntimeTerminalPromptStage,
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalSetPaneTitle,
RuntimeTerminalResolvePane,
RuntimeTerminalSend,
RuntimeTerminalShow,