diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index fd0645f5274..7d28c1f9ee8 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -20020,9 +20020,9 @@ describe('OrcaRuntimeService', () => { ptysById: Map } ).ptysById.get('pty-1') - expect(pty?.lastOscTitle).toBe('Pi') + expect(pty?.lastOscTitle).toBe('π - my-project') expect(pty?.lastAgentStatus).toBe('idle') - // Why: worktree.ps / mobile re-detect from stored lastOscTitle, not the raw OSC frame; bare "Pi" must still classify as idle after normalize. + // Why: worktree.ps / mobile re-detect from stored lastOscTitle, not the raw OSC frame; the preserved π title must still classify as idle after normalize. expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('idle') }) @@ -20049,7 +20049,7 @@ describe('OrcaRuntimeService', () => { applySeededAgentStatus: (ptyId: string, title: string) => void } ).applySeededAgentStatus('pty-1', 'π - my-project') - expect(pty?.lastOscTitle).toBe('Pi') + expect(pty?.lastOscTitle).toBe('π - my-project') expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('idle') }) @@ -25867,7 +25867,7 @@ describe('OrcaRuntimeService', () => { expect(result.tabs[0]).toEqual( expect.objectContaining({ type: 'terminal', - title: '\u280b OMP' + title: '\u280b OMP - tmp' }) ) expect(result.tabs[0]).not.toHaveProperty('launchAgent') diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index 6fcd0a6719c..c5de2eb1813 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -86,7 +86,7 @@ describe('buildTitleDerivedAgentRows', () => { }) expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([ - ['omp', 'working', '\u280b OMP'] + ['omp', 'working', '\u280b OMP: tmp'] ]) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts index 3663f07402a..410c252adc8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts @@ -479,8 +479,8 @@ describe('connectPanePty', () => { expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '\u280b OMP') expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '\u280b OMP') titleHandler('π: tmp', 'π: tmp') - expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'OMP ready') - expect(deps.updateTabTitle).toHaveBeenLastCalledWith('tab-1', 'OMP ready') + expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'OMP: tmp') + expect(deps.updateTabTitle).toHaveBeenLastCalledWith('tab-1', 'OMP: tmp') const statusHandler = createdTransportOptions[0]?.onAgentStatus as | ((payload: { state: 'working'; prompt: string; agentType: 'pi' }) => void) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index e961329eb3c..6cbc2eb99f4 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -1,4 +1,4 @@ -// Why: reproduce the renderer Pi spinner pipeline (pty:data IPC → onTitleChange); electron verification showed frames arrive but the store never sees "⠋ Pi". +// Why: reproduce the renderer Pi spinner pipeline (pty:data IPC → onTitleChange); electron verification showed frames arrive but the store never sees the canonicalized "⠋ π - cwd" working title. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -113,7 +113,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) - expect(seenTitles).toContain('⠋ Pi') + expect(seenTitles).toContain('⠋ π - cwd') transport.disconnect() }) @@ -133,7 +133,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) - expect(seenTitles).toContain('⠋ Pi') + expect(seenTitles).toContain('⠋ π - cwd') transport.disconnect() }) @@ -153,13 +153,13 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) - expect(seenTitles).toContain('⠋ Pi') + expect(seenTitles).toContain('⠋ π - cwd') transport.disconnect() }) - it('reproduces "Pi is idle" state: after working→idle, onTitleChange ends on Pi', async () => { - // Why: bug shows the store stuck at idle "Pi" while working — assert both working and idle labels reach onTitleChange, in order. + it('reproduces "Pi is idle" state: after working→idle, onTitleChange ends on the idle title', async () => { + // Why: bug shows the store stuck at the idle title while working — assert both working and idle titles reach onTitleChange, in order. const { createIpcPtyTransport } = await import('./pty-transport') const onTitleChange = vi.fn() @@ -173,8 +173,8 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) - const workingIdx = seenTitles.indexOf('⠋ Pi') - const finalIdleIdx = seenTitles.lastIndexOf('Pi') + const workingIdx = seenTitles.indexOf('⠋ π - cwd') + const finalIdleIdx = seenTitles.lastIndexOf('π - cwd') expect(workingIdx).toBeGreaterThanOrEqual(0) expect(finalIdleIdx).toBeGreaterThan(workingIdx) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts index 808cedad5e9..3d4f26ec97b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts @@ -79,9 +79,9 @@ describe('pty-transport — coalesced OSC titles from Pi', () => { // renders 'working' as long as detectAgentStatusFromTitle sees a working // title — if the intermediate frames are dropped, users never see the // spinner on fast-agent prompts. - expect(seen).toContain('⠋ Pi') + expect(seen).toContain('⠋ π - cwd') // Idle must land last so the spinner disappears on agent_end. - expect(seen.at(-1)).toBe('Pi') + expect(seen.at(-1)).toBe('π - cwd') transport.disconnect() }) @@ -101,7 +101,7 @@ describe('pty-transport — coalesced OSC titles from Pi', () => { await flushPtySideEffects() const seen = onTitleChange.mock.calls.map((c) => c[0]) - expect(seen).toContain('⠋ Pi') + expect(seen).toContain('⠋ π - cwd') transport.disconnect() }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts index c556993c8c9..1c67b87be06 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts @@ -2,7 +2,8 @@ // entirely in unit-test harness. Pi's titlebar extension emits OSC 0 titles of // the form `\x1b]0;⠋ π - cwd\x07` (working frames) and `\x1b]0;π - cwd\x07` // (idle). The electron-level verification showed these chunks reach `pty:data` -// but the store's runtimePaneTitlesByTabId never flips to "⠋ Pi". This file +// but the store's runtimePaneTitlesByTabId never flips to the canonicalized +// "⠋ π - cwd" working title. This file // pins the transport-level contract: `onTitleChange` must fire for working // frames, in the normalized form the store consumes. @@ -71,12 +72,12 @@ describe('createIpcPtyTransport — Pi titlebar spinner signal', () => { await flushPtySideEffects() const normalized = onTitleChange.mock.calls.map((c) => c[0]) - // The store only stores the normalized label, which is what the worktree + // The store only stores the normalized title, which is what the worktree // card feeds back into detectAgentStatusFromTitle. The working→idle cycle - // must surface at least one "⠋ Pi" so the card can classify the pane as - // 'working' (detectAgentStatusFromTitle('⠋ Pi') === 'working'). - expect(normalized).toContain('⠋ Pi') - expect(normalized).toContain('Pi') + // must surface at least one "⠋ π - cwd" so the card can classify the pane as + // 'working' (detectAgentStatusFromTitle('⠋ π - cwd') === 'working'). + expect(normalized).toContain('⠋ π - cwd') + expect(normalized).toContain('π - cwd') transport.disconnect() }) @@ -99,7 +100,7 @@ describe('createIpcPtyTransport — Pi titlebar spinner signal', () => { await flushPtySideEffects() const calls = onTitleChange.mock.calls.map((c) => c[0]) - expect(calls).toContain('⠋ Pi') + expect(calls).toContain('⠋ π - cwd') transport.disconnect() }) @@ -112,7 +113,7 @@ describe('createIpcPtyTransport — Pi titlebar spinner signal', () => { // last working frame), the working frames in between must not be the only // signal the transport ever saw. The important guarantee is that during // an actual working period (before agent_end) at least one chunk's last - // title is a working frame, and that produces a "⠋ Pi" onTitleChange. + // title is a working frame, and that produces a "⠋ π - cwd" onTitleChange. const { createIpcPtyTransport } = await import('./pty-transport') const onTitleChange = vi.fn() @@ -124,7 +125,7 @@ describe('createIpcPtyTransport — Pi titlebar spinner signal', () => { await flushPtySideEffects() const calls = onTitleChange.mock.calls.map((c) => c[0]) - expect(calls).toContain('⠋ Pi') + expect(calls).toContain('⠋ π - cwd') transport.disconnect() }) diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 9b614ca642d..8bd1314d577 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -395,25 +395,27 @@ describe('normalizeTerminalTitle', () => { ) }) - it('collapses Pi spinner and idle titles to stable labels', () => { - expect(normalizeTerminalTitle('⠋ π - my-project')).toBe('⠋ Pi') - expect(normalizeTerminalTitle('π - my-project')).toBe('Pi') - expect(normalizeTerminalTitle('⠋ π: my-project')).toBe('⠋ Pi') - expect(normalizeTerminalTitle('π: my-project')).toBe('Pi') - expect(normalizeTerminalTitle('π -')).toBe('Pi') - expect(normalizeTerminalTitle('π:')).toBe('Pi') - expect(normalizeTerminalTitle('π ')).toBe('Pi') + it('preserves Pi titles and canonicalizes only the spinner frame', () => { + // Only the braille frame churns; the rest is the session name and cwd (#16093). + expect(normalizeTerminalTitle('⠋ π - my-project')).toBe('⠋ π - my-project') + expect(normalizeTerminalTitle('⠙ π - my-project')).toBe('⠋ π - my-project') + expect(normalizeTerminalTitle('π - my-project')).toBe('π - my-project') + expect(normalizeTerminalTitle('⠋ π: my-project')).toBe('⠋ π: my-project') + expect(normalizeTerminalTitle('π: my-project')).toBe('π: my-project') + expect(normalizeTerminalTitle('π -')).toBe('π -') + expect(normalizeTerminalTitle('π:')).toBe('π:') + expect(normalizeTerminalTitle('π ')).toBe('π ') }) - it('does not collapse Pi-compatible titles whose cwd mentions Gemini', () => { - expect(normalizeTerminalTitle('⠋ π - gemini')).toBe('⠋ Pi') - expect(normalizeTerminalTitle('π - gemini')).toBe('Pi') - expect(normalizeTerminalTitle('⠋ π: gemini')).toBe('⠋ Pi') - expect(normalizeTerminalTitle('π: gemini')).toBe('Pi') - expect(normalizeTerminalTitle('⠋ π gemini')).toBe('⠋ Pi') - expect(normalizeTerminalTitle('π gemini')).toBe('Pi') - expect(normalizeTerminalTitle('⠋ π - gemini-project')).toBe('⠋ Pi') - expect(normalizeTerminalTitle('π - gemini-project')).toBe('Pi') + it('does not route Pi titles whose cwd mentions Gemini into Gemini normalization', () => { + expect(normalizeTerminalTitle('⠋ π - gemini')).toBe('⠋ π - gemini') + expect(normalizeTerminalTitle('π - gemini')).toBe('π - gemini') + expect(normalizeTerminalTitle('⠋ π: gemini')).toBe('⠋ π: gemini') + expect(normalizeTerminalTitle('π: gemini')).toBe('π: gemini') + expect(normalizeTerminalTitle('⠋ π gemini')).toBe('⠋ π gemini') + expect(normalizeTerminalTitle('π gemini')).toBe('π gemini') + expect(normalizeTerminalTitle('⠋ π - gemini-project')).toBe('⠋ π - gemini-project') + expect(normalizeTerminalTitle('π - gemini-project')).toBe('π - gemini-project') }) }) diff --git a/src/renderer/src/store/slices/repro-omp-pi-alternating-title-frame-churn.test.ts b/src/renderer/src/store/slices/repro-omp-pi-alternating-title-frame-churn.test.ts deleted file mode 100644 index 0fdfb3fb157..00000000000 --- a/src/renderer/src/store/slices/repro-omp-pi-alternating-title-frame-churn.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * OMP tab-title churn repro: an OMP-owned terminal tab's title text oscillates - * between "OMP" and "Pi" at spinner cadence, committing a fresh store patch on - * every frame. - * - * OMP wraps Pi, so TWO independent writers publish OSC title frames into the - * same pane, and both are "correct" from their own vantage point: - * - * A) main's synthetic title spinner. `driveSyntheticTitleFromHook` starts a - * shared 80ms interval (src/main/index.ts:1947 SPINNER_FRAMES, :1948 - * SPINNER_INTERVAL_MS) that injects `\x1b]0; \x07` - * (:2170, :2205). For an OMP-owned pane the profile is `omp`, so every tick - * asserts " OMP". - * B) the OMP process itself, which is Pi underneath and emits Pi's own frames. - * "⠋ Pi" passes through untouched, and legacy "π …" frames are collapsed to - * a HARDCODED "Pi" by `normalizeTerminalTitle` - * (src/shared/agent-title-status.ts:135-144) — that collapse is - * identity-blind, so an OMP-owned pane still yields the literal "Pi". - * - * `pi` and `omp` share `titleIdentityGroup: 'pi-compatible'` - * (src/shared/synthetic-agent-title.ts:45-56), so these are the SAME identity as - * far as ownership is concerned — but neither writer is normalized to the tab's - * launch owner before it reaches the store. - * - * The churn suppressor that normally absorbs spinner noise is defeated here. - * `isDecorativeAgentTitleFrameChange` keys on `status:textWithoutSpinner` - * (src/shared/agent-decorative-title-signature.ts:17): "⠋ OMP" -> `working:OMP`, - * "⠙ Pi" -> `working:Pi`. The signatures differ, so every alternating frame is - * classified as a MEANINGFUL change and commits — through - * `applyTerminalTabTitleUpdates` - * (src/renderer/src/store/slices/terminal-tab-title-batch.ts:183) for `tab.title` - * (the string `resolveTerminalTabTitle` renders in the tab bar) and through - * `setRuntimePaneTitle` - * (src/renderer/src/store/terminals/terminal-tab-presentation.ts:145-172) for the - * pane slot. At 80ms that is ~12 committed patches per second, per working OMP - * tab, with a visibly flickering label. - * - * The oracle is commit COUNT, not exact text: the status never leaves 'working' - * across the sequence, so a correctly owner-pinned title settles after the first - * frame and every later frame is decoration. Both tests drive the REAL store - * actions over the REAL frame text main emits. - */ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync: vi.fn() -})) -vi.mock('@/components/terminal-pane/pty-transport', () => ({ - registerEagerPtyBuffer: vi.fn(), - ensurePtyDispatcher: vi.fn(), - unregisterPtyDataHandlers: vi.fn() -})) -vi.mock('@/components/terminal-pane/shutdown-buffer-captures', () => ({ - shutdownBufferCaptures: vi.fn() -})) - -// @ts-expect-error -- minimal preload API stub for the slice's IPC writes -globalThis.window = { api: {} } - -import { getPiCompatibleSyntheticAgentLabel } from '../../../../shared/pi-compatible-synthetic-title' -import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' -import { - createTestStore, - makeTab, - makeUnifiedTab, - makeWorktree, - seedStore -} from './store-test-helpers' - -const WT = 'wt-omp' -const TAB_ID = 'tab-omp-1' -const GROUP_ID = 'group-1' -const PANE_ID = 1 - -// Verbatim from src/main/index.ts:1947. -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] - -/** - * 20 frames of one OMP turn: main's synthetic "OMP" tick interleaved with the - * wrapped Pi harness's own frame. Every frame classifies as 'working', so the - * ONLY thing changing is which of the two pi-compatible identities is showing. - */ -const OMP_TURN_TITLE_FRAMES = Array.from({ length: 20 }, (_, index) => { - const spinner = SPINNER_FRAMES[index % SPINNER_FRAMES.length] - return index % 2 === 0 ? `${spinner} OMP` : `${spinner} Pi` -}) - -function seedOmpTab(store: ReturnType): void { - seedStore(store, { - worktreesByRepo: { - repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt-omp' })] - }, - tabsByWorktree: { - // The user launched OMP into this tab — the identity the title must follow. - [WT]: [makeTab({ id: TAB_ID, worktreeId: WT, title: 'Terminal 1', launchAgent: 'omp' })] - }, - unifiedTabsByWorktree: { - [WT]: [makeUnifiedTab({ id: TAB_ID, worktreeId: WT, groupId: GROUP_ID })] - }, - activeWorktreeId: WT - }) -} - -/** The identity a tab-bar frame reads as: 'OMP', 'Pi', or null when neither. */ -function displayedAgentIdentity(tabTitle: string): string | null { - return getPiCompatibleSyntheticAgentLabel( - resolveTerminalTabTitle({ title: tabTitle, customTitle: null }, false, 'Terminal 1') - ) -} - -describe('OMP-owned tab title across interleaved OMP/Pi spinner frames', () => { - it('commits at most one tab-title patch for the whole working turn', () => { - const store = createTestStore() - seedOmpTab(store) - - // Zustand only notifies when the action returns a fresh patch, so one - // listener call === one committed store patch. - let commits = 0 - const seenTitles: string[] = [] - const unsubscribe = store.subscribe((state) => { - commits += 1 - const title = state.tabsByWorktree[WT]?.[0]?.title - if (title && title !== seenTitles.at(-1)) { - seenTitles.push(title) - } - }) - - for (const frame of OMP_TURN_TITLE_FRAMES) { - store.getState().updateTabTitle(TAB_ID, frame) - } - unsubscribe() - - // One commit takes the tab off its "Terminal 1" default; the remaining 19 - // frames are pure decoration under the OMP owner. - expect(commits).toBeLessThanOrEqual(1) - expect(Array.from(new Set(seenTitles.map(displayedAgentIdentity)))).toEqual(['OMP']) - }) - - it('commits at most one runtime pane-title patch for the whole working turn', () => { - const store = createTestStore() - seedOmpTab(store) - - let commits = 0 - const unsubscribe = store.subscribe(() => { - commits += 1 - }) - - for (const frame of OMP_TURN_TITLE_FRAMES) { - store.getState().setRuntimePaneTitle(TAB_ID, PANE_ID, frame) - } - unsubscribe() - - expect(commits).toBeLessThanOrEqual(1) - expect( - getPiCompatibleSyntheticAgentLabel( - store.getState().runtimePaneTitlesByTabId[TAB_ID]?.[PANE_ID] ?? '' - ) - ).toBe('OMP') - }) - - // Why: relabeling is for frames naming a DIFFERENT group member. A frame that already names the - // owner carries its own status wording, so restating bare "OMP" as "OMP ready" would change a - // tab that never flapped — and the same guard keeps a plain Pi-owned tab byte-identical. - it.each([ - ['omp', 'OMP'], - ['pi', 'Pi'] - ] as const)('leaves a %s-owned tab’s own identity frames untouched', (launchAgent, label) => { - const store = createTestStore() - seedStore(store, { - worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt-omp' })] }, - tabsByWorktree: { - [WT]: [makeTab({ id: TAB_ID, worktreeId: WT, title: 'Terminal 1', launchAgent })] - }, - unifiedTabsByWorktree: { - [WT]: [makeUnifiedTab({ id: TAB_ID, worktreeId: WT, groupId: GROUP_ID })] - }, - activeWorktreeId: WT - }) - - for (const frame of [label, `${label} ready`, `⠋ ${label}`]) { - store.getState().setRuntimePaneTitle(TAB_ID, PANE_ID, frame) - expect(store.getState().runtimePaneTitlesByTabId[TAB_ID]?.[PANE_ID]).toBe(frame) - } - }) - - // Why: owner-pinning must stay scoped to bare identity frames. A semantic session title carries - // text no agent profile can reproduce, so relabeling it to "OMP ready" would lose real - // information — the complaint in #16093, which this fix must not reintroduce. - it('leaves a semantic session title untouched', () => { - const store = createTestStore() - seedOmpTab(store) - - const semanticTitle = 'π - fixing the sidebar - orca' - store.getState().updateTabTitle(TAB_ID, semanticTitle) - store.getState().setRuntimePaneTitle(TAB_ID, PANE_ID, semanticTitle) - - expect(store.getState().tabsByWorktree[WT]?.[0]?.title).toBe(semanticTitle) - expect(store.getState().runtimePaneTitlesByTabId[TAB_ID]?.[PANE_ID]).toBe(semanticTitle) - }) -}) diff --git a/src/renderer/src/store/slices/terminal-tab-title-batch.ts b/src/renderer/src/store/slices/terminal-tab-title-batch.ts index bc49c3affe6..d0456541af3 100644 --- a/src/renderer/src/store/slices/terminal-tab-title-batch.ts +++ b/src/renderer/src/store/slices/terminal-tab-title-batch.ts @@ -1,6 +1,5 @@ import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title' import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' -import { relabelCompatibleAgentIdentityFrameForOwner } from '../../../../shared/agent-title-owner' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' @@ -180,10 +179,7 @@ export function applyTerminalTabTitleUpdates( if (!currentTab || !tabIndexes) { continue } - // Why: a wrapped harness emits the inner agent's identity (OMP wraps Pi), so pin those frames - // to the launch owner — otherwise the alternating label defeats the decorative check below. - const ownedTitle = relabelCompatibleAgentIdentityFrameForOwner(title, currentTab.launchAgent) - const nextTitle = ownedTitle.trim() || getFallbackTabTitle(currentTab) + const nextTitle = title.trim() || getFallbackTabTitle(currentTab) if (isDecorativeAgentTitleFrameChange(currentTab.title, nextTitle)) { updateStageUnifiedLabel(stage, tabId, 'label', currentTab.title) continue diff --git a/src/renderer/src/store/terminals/terminal-tab-presentation.ts b/src/renderer/src/store/terminals/terminal-tab-presentation.ts index 7ce896dcf7b..7e1d2f64442 100644 --- a/src/renderer/src/store/terminals/terminal-tab-presentation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-presentation.ts @@ -1,6 +1,4 @@ import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' -import { relabelCompatibleAgentIdentityFrameForOwner } from '../../../../shared/agent-title-owner' -import { getPiCompatibleSyntheticAgentLabel } from '../../../../shared/pi-compatible-synthetic-title' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { @@ -12,21 +10,8 @@ import { getTerminalTabOwnerWorktreeId } from '../slices/terminal-tab-owner-index' import { getTabIdFromPaneKey } from './terminal-pty-identities' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { AppState } from '../types' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -function getTabLaunchAgent( - state: Pick, - tabId: string -): TuiAgent | undefined { - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) - if (!ownerWorktreeId) { - return undefined - } - return state.tabsByWorktree[ownerWorktreeId]?.find((tab) => tab.id === tabId)?.launchAgent -} - export function createTerminalTabPresentationActions( set: TerminalStoreSet, get: TerminalStoreGet @@ -157,13 +142,8 @@ export function createTerminalTabPresentationActions( return { tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: nextTabs } } }) }, - setRuntimePaneTitle: (tabId, paneId, rawTitle) => { + setRuntimePaneTitle: (tabId, paneId, title) => { set((s) => { - // Why: same-group frames (OMP wraps Pi) must read as the launch owner or the alternating - // label defeats the decorative check below; the owner lookup stays off the common path. - const title = getPiCompatibleSyntheticAgentLabel(rawTitle) - ? relabelCompatibleAgentIdentityFrameForOwner(rawTitle, getTabLaunchAgent(s, tabId)) - : rawTitle const currentByPane = s.runtimePaneTitlesByTabId[tabId] ?? {} const prevTitle = currentByPane[paneId] if (prevTitle === title) { diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index 14ee33eb7a8..32fc1bd2649 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -158,7 +158,7 @@ describe('Pi-compatible title detection', () => { ['OMP - action required', 'OMP', 'permission'], ['\u280b Pi', 'Pi', 'working'], ['Pi ready', 'Pi', 'idle'], - // Why: normalizeTerminalTitle collapses idle π frames to bare "Pi"; re-detection + // Why: titles stored by the old collapse are still bare "Pi"; re-detection // from stored lastOscTitle must still classify idle, not neutral. ['Pi', 'Pi', 'idle'], ['Pi - action required', 'Pi', 'permission'] @@ -168,7 +168,8 @@ describe('Pi-compatible title detection', () => { }) it('re-detects status after display-title normalization for Pi idle frames', () => { - expect(normalizeTerminalTitle('π - my-project')).toBe('Pi') + // Normalization now preserves the session name and cwd (#16093). + expect(normalizeTerminalTitle('π - my-project')).toBe('π - my-project') expect(detectAgentStatusFromTitle(normalizeTerminalTitle('π - my-project'))).toBe('idle') expect(detectAgentStatusFromTitle(normalizeTerminalTitle('\u280b π - my-project'))).toBe( 'working' @@ -179,18 +180,28 @@ describe('Pi-compatible title detection', () => { ['\u280b Pi', 'omp', '\u280b OMP'], ['Pi ready', 'omp', 'OMP ready'], ['Pi - action required', 'omp', 'OMP - action required'], - ['π - tmp', 'omp', 'OMP ready'], - ['π: tmp', 'omp', 'OMP ready'], - ['\u280b π: tmp', 'omp', '\u280b OMP'], - ['\u280b π - tmp', 'omp', '\u280b OMP'], + // Why: the brand is swapped for the owner's label in place — the pane still reads as its + // launch owner, but the session name and cwd it chose survive (#16093). + ['π - tmp', 'omp', 'OMP - tmp'], + ['π: tmp', 'omp', 'OMP: tmp'], + ['\u280b π: tmp', 'omp', '\u280b OMP: tmp'], + ['\u280b π - tmp', 'omp', '\u280b OMP - tmp'], ['\u280b OMP', 'pi', '\u280b Pi'], - ['lucky-echidna | \u283c π - Diagnose Orca terminal title flicker - test', 'omp', '\u280b OMP'], + [ + 'lucky-echidna | \u283c π - Diagnose Orca terminal title flicker - test', + 'omp', + 'lucky-echidna | \u283c OMP - Diagnose Orca terminal title flicker - test' + ], ['lucky-echidna | Pi ready', 'omp', 'OMP ready'], ['Codex | Pi ready', 'omp', 'OMP ready'], // Why: the wrapped whole reads as a braille Claude title, but the re-ownable // synthetic pane suffix must still win. ['lucky-echidna | ⠋ OMP', 'omp', '⠋ OMP'], - ['lucky-echidna | \u283c π - Diagnose | test', 'omp', '\u280b OMP'] + [ + 'lucky-echidna | \u283c π - Diagnose | test', + 'omp', + 'lucky-echidna | \u283c OMP - Diagnose | test' + ] ] as const)('normalizes %s to the authoritative %s owner', (title, owner, expectedTitle) => { expect(normalizeCompatibleAgentTitleForOwner(title, owner)).toBe(expectedTitle) }) diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index edf1f945527..dfa966669ea 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -5,12 +5,12 @@ import { SYNTHETIC_AGENT_TITLE_PROFILES, type SyntheticAgentTitleProfile } from './synthetic-agent-title' -import { - getPiCompatibleSyntheticAgentLabel, - isLegacyPiCompatibleTitle -} from './pi-compatible-synthetic-title' +import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' +/** The π brand a Pi/OMP title leads with; the owner's label replaces it in place. */ +const LEGACY_PI_BRAND = 'π' + type TitleProfileMatch = { profile: SyntheticAgentTitleProfile sourceTitle: string @@ -150,6 +150,19 @@ export function normalizeCompatibleAgentTitleForOwner( ) { return title } + // Why: a π-branded title is the agent's own semantic session title (`π > - `; + // Orca's injected extension writes the same shape). Swap only the BRAND for the owner's label + // so the pane still reads as its launch owner (#6689, #7633, #9077) without discarding the + // session name and cwd, which collapsing to a bare profile label threw away (#16093). + if (isLegacyPiCompatibleTitle(source.sourceTitle)) { + // Why scoped to the matched segment: a multiplexer prefix could itself contain the brand, + // and a whole-string replace would rewrite that instead of the pane's own identity. Note the + // scoping is only as good as the segment match — a prefix that itself parses as a π title + // makes the whole string the match, and then the prefix's brand is what gets swapped. + const ownedSegment = source.sourceTitle.replace(LEGACY_PI_BRAND, ownerProfile.workingLabel) + const segmentAt = title.lastIndexOf(source.sourceTitle) + return segmentAt === -1 ? ownedSegment : title.slice(0, segmentAt) + ownedSegment + } const sourceStatus = getSourceTitleStatus(source.sourceTitle) if (sourceStatus === 'working') { return `\u280b ${ownerProfile.workingLabel}` @@ -169,31 +182,6 @@ export function normalizeCompatibleAgentTitleForOwner( return ownerProfile.workingLabel } -/** - * Relabels only a bare identity frame ("⠋ Pi", "Pi ready") to the owner's label. - * - * Why: a wrapped harness publishes the inner agent's identity (OMP wraps Pi), so those frames - * alternate against the owner's own and defeat decorative-frame suppression at the store. A - * semantic session title ("π - - ") is left alone — it carries text no owner - * profile can reproduce, so rewriting it to a generic label would lose real information. - */ -export function relabelCompatibleAgentIdentityFrameForOwner( - title: string, - ownerAgentType: AgentType | null | undefined -): string { - const frameLabel = getPiCompatibleSyntheticAgentLabel(title) - if (!frameLabel) { - return title - } - // Why: the frame already names the owner, so its own status wording is authoritative — rewriting - // it would restate bare "Pi" as "Pi ready" and change a Pi-owned tab that never flapped. - const ownerProfile = getSyntheticAgentTitleProfile(ownerAgentType) - if (ownerProfile?.workingLabel.toLowerCase() === frameLabel.toLowerCase()) { - return title - } - return normalizeCompatibleAgentTitleForOwner(title, ownerAgentType) -} - /** * Why: mirrored remote status entries must keep the owner and title in sync * or later snapshots repaint the same tab under the wrapper agent. diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 5c13bb56e95..82cf13bf408 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -25,7 +25,11 @@ import { } from './agent-title-core' import type { AgentStatus } from './agent-title-core' import { isOpenCodeNativeTitle } from './opencode-terminal-title' -import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' +import { + getPiCompatibleTitleSeparatorStatus, + getPiCompatibleSyntheticAgentStatus +} from './pi-compatible-synthetic-title' +import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' /** @@ -132,15 +136,14 @@ export function normalizeTerminalTitle(title: string): string { } } - // Why: Pi animates every 80ms; collapse frames while preserving status. - if (isPiAgentTitle(title)) { - const status = detectAgentStatusFromTitle(title) - if (status === 'working') { - return '\u280b Pi' - } - if (status === 'idle') { - return 'Pi' - } + // Why: Pi/OMP animate a braille frame every 80ms, so the frame is the churn — but the rest of + // the title is the session name and cwd the agent chose. Canonicalize the frame in place + // (it leads in `⠋ π - session - cwd` and sits medially in `π ⠋ label`) and keep everything + // else; collapsing to a bare "Pi" discarded both the identity and the label (#16093). + // Why segments: a multiplexer prefixes the pane title (`zsh | ⠋ π - …`), and an anchored + // match would skip the canonicalization and let the frame churn through (#8032). + if (getWrapperTitleSegments(title).some(isPiAgentTitle)) { + return canonicalizeBrailleSpinnerFrame(title) } // Why: Grok Build interpolates a rotating status/tool phrase between the @@ -154,6 +157,17 @@ export function normalizeTerminalTitle(title: string): string { return title } +/** Why: any braille frame reads as the same animation step, so consecutive frames dedupe. */ +function canonicalizeBrailleSpinnerFrame(title: string): string { + let canonical = '' + for (const char of title) { + const codePoint = char.codePointAt(0) + canonical += + codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff ? '\u280b' : char + } + return canonical +} + export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (!title || isClaudeManagementTitle(title)) { return null @@ -186,6 +200,12 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { return 'idle' } + // Why: read the state separator before the blanket idle below — `π !