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
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}]` : ''