mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -38,6 +38,7 @@ export {
|
||||
formatTerminalList,
|
||||
formatTerminalRead,
|
||||
formatTerminalRename,
|
||||
formatTerminalSetPaneTitle,
|
||||
formatTerminalSend,
|
||||
formatTerminalShow,
|
||||
formatTerminalSplit,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>>
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -165,6 +165,7 @@ export type {
|
||||
RuntimeTerminalPromptStage,
|
||||
RuntimeTerminalRead,
|
||||
RuntimeTerminalRename,
|
||||
RuntimeTerminalSetPaneTitle,
|
||||
RuntimeTerminalResolvePane,
|
||||
RuntimeTerminalSend,
|
||||
RuntimeTerminalShow,
|
||||
|
||||
Reference in New Issue
Block a user