fix(terminal): stop Orca mangling the OMP/Pi title it writes itself (#16381)

* fix(terminal): collapse identity group in the title churn signature

Replaces the ingest-time title rewrite from #16373 with a non-destructive
fix at the actual cause.

The churn suppressor `isDecorativeAgentTitleFrameChange` keyed on the
literal label, so `working:OMP` and `working:Pi` compared unequal and every
alternating frame from a wrapped harness committed a store patch. #16373
made the labels agree by rewriting the stored title to the tab's launch
owner — but `runtimePaneTitlesByTabId` is also the Windows Shift+Enter
byte-encoding input, so normalizing at ingest destroyed evidence other
consumers read (fixed separately in #16376).

Collapse the identity group inside the signature instead. Which member of
a group a frame names is decoration, exactly like the spinner glyph the
signature already strips, so frames compare equal without touching what is
stored. Suppression now changes only WHETHER a frame commits, never WHAT
it says.

Also fixes the flap under a multiplexer (#8032): the collapse runs over
wrapper segments, so "zsh | ⠋ Pi" and "zsh | ⠙ OMP" compare equal, which
the anchored owner-relabel in #16373 never matched.

Reverts the store changes from #16373 and drops the helper it added.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* fix(terminal): fold only bare identity frames into the group token

A legacy "π - <session> - <cwd>" title is Pi-compatible too, so folding
every profile match collapsed two different sessions to the same signature
and suppressed the change outright — reintroducing #16093 through the
churn signature.

Fold only exact bare identity frames, matched per wrapper segment, so
semantic session titles keep comparing on their own text.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* docs(terminal): correct the flap diagnosis in the repro header

Verified against the OMP source: it emits only π-glyph frames
(`DEFAULT_TERMINAL_TITLE = "π"`, title-generator.ts:25), and on an
Orca-hosted pane its native titler cedes to Orca's own injected extension,
which writes `⠋ π - <session> - <cwd>`.

So OMP emits neither "OMP" nor "Pi". Both flap sides are Orca's:
"OMP" from driveSyntheticTitleFromHook, "Pi" from normalizeTerminalTitle
collapsing our own extension's output to a hardcoded literal.

The prior header credited the wrapped harness for frames it never sends,
which is the same wrong narrative that produced eight fixes at eight
layers. No behavior change.

* fix(terminal): stop Orca mangling the OMP/Pi title it writes itself

Verified against the OMP source: it emits only π-branded frames
(`DEFAULT_TERMINAL_TITLE = "π"`, title-generator.ts:25), and on an
Orca-hosted pane its native titler cedes to Orca's OWN injected extension,
which writes `π - <session> - <cwd>` / `⠋ π - <session> - <cwd>` at 80ms.

So neither flapping string came from OMP. Orca made both:
  "Pi"  — normalizeTerminalTitle collapsing our extension's output to a
          hardcoded literal, discarding the session name and cwd (#16093)
  "OMP" — driveSyntheticTitleFromHook injecting over it every 80ms

Fixed at the source:
- normalizeTerminalTitle canonicalizes only the rotating braille frame and
  keeps the rest, in both spinner positions and through a multiplexer
  prefix (#8032). Status still round-trips through normalization.
- detectAgentStatusFromTitle reads the π state separator, so `π ! <label>`
  is permission instead of the blanket idle that hid a blocked agent.
- normalizeCompatibleAgentTitleForOwner swaps only the brand for the
  owner's label, so a pane still reads as its launch owner (#6689, #7633,
  #9077) without losing the session text.
- pi/omp set synthesizeWorkingTitle: false — the agent animates its own
  working title. Terminal states still synthesize; they carry the pane's
  agent identity downstream.

Reverts the ingest-time title rewrite from #16373, whose normalization of
runtimePaneTitlesByTabId also changed Windows Shift+Enter bytes (#16376).

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* fix(terminal): match the state separator only in exact profile casing

The separator check runs on every title, so `omp - deploy notes` and
`pi - refactor the parser` read as an idle agent. The owner rewrite only
ever emits the exact profile labels, so dropping case-insensitivity keeps
`OMP - tmp` classifying while ordinary prose stops matching.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* test(terminal): pin one real OMP turn to two committed patches

Drives 30 working frames as Orca's injected extension emits them plus the
idle transition, and asserts what survives the churn gate. Before the fix
every frame alternated "⠋ Pi"/"⠋ OMP" and each one committed — ~12 store
patches per second on a working tab.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* fix(terminal): carry the permission guard inside the separator reader

`-` is both a π state separator and the delimiter in the synthetic
permission label, so `OMP - action required` read as idle. It resolved
correctly only because detectAgentStatusFromTitle happens to check the
synthetic label first — and the separator fn is exported, so a direct
caller inherited the bug.

Also pins the owner rewrite's fixed-point property, which holds only
because getAgentLabel does not tokenize omp/pi, and corrects a comment
that overstated how tightly the brand swap is scoped.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* docs(terminal): name the flag the code actually sets

The suite header cited `synthesizeTerminalTitle: false`; the profiles set
`synthesizeWorkingTitle: false`. The distinction is the whole reason the
narrower flag was chosen — terminal-state frames still carry the pane's
agent identity downstream — so the wrong name buried the rationale.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

---------

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>
This commit is contained in:
Neil
2026-08-25 02:28:08 -07:00
committed by GitHub
co-authored by Seongho.Bak
parent 33587fb77d
commit fcf55f2d68
18 changed files with 420 additions and 326 deletions
+4 -4
View File
@@ -20020,9 +20020,9 @@ describe('OrcaRuntimeService', () => {
ptysById: Map<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
}
).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')
@@ -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']
])
})
@@ -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)
@@ -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)
@@ -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()
})
@@ -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()
})
+19 -17
View File
@@ -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')
})
})
@@ -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;<frame> <profile.workingLabel>\x07`
* (:2170, :2205). For an OMP-owned pane the profile is `omp`, so every tick
* asserts "<spinner> 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<typeof createTestStore>): 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 tabs 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)
})
})
@@ -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
@@ -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<AppState, 'tabsByWorktree'>,
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) {
+19 -8
View File
@@ -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)
})
+17 -29
View File
@@ -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 (`π > <session> - <cwd>`;
// 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 ("π - <session> - <cwd>") 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.
+30 -10
View File
@@ -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 — `π ! <label>` is a
// blocked agent, and treating it as idle hides an OMP pane waiting on the user.
const piCompatibleSeparatorStatus = getPiCompatibleTitleSeparatorStatus(title)
if (piCompatibleSeparatorStatus) {
return piCompatibleSeparatorStatus
}
if (isPiTerminalTitle(title)) {
return 'idle'
}
@@ -0,0 +1,253 @@
/**
* OMP/Pi terminal titles: preserve the session label, collapse only the animation.
*
* The reported symptom was an OMP tab title flickering between "OMP" and "Pi" ~12x/sec.
* OMP emits NEITHER string. Verified against oh-my-pi
* (packages/coding-agent/src/utils/title-generator.ts): `DEFAULT_TERMINAL_TITLE = "π"` (:25)
* and `buildTerminalTitleWithState` (:530-544) compose `π ⠋ <label>` / `π > <label>` /
* `π ! <label>` — always the π glyph. On an Orca-hosted pane OMP's native titler cedes
* entirely to Orca's OWN injected extension (src/main/pi/titlebar-extension-source.ts:21,44),
* which writes `π - <session> - <cwd>` and `⠋ π - <session> - <cwd>` every 80ms.
*
* Both flapping strings were manufactured by Orca:
* "OMP" — `driveSyntheticTitleFromHook` (src/main/index.ts), from the omp profile's label.
* "Pi" — `normalizeTerminalTitle` collapsing our own extension's output to a hardcoded
* literal, discarding the session name and cwd along with it (#16093).
*
* The fix removes both: the agent owns the title it animates (`synthesizeWorkingTitle: false`
* — not the stronger `synthesizeTerminalTitle: false`, because terminal-state frames still
* carry the pane's agent identity downstream), and the normalizer canonicalizes only the
* rotating braille frame so consecutive frames dedupe while the label survives.
*/
import { describe, expect, it } from 'vitest'
import { detectAgentStatusFromTitle, normalizeTerminalTitle } from './agent-title-status'
import { isDecorativeAgentTitleFrameChange } from './agent-decorative-title-signature'
import {
getSyntheticAgentTerminalTitle,
shouldDriveSyntheticAgentTitleFromHook
} from './synthetic-agent-title'
import { normalizeCompatibleAgentTitleForOwner } from './agent-title-owner'
import { getPiCompatibleTitleSeparatorStatus } from './pi-compatible-synthetic-title'
// Verbatim from src/main/pi/titlebar-extension-source.ts:44 and oh-my-pi:530-544.
const ORCA_EXTENSION_WORKING = (frame: string): string => `${frame} π - fixing the sidebar - orca`
const OMP_NATIVE_WORKING = (frame: string): string => `π ${frame} fixing the sidebar`
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
describe('normalizeTerminalTitle keeps the OMP/Pi session label', () => {
it.each([
['Orca extension (spinner leads)', ORCA_EXTENSION_WORKING],
['OMP native (spinner is medial)', OMP_NATIVE_WORKING]
])('collapses every %s frame to one value without losing the label', (_name, build) => {
const normalized = new Set(FRAMES.map((frame) => normalizeTerminalTitle(build(frame))))
expect(normalized.size).toBe(1)
const [only] = [...normalized]
expect(only).toContain('fixing the sidebar')
expect(only).toContain('π')
// The bare labels the old collapse minted must be gone entirely.
expect(only).not.toBe('⠋ Pi')
expect(only).not.toBe('Pi')
})
it('leaves idle and attention titles byte-identical', () => {
for (const title of [
'π - fixing the sidebar - orca',
'π > fixing the sidebar',
'π ! fixing the sidebar',
'π: fixing the sidebar'
]) {
expect(normalizeTerminalTitle(title)).toBe(title)
}
})
// Why: a multiplexer prefixes the pane's title, so an anchored match skipped the collapse
// and the frame churned straight through (#8032).
it('collapses frames under a multiplexer prefix', () => {
const normalized = new Set(
FRAMES.map((frame) => normalizeTerminalTitle(`zsh | ${ORCA_EXTENSION_WORKING(frame)}`))
)
expect(normalized.size).toBe(1)
expect([...normalized][0]).toContain('fixing the sidebar')
})
it('keeps two different sessions distinguishable', () => {
expect(normalizeTerminalTitle('π - session A - orca')).not.toBe(
normalizeTerminalTitle('π - session B - orca')
)
})
})
describe('detectAgentStatusFromTitle reads the π state separator', () => {
it.each([
['π ! fixing the sidebar', 'permission'],
['π > fixing the sidebar', 'idle'],
['π ⠋ fixing the sidebar', 'working'],
['⠋ π - fixing the sidebar - orca', 'working'],
['π: fixing the sidebar', 'idle']
])('classifies %s as %s', (title, expected) => {
expect(detectAgentStatusFromTitle(title)).toBe(expected)
})
})
describe('the churn is gone at the suppressor', () => {
it('treats consecutive animation frames as decoration', () => {
for (const build of [ORCA_EXTENSION_WORKING, OMP_NATIVE_WORKING]) {
for (let index = 1; index < FRAMES.length; index += 1) {
expect(
isDecorativeAgentTitleFrameChange(
normalizeTerminalTitle(build(FRAMES[index - 1])),
normalizeTerminalTitle(build(FRAMES[index]))
)
).toBe(true)
}
}
})
it('still commits a real working -> attention transition', () => {
expect(
isDecorativeAgentTitleFrameChange(
normalizeTerminalTitle(ORCA_EXTENSION_WORKING('⠋')),
normalizeTerminalTitle('π ! fixing the sidebar')
)
).toBe(false)
})
})
describe('Orca stops writing over the working title it does not own', () => {
// Why: the agent animates its own working title, so synthesizing there both replaced the
// session label and fought its frames at 80ms — that pair is the flap.
it.each(['omp', 'pi'] as const)('synthesizes no working title for %s', (agent) => {
expect(shouldDriveSyntheticAgentTitleFromHook(agent, 'working')).toBe(false)
})
// Why NOT terminal states: the synthetic frame is also how the pane's agent identity reaches
// downstream consumers, and both agents are quiet at rest so nothing churns.
it.each(['omp', 'pi'] as const)('still synthesizes terminal states for %s', (agent) => {
for (const state of ['done', 'waiting', 'blocked'] as const) {
expect(getSyntheticAgentTerminalTitle(agent, state)).not.toBeNull()
expect(shouldDriveSyntheticAgentTitleFromHook(agent, state)).toBe(true)
}
})
it('leaves agents that own no title alone', () => {
expect(getSyntheticAgentTerminalTitle('droid', 'done')).toBe('Droid ready')
expect(shouldDriveSyntheticAgentTitleFromHook('droid', 'working')).toBe(true)
})
})
describe('the owner relabel keeps the label and swaps only the brand', () => {
// Why: this runs on the pane display title, tab title, sidebar rows and the mobile publish.
// Collapsing a semantic title to a bare profile label re-destroyed the session name at display
// time (#16093); keeping the brand as π would lose the explicit owner identity that #6689,
// #7633 and #9077 established. Swapping the brand in place satisfies both.
it.each([
['⠋ π - fixing the sidebar - orca', '⠋ OMP - fixing the sidebar - orca'],
['π - fixing the sidebar - orca', 'OMP - fixing the sidebar - orca'],
['π ! fixing the sidebar', 'OMP ! fixing the sidebar'],
['π > fixing the sidebar', 'OMP > fixing the sidebar']
])('rewrites %s to %s for an omp-owned pane', (title, expected) => {
expect(normalizeCompatibleAgentTitleForOwner(title, 'omp')).toBe(expected)
})
// Why: the owner rewrite feeds status classification downstream, so a rewritten title must
// classify exactly as its source did — that round-trip is why the old collapse existed.
it.each([
'⠋ π - fixing the sidebar - orca',
'π - fixing the sidebar - orca',
'π ! fixing the sidebar',
'π > fixing the sidebar',
'⠋ Pi',
'Pi ready',
'Pi - action required'
])('preserves the status of %s across the owner rewrite', (title) => {
expect(detectAgentStatusFromTitle(normalizeCompatibleAgentTitleForOwner(title, 'omp'))).toBe(
detectAgentStatusFromTitle(title)
)
})
// Why keep: a bare frame carries no session text, and persisted pre-upgrade values are bare.
// Re-owning them is what pins an OMP pane to OMP (#6689, #7633, #9077).
it('still re-owns bare identity frames', () => {
expect(normalizeCompatibleAgentTitleForOwner('⠋ Pi', 'omp')).toBe('⠋ OMP')
expect(normalizeCompatibleAgentTitleForOwner('Pi ready', 'omp')).toBe('OMP ready')
expect(normalizeCompatibleAgentTitleForOwner('⠋ OMP', 'pi')).toBe('⠋ Pi')
})
})
describe('the state separator does not fire on ordinary titles', () => {
// Why: the separator check runs on every title, so a project named `omp-harness` or a task
// description starting "pi - …" must not read as an agent state. The owner rewrite only ever
// emits the exact profile casing, so lowercase prose is safe to reject.
it.each([
'omp-harness ready',
'pi-scratch ready',
'pipeline - build',
'pip - install',
'pi - refactor the parser',
'omp - deploy notes',
'npm - run build',
'node - server'
])('classifies %s as no agent', (title) => {
expect(detectAgentStatusFromTitle(title)).toBeNull()
})
})
describe('one real OMP turn', () => {
// Why: the reported symptom was ~12 committed store patches per second on a working OMP tab.
// This drives a full turn of the frames Orca's injected extension actually emits and counts
// what survives the churn gate. Before the fix each frame alternated "⠋ Pi"/"⠋ OMP" and every
// one of them committed.
it('commits twice across 30 working frames plus the idle transition', () => {
const frames = Array.from(
{ length: 30 },
(_, index) => `${FRAMES[index % FRAMES.length]} π - fixing the sidebar - orca`
)
frames.push('π - fixing the sidebar - orca')
const committed: string[] = []
let previous: string | null = null
for (const frame of frames) {
const normalized = normalizeTerminalTitle(frame)
if (previous !== null && isDecorativeAgentTitleFrameChange(previous, normalized)) {
continue
}
if (normalized === previous) {
continue
}
committed.push(normalized)
previous = normalized
}
expect(committed).toEqual(['⠋ π - fixing the sidebar - orca', 'π - fixing the sidebar - orca'])
})
})
describe('latent hazards the reviewers flagged', () => {
// Why: `-` is both a state separator and the permission label's delimiter. This resolves
// correctly today only because the synthetic check runs first in detectAgentStatusFromTitle,
// and the separator fn is exported — so pin both the caller and the fn itself.
it.each(['Pi - action required', 'OMP - action required'])(
'classifies %s as permission, not idle',
(title) => {
expect(detectAgentStatusFromTitle(title)).toBe('permission')
expect(getPiCompatibleTitleSeparatorStatus(title)).toBe('permission')
}
)
// Why: the owner rewrite re-runs on its own output during hydration and seeding. It is a fixed
// point only because getAgentLabel does not tokenize `omp`/`pi`; if that ever changes, a
// rewritten title would collapse back to a bare label and lose the session text again.
it.each([
'⠋ π - fixing the sidebar - orca',
'π - fixing the sidebar - orca',
'π ! fixing the sidebar',
'π ⠙ fixing the sidebar',
'zsh | ⠙ π - a - b'
])('is a fixed point when the owner rewrite re-runs on %s', (title) => {
const once = normalizeCompatibleAgentTitleForOwner(title, 'omp')
expect(normalizeCompatibleAgentTitleForOwner(once, 'omp')).toBe(once)
expect(once).toContain('OMP')
})
})
@@ -5,6 +5,12 @@ const PI_COMPATIBLE_SYNTHETIC_TITLE_RE =
/^\s*(?:[\u2800-\u28ff]\s+)?(pi|omp)(?:\s+-\s+action required|\s+(?:ready|idle|done))?\s*$/i
// Why: legacy Pi/OMP-compatible shells can emit the delimiter before cwd text exists.
const LEGACY_PI_COMPATIBLE_TITLE_RE = /^\s*(?:[\u2800-\u28ff]\s+)?π(?:\s*[-:]|\s)\s*.*$/u
// Why: the state separator sits directly after the brand — `π ! label`, `OMP > label`. The brand
// may already have been swapped for the owner's label, so accept those too — but only in their
// exact profile casing, since a lowercase `pi - refactor…` is ordinary prose, not a Pi title.
// The separator must be delimited (`:` attached, or spaced) or `omp-harness` reads as a state.
const PI_COMPATIBLE_SEPARATOR_RE = /^\s*(?:π|Pi|OMP)(?::|\s+([!>-]))(?=\s|$)/u
const PI_COMPATIBLE_PERMISSION_TAIL_RE = /\baction required\b/i
function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
@@ -53,3 +59,32 @@ export function getPiCompatibleSyntheticAgentStatus(
export function isLegacyPiCompatibleTitle(title: string): boolean {
return LEGACY_PI_COMPATIBLE_TITLE_RE.test(title)
}
/**
* Reads the run state a π-branded title encodes in its separator.
*
* Why: Pi/OMP put the state between the brand and the session label —
* `π ! <label>` means the agent is blocked on the user, `π > <label>` is the
* user's turn, `π ⠋ <label>` is working (upstream `buildTerminalTitleWithState`).
* Without this the `!` is never read and a blocked agent classifies as idle.
*/
export function getPiCompatibleTitleSeparatorStatus(
title: string
): PiCompatibleSyntheticAgentStatus | null {
// Why: a spinner anywhere means the agent is working, and that outranks the separator —
// the frame is drawn over the idle separator position while a turn runs.
if (containsBrailleSpinner(title)) {
return null
}
const match = PI_COMPATIBLE_SEPARATOR_RE.exec(title)
if (!match) {
return null
}
// Why: `-` is both a state separator and the delimiter in the synthetic permission label, so
// `OMP - action required` would read as idle. Callers happen to resolve that label earlier,
// but this is exported — carry the guard here rather than depend on their ordering.
if (PI_COMPATIBLE_PERMISSION_TAIL_RE.test(title)) {
return 'permission'
}
return match[1] === '!' ? 'permission' : 'idle'
}
+3 -2
View File
@@ -32,12 +32,13 @@ describe('synthetic agent titles', () => {
it('provides Pi-compatible OMP titles for hook-driven status updates', () => {
expect(getSyntheticAgentTerminalTitle('omp', 'done')).toBe('OMP ready')
expect(getSyntheticAgentTerminalTitle('omp', 'waiting')).toBe('OMP - action required')
expect(shouldDriveSyntheticAgentTitleFromHook('omp', 'working')).toBe(true)
// Why: the native π working title carries session name/cwd; synthesizing would clobber it.
expect(shouldDriveSyntheticAgentTitleFromHook('omp', 'working')).toBe(false)
})
it('provides Pi titles for hook-driven status updates', () => {
expect(getSyntheticAgentTerminalTitle('pi', 'done')).toBe('Pi ready')
expect(getSyntheticAgentTerminalTitle('pi', 'waiting')).toBe('Pi - action required')
expect(shouldDriveSyntheticAgentTitleFromHook('pi', 'working')).toBe(true)
expect(shouldDriveSyntheticAgentTitleFromHook('pi', 'working')).toBe(false)
})
})
+9 -2
View File
@@ -46,13 +46,20 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleP
workingLabel: 'Pi',
permissionLabel: 'Pi - action required',
idleLabel: 'Pi ready',
titleIdentityGroup: 'pi-compatible'
titleIdentityGroup: 'pi-compatible',
// Why: Pi owns its working OSC title (`π ⠋ <session>`) and animates it itself. Synthesizing
// over it replaced the session label and fought its frames at 80ms. Terminal states still
// synthesize: they carry the pane's agent identity downstream, and Pi is quiet at rest.
synthesizeWorkingTitle: false
},
omp: {
workingLabel: 'OMP',
permissionLabel: 'OMP - action required',
idleLabel: 'OMP ready',
titleIdentityGroup: 'pi-compatible'
titleIdentityGroup: 'pi-compatible',
// Why: on an Orca-hosted pane it is Orca's own injected titlebar extension writing the
// working title (src/main/pi/titlebar-extension-source.ts). See pi above.
synthesizeWorkingTitle: false
},
droid: {
workingLabel: 'Droid',
@@ -429,7 +429,8 @@ describe('real PTY decorative session-tabs fanout', () => {
agent: 'pi' as const,
firstTitle: '⠋ π - project',
heartbeatTitles: ['⠋ π - project', '⠙ π - project'],
expectedTitle: '⠋ Pi'
// Why: the π brand swaps for the owner label in place; session text survives.
expectedTitle: '⠋ Pi - project'
},
{
agent: 'grok-build' as const,
@@ -486,11 +487,11 @@ describe('real PTY decorative session-tabs fanout', () => {
expect(publications).toHaveLength(2)
expect(
publications.every((snapshot) => {
publications.map((snapshot) => {
const terminal = snapshot.tabs[0]
return terminal?.type === 'terminal' && terminal.title === testCase.expectedTitle
return terminal?.type === 'terminal' ? terminal.title : null
})
).toBe(true)
).toEqual([testCase.expectedTitle, testCase.expectedTitle])
const finalTerminal = publications.at(-1)?.tabs[0]
const finalAgentStatus =
finalTerminal?.type === 'terminal' ? finalTerminal.agentStatus : undefined