diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index c8fcf0a10c9..d2f087b21eb 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -441,6 +441,34 @@ describe('headless hook agent-status projection (#11761)', () => { }) }) + // A rename is the tab's name, not process title evidence: the CLI status read keeps the agent. + it('keeps the terminal agent status running after the pane is renamed to a shell name', async () => { + const runtime = await createRuntimeWithHookRows([ + hookRow({ + state: 'working', + prompt: 'Working', + toolName: undefined, + interactivePrompt: undefined + }) + ]) + observePaneTitle(runtime, '⠋ Claude') + const { tabs } = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const handle = tabs[0]?.type === 'terminal' ? tabs[0].terminal : undefined + if (!handle) { + throw new Error('expected a live terminal handle') + } + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true + }) + + await runtime.renameTerminal(handle, 'bash') + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + }) + it('retires the renamed agent when its OSC title returns to the shell', async () => { const runtime = await createRuntimeWithHookRows([ hookRow({ state: 'working', prompt: '', toolName: undefined, interactivePrompt: undefined }) diff --git a/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts index 4166ebd9844..84081c2b36b 100644 --- a/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts +++ b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts @@ -1,11 +1,13 @@ import './orca-runtime-test-lifecycle.spec' import type { RuntimeStore } from './runtime-store-contract' +import { getDefaultWorkspaceSession } from '../../shared/constants' import { describe, expect, it, vi } from 'vitest' import { createMobileCreateTestNotifier } from './orca-runtime-test-scenario-builders.spec' import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, TEST_WORKTREE_ID, + TEST_WORKTREE_PATH, makeRuntimeStoreWithWorkspaceSession, makeWorkspaceSessionWithHeadlessTerminal } from './orca-runtime-test-fixtures.spec' @@ -92,6 +94,104 @@ describe('terminal rename before renderer graph hydration', () => { }) }) +// The name is display only (#20626): every reader that shows the terminal still shows it. +describe('terminal rename display', () => { + it('reveals a renamed background terminal under its name', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'background-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const notifier = createMobileCreateTestNotifier(vi.fn()) + runtime.setNotifier(notifier) + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + presentation: 'background' + }) + runtime.onPtyData('background-pty', '\x1b]0;bash\x07', Date.now()) + + await runtime.renameTerminal(created.handle, 'Review worker') + await runtime.focusTerminal(created.handle) + + expect(notifier.revealTerminalSession.mock.calls.at(-1)?.[1]).toMatchObject({ + title: 'Review worker' + }) + }) + + it('shows an orphan terminal under its new name', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.registerPty('orphan-pty', TEST_WORKTREE_ID) + runtime.onPtyData('orphan-pty', '\x1b]0;bash\x07', Date.now()) + const orphan = (await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).terminals.find( + (terminal) => terminal.ptyId === 'orphan-pty' + ) + if (!orphan?.orphaned) { + throw new Error('expected an orphan terminal') + } + + await runtime.renameTerminal(orphan.handle, 'Recovered worker') + + await expect(runtime.showTerminal(orphan.handle)).resolves.toMatchObject({ + title: 'Recovered worker' + }) + }) + + it('keeps an orphan terminal name once a tab adopts it', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] } + }) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'orphan-pty', + incarnationId: 'orphan-incarnation', + terminalHandle: 'term_orphan', + title: 'bash', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + wslDistro: null + } + ] + }) + const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + runtime.onPtyData('orphan-pty', '\x1b]0;bash\x07', Date.now()) + await runtime.renameTerminal('term_orphan', 'Recovered worker') + + const adopted = await runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: before.topologyRevisions?.[TEST_WORKTREE_ID] ?? 0, + claims: [ + { + terminal: 'term_orphan', + ptyId: 'orphan-pty', + incarnationId: 'orphan-incarnation', + tabId: 'recovered-tab', + leafId: HEADLESS_LEAF_ID + } + ] + }) + + await expect(runtime.showTerminal('term_orphan')).resolves.toMatchObject({ + title: 'Recovered worker' + }) + expect( + adopted.snapshot.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'recovered-tab' + ) + ).toMatchObject({ title: 'Recovered worker' }) + }) +}) + describe('terminal view rename characterization', () => { function notifier(renameTerminal: RuntimeNotifier['renameTerminal']): RuntimeNotifier { return { @@ -131,8 +231,7 @@ describe('terminal view rename characterization', () => { runtime['touchMobileSessionSnapshotsForPty'] = function (this: OrcaRuntimeService, ptyId) { receivers.push(this) expect(ptyId).toBe('rename-pty') - expect(pty.title).toBe('Pinned') - expect(pty.titleUpdatedAt).toBeGreaterThan(0) + expect(pty.title).toBeNull() events.push('touch') } await expect(runtime.renameTerminal(handle, 'Pinned')).resolves.toEqual({ diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts index fe3687aab0a..8998a933e37 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_BRACKETED_PASTE_START, @@ -14,6 +15,7 @@ import { TEST_WORKTREE_ID, TEST_WORKTREE_PATH, UUID_RE, + makeRuntimeStoreWithWorkspaceSession, renderGateCapMs, store } from '../orca-runtime-test-fixtures.spec' @@ -538,7 +540,8 @@ describe('OrcaRuntimeService', () => { }) it('renames background terminal handles without requiring a visible tab', async () => { - const runtime = new OrcaRuntimeService(store) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()) + const runtime = new OrcaRuntimeService(runtimeStore) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), write: () => true, diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts index 1c651b20f9b..ef0480a35a6 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts @@ -183,20 +183,6 @@ describe('restored turns on a synced leaf', () => { ) }) - it('keeps a restored turn when a manual shell-like label overrides an agent OSC title', () => { - const receivedAt = Date.now() - 60_000 - const leaf = { - ...leafWithTitle('✳ Claude'), - paneTitle: 'bash', - paneTitleUpdatedAt: 2 - } - const result = project(leaf, receivedAt + 1_000, receivedAt) - - expect(result.agentStatus).toEqual( - expect.objectContaining({ state: 'working', restoredUnconfirmed: true }) - ) - }) - it('retires to done once the leaf PTY paints a newer shell title', () => { const receivedAt = Date.now() - 60_000 const result = project(leafWithTitle('bash'), receivedAt + 1_000, receivedAt) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index 3f56fc124bf..0dcccef37e8 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -71,12 +71,7 @@ export function buildRuntimeMobileAgentStatus( ) : null const ptyTitleClassification = classifyAgentTitle(ptyTitle) - const titleOmitsAgent = ptyTitle !== null && ptyTitleClassification !== 'agent' - const oscTitle = pty?.lastOscTitle ?? leaf?.lastOscTitle ?? null - // Why: a manual rename is not the shell reclaiming the pane, so the hook session stays addressable (#20626). - const manualTitleOverridesAgentOsc = - titleOmitsAgent && ptyTitle !== oscTitle && classifyAgentTitle(oscTitle) === 'agent' - const nonAgentTitle = titleOmitsAgent && !manualTitleOverridesAgentOsc + const nonAgentTitle = ptyTitle !== null && ptyTitleClassification !== 'agent' if (nonAgentTitle) { // Why: non-agent title = shell reclaimed the pane; suppress to clear stuck spinners (#1437), though a live hook signal survives. const hasLiveHookSignal = @@ -147,7 +142,6 @@ export function buildRuntimeMobileAgentStatus( // client's row) and drop the question fields (#12346). Only a later shell title retires it. const shellTitleRetiredRestored = hookRow.restored !== null && - !manualTitleOverridesAgentOsc && terminalTitleBlocksExplicitAgentStatus(ptyTitle) && (pty?.lastOscTitleEpochMs ?? host.getPtyTitleObservedAtEpochMs?.(leaf?.ptyId ?? null) ?? 0) >= hookRow.restored.updatedAt diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index 6696e3ce18f..9a3afe56f4c 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -55,7 +55,8 @@ export function buildRuntimeTerminalOrphanSession(args: { worktreeId: worktreeId, title, defaultTitle: title, - customTitle: null, + // An orphan's rename lives on its PTY until a tab owns it. + customTitle: pty.manualTitle ?? null, color: null, sortOrder: tabsById.size, createdAt: Date.now() diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index e0e989d0527..9bbed1fbee4 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -112,6 +112,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { controllerTitle: string | null title: string | null titleUpdatedAt: number | null + /** The name a rename gave this terminal: display only, never title evidence (#20626). */ + manualTitle?: string | null lastOutputAt: number | null /** See terminal-command-paint.ts; absent until the pane's first output, and again after a gap or a new process. */ commandPaint?: TerminalCommandPaint diff --git a/src/main/runtime/terminal/focus/focus-terminal.ts b/src/main/runtime/terminal/focus/focus-terminal.ts index c7453c0b80e..9f6ba58f41b 100644 --- a/src/main/runtime/terminal/focus/focus-terminal.ts +++ b/src/main/runtime/terminal/focus/focus-terminal.ts @@ -7,8 +7,13 @@ import type { import { parsePaneKey } from '../../../../shared/stable-pane-id' import { getLatestPtyTitle } from '../../runtime-worktree-status-projection' import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution' +import type { OrcaRuntimeWithBuildPtyTerminalSummary as PtyTerminalSummaryMethods } from '../query/pty-terminal-summary' export abstract class OrcaRuntimeWithFocusTerminal extends OrcaRuntimeWithWaitForLeafPtyId { + protected abstract getPtyManualTitle( + ...args: Parameters + ): ReturnType + async focusTerminal( handle: string, options: { navigateHost?: boolean } = {} @@ -80,7 +85,9 @@ export abstract class OrcaRuntimeWithFocusTerminal extends OrcaRuntimeWithWaitFo const parsedPaneKey = parsePaneKey(live.pty.paneKey ?? '') const revealed = await notifier.revealTerminalSession(live.pty.worktreeId, { ptyId: live.pty.ptyId, - title: getLatestPtyTitle(this.getPtyDisplayRecord(live.pty)), + title: + this.getPtyManualTitle(live.pty, false) ?? + getLatestPtyTitle(this.getPtyDisplayRecord(live.pty)), ...(live.pty.launchConfig ? { launchConfig: copySleepingAgentLaunchConfig(live.pty.launchConfig) } : {}), diff --git a/src/main/runtime/terminal/query/pty-terminal-summary.ts b/src/main/runtime/terminal/query/pty-terminal-summary.ts index e7d581613ad..980c91247aa 100644 --- a/src/main/runtime/terminal/query/pty-terminal-summary.ts +++ b/src/main/runtime/terminal/query/pty-terminal-summary.ts @@ -46,10 +46,8 @@ export abstract class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntime const pane = parsePaneKey(pty.paneKey ?? '') const orphaned = !ptyHoldsRecordedSurface(pty, this.ptySurfaceTopology()) - // A restarted host's terminal has no title yet; until it has one, its tab's saved name stands in. - const title = - getLatestPtyTitle(this.getPtyDisplayRecord(pty)) ?? - (orphaned ? null : this.getSavedTerminalTabCustomTitle(pty.worktreeId, pty.tabId)) + const processTitle = getLatestPtyTitle(this.getPtyDisplayRecord(pty)) + const title = this.getPtyManualTitle(pty, orphaned) ?? processTitle const paneTitle = orphaned || !pty.tabId || !pane ? null @@ -87,20 +85,20 @@ export abstract class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntime ...this.resolvePaneAgentIdentityField( pty.launchAgent, pty.foregroundAgent, - title, + processTitle, pty.paneKey ?? null ) } } - protected getSavedTerminalTabCustomTitle( - worktreeId: string, - tabId: string | null - ): string | null { - const tab = this.getWorkspaceSessionForWorktree(worktreeId)?.tabsByWorktree[worktreeId]?.find( - (candidate) => candidate.id === tabId - ) - return tab?.customTitle?.trim() || null + /** The terminal's manual name: its saved tab's, or the rename's while no saved tab holds it. */ + protected getPtyManualTitle(pty: RuntimePtyWorktreeRecord, orphaned: boolean): string | null { + const tab = orphaned + ? undefined + : this.getWorkspaceSessionForWorktree(pty.worktreeId)?.tabsByWorktree[pty.worktreeId]?.find( + (candidate) => candidate.id === pty.tabId + ) + return tab ? tab.customTitle?.trim() || null : (pty.manualTitle ?? null) } protected getLiveLeafForHandle(handle: string): { diff --git a/src/main/runtime/workspaces/workspace-removal-target.ts b/src/main/runtime/workspaces/workspace-removal-target.ts index dd486e28163..f64a4156983 100644 --- a/src/main/runtime/workspaces/workspace-removal-target.ts +++ b/src/main/runtime/workspaces/workspace-removal-target.ts @@ -240,10 +240,9 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu } const pty = this.getLivePtyForHandle(handle) if (pty) { - pty.pty.title = title - // Why: a manual rename must outrank later agent OSC title updates (which - // win by timestamp), so stamp it as the freshest title. - pty.pty.titleUpdatedAt = Date.now() + // Why: not pty.title — that is the process's own title evidence, and a name there reads as + // the shell taking the pane back (#20626). + pty.pty.manualTitle = title?.trim() || null this.touchMobileSessionSnapshotsForPty(pty.pty.ptyId) // Why: without a renderer the rename only lived on the live pty and was // lost on restart. Persist customTitle so a headless rebuild keeps it.