mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
Align query authority contract and spawn-time ownership
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -38,9 +38,14 @@ depend on.
|
||||
5. Snapshots and live bytes have ordering metadata. A view restore must not
|
||||
duplicate bytes already included in the snapshot or drop bytes that arrived
|
||||
after it.
|
||||
6. Terminal query authority stays with the visible renderer when needed. The
|
||||
headless model tracks state but must not answer DA, DSR, OSC 11, or other
|
||||
shell/TUI queries that would inject replies into the PTY.
|
||||
6. Terminal query authority is singular and structural: the party that
|
||||
writes a chunk into a live terminal answers its queries. Visible renderer
|
||||
and remote views keep xterm authority. Chunks dropped by the
|
||||
hidden-delivery gate are answered exactly once by the main model
|
||||
responder, from runtime-emulator state plus renderer-pushed view
|
||||
attributes. Replayed, seeded, or snapshot bytes are answered by no one.
|
||||
The daemon emulator never answers. (Amended by Phase 5 — see
|
||||
[`terminal-query-authority.md`](./terminal-query-authority.md).)
|
||||
7. The transcript contract stays separate from screen restore. `orca terminal
|
||||
read` must preserve bounded previews, cursor pagination, partial-line rules,
|
||||
truncation flags, and total counts even if view snapshots change shape.
|
||||
@@ -116,7 +121,11 @@ Before moving more runtime behavior behind the model/view boundary, add or
|
||||
extend tests that prove:
|
||||
|
||||
- headless snapshots rehydrate rich alternate-screen TUI state;
|
||||
- headless tracking does not answer DA, DSR, OSC 11, or theme-sensitive queries;
|
||||
- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive
|
||||
queries (the `session.test.ts` pins are permanent);
|
||||
- the main runtime responder answers queries only from live chunks the
|
||||
hidden-delivery gate dropped — never delivered, replayed, seeded, or
|
||||
remote-subscribed chunks;
|
||||
- hidden renderer overflow restores from model state without duplicate live
|
||||
output;
|
||||
- sleep/wake and worktree revisit restore from model-correct state;
|
||||
@@ -135,6 +144,7 @@ Current coverage is spread across:
|
||||
- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts`
|
||||
- `src/main/runtime/rpc/terminal-multiplex.test.ts`
|
||||
- `src/main/runtime/orca-runtime.test.ts`
|
||||
- `src/main/runtime/terminal-query-responder.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts`
|
||||
- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`
|
||||
- `tests/e2e/terminal-sleep-wake-restore.spec.ts`
|
||||
|
||||
@@ -150,8 +150,13 @@ actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes`
|
||||
for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty
|
||||
flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty
|
||||
reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`)
|
||||
stays authoritative. A re-seeded emulator that lost flags answers `?0u`;
|
||||
protocol-conformant programs re-push.
|
||||
stays authoritative. Slice 3 wires the re-seed consumer: the daemon
|
||||
warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn
|
||||
result into `seedHeadlessTerminal`, which applies them to the fresh runtime
|
||||
emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding
|
||||
window), so hidden `CSI ? u` reports the flags the hidden app actually
|
||||
pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer
|
||||
`?0u`; protocol-conformant programs re-push.
|
||||
|
||||
### ConPTY DA1 variant
|
||||
|
||||
@@ -163,8 +168,8 @@ emulator parser (the main-side twin of
|
||||
the forwarding predicate. The override is installed at emulator creation and
|
||||
retrofitted when the spawn mark lands (daemon stream data can create the
|
||||
emulator before the awaited spawn response marks the PTY). ConPTY blocking on
|
||||
a missing DA1 is a spawn-time hazard; see the races section for the
|
||||
hidden-at-spawn loss window that remains until Phase 6.
|
||||
a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is
|
||||
closed by the slice-3 `initiallyHidden` spawn flag (races section).
|
||||
|
||||
## Suppression: when main never replies
|
||||
|
||||
@@ -206,12 +211,17 @@ That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or
|
||||
tolerate silence, as they did for every hidden pane before this phase). The
|
||||
one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible
|
||||
pane or an active codex startup window answers it from the renderer xterm.
|
||||
But a PTY spawned hidden **without** the startup window has no answerer until
|
||||
the renderer's hidden mark lands in main (one IPC hop after spawn): a DA1
|
||||
arriving in that pre-mark window is lost. That loss window is the pre-Phase-4
|
||||
hidden status quo and persists until Phase 6 marks hidden panes at spawn
|
||||
(spawn-record flag, below) — spawn-time ownership is not deterministic before
|
||||
then.
|
||||
A PTY spawned hidden **without** the startup window previously had no
|
||||
answerer until the renderer's hidden mark landed in main (one IPC hop after
|
||||
spawn). Slice 3 closes that window with the `initiallyHidden` spawn-record
|
||||
flag: the renderer declares hidden-at-spawn on `pty:spawn` (never while the
|
||||
codex startup window would run, and never for remote-runtime transports),
|
||||
and main marks the PTY hidden before the first byte — pre-spawn for
|
||||
daemon-host sessions whose id is minted up front, immediately after
|
||||
`provider.spawn` resolves otherwise — so the gate and responder own queries
|
||||
from byte one. The pane's first visibility sync then re-marks or unmarks
|
||||
through the existing Phase-4 machinery (unmark emits the restore marker for
|
||||
any spawn-window drops).
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -283,16 +293,26 @@ otherwise untouched in this phase.
|
||||
|
||||
## What Phase 6 (delete skip grammar + startup window) requires from this design
|
||||
|
||||
- **Mark-before-first-byte**: panes spawned without a visible view must be
|
||||
hidden-marked at spawn (spawn-record flag, not a renderer round trip) so
|
||||
startup queries — including ConPTY's blocking DA1 and codex startup probes —
|
||||
are main-owned from byte zero once the 10s window is gone.
|
||||
- **Attributes before spawn**: the renderer must push view attributes at app
|
||||
start, before any hidden spawn, or spawn-time view-attribute queries fall
|
||||
into the silent-until-push rule.
|
||||
- **Daemon shell-ready write gating** queues responder replies until the
|
||||
ready marker; spawn-time replies on Windows daemon PTYs need explicit
|
||||
validation before the window is removed.
|
||||
Accepted and shipped in slice 3 (except where noted):
|
||||
|
||||
- **Mark-before-first-byte** (shipped): panes spawned without a visible view
|
||||
are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn`
|
||||
(spawn-record flag, not a renderer round trip) so startup queries —
|
||||
including ConPTY's blocking DA1 — are main-owned from byte zero. Codex
|
||||
startup probes stay renderer-answered while the 10s window exists: the
|
||||
renderer never sets the flag for codex startups; once Phase 6 deletes the
|
||||
window, dropping that exclusion makes codex spawns main-owned too.
|
||||
- **Attributes before spawn** (shipped): the renderer pushes composed view
|
||||
attributes once at app start (right after settings load, before terminal
|
||||
reconnect/spawn), so spawn-time view-attribute queries no longer fall into
|
||||
the silent-until-push rule. Per-pane appearance applies keep re-publishing
|
||||
through the same deduped publisher.
|
||||
- **Daemon shell-ready write gating** (verified): responder replies through
|
||||
`ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never
|
||||
dropped, and the queue flushes at the shell-ready marker or the 15s
|
||||
`SHELL_READY_TIMEOUT_MS` bound (`session.ts`). Spawn-time replies on
|
||||
Windows daemon PTYs still need explicit e2e validation before the codex
|
||||
window is removed.
|
||||
- With the skip grammar deleted, every chunk is either written to a live
|
||||
xterm or dropped — the delivered-but-skipped no-reply gap disappears and
|
||||
the only remaining loss window is the mark IPC race.
|
||||
|
||||
@@ -34,7 +34,7 @@ Remote-runtime PTYs (`remote:`) never transit local main; the renderer
|
||||
| OSC 133;D command-finished exit code | main | main | renderer |
|
||||
| GitHub PR-link scan | main | main | renderer |
|
||||
| Command Code output scrape | main (shipped: per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main (shipped) | renderer |
|
||||
| DECSET 2031 color-scheme reply | renderer view/watcher — query authority stays with the view (contract invariant 6) | same | renderer |
|
||||
| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer |
|
||||
| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer |
|
||||
|
||||
## Main-Side Tracker
|
||||
|
||||
@@ -216,12 +216,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
|
||||
const isAltScreen = result.snapshot.modes.alternateScreen
|
||||
const snapshotPayload = result.snapshot.rehydrateSequences + result.snapshot.snapshotAnsi
|
||||
// Why kitty flags ride beside the payload, not inside it: the snapshot
|
||||
// string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's
|
||||
// deliberate kitty reset must win. Only the runtime emulator re-seed
|
||||
// consumes the flags (terminal-query-authority.md §kitty).
|
||||
const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags
|
||||
return {
|
||||
id: sessionId,
|
||||
pid,
|
||||
snapshot: snapshotPayload,
|
||||
snapshotCols: result.snapshot.cols,
|
||||
snapshotRows: result.snapshot.rows,
|
||||
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
|
||||
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
|
||||
: {}),
|
||||
isReattach: true,
|
||||
isAlternateScreen: isAltScreen
|
||||
}
|
||||
|
||||
@@ -157,6 +157,20 @@ export class HeadlessEmulator {
|
||||
this.viewAttributeResponder?.clearColorOverrides()
|
||||
}
|
||||
|
||||
/** Re-seed parity for snapshot `modes.kittyKeyboardFlags`
|
||||
* (terminal-query-authority.md §kitty): replays the persisted flags
|
||||
* through the same `CSI = flags ; 1 u` parse a live push uses, so hidden
|
||||
* `CSI ? u` reports them instead of `?0u`. Routed as an UNFLAGGED write —
|
||||
* outside any forwarding window, it can never answer anything — and never
|
||||
* into renderer rehydrateSequences (POST_REPLAY_REATTACH_RESET's kitty
|
||||
* reset stays authoritative). */
|
||||
applyKittyKeyboardFlags(flags: number): Promise<void> {
|
||||
if (!Number.isInteger(flags) || flags <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return this.write(`\x1b[=${flags};1u`)
|
||||
}
|
||||
|
||||
private emitQueryReply(reply: string): void {
|
||||
if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) {
|
||||
this.onQueryReply(reply)
|
||||
|
||||
@@ -162,10 +162,13 @@ describe('Session', () => {
|
||||
|
||||
describe('emulator does not reply to terminal queries', () => {
|
||||
// Why: daemon emulator parses in-process synchronously — before
|
||||
// handleSubprocessData forwards bytes to the renderer over IPC — so any
|
||||
// auto-reply it emits races ahead of the renderer's xterm and clobbers
|
||||
// it with default-xterm values (no theme, stale cursor). The renderer is
|
||||
// the authoritative responder; a daemon-side reply to any query is a bug.
|
||||
// handleSubprocessData forwards bytes onward — so any auto-reply it
|
||||
// emits races ahead of the live answerer and clobbers it with
|
||||
// default-xterm values (no theme, stale cursor). Query authority is
|
||||
// structural (terminal-query-authority.md): a delivered chunk is
|
||||
// answered by the consuming view's xterm, a hidden-dropped chunk by
|
||||
// MAIN's runtime model responder. The daemon emulator is neither — it
|
||||
// stays write-only forever, and these pins are permanent.
|
||||
it.each([
|
||||
['OSC 10 foreground-color', '\x1b]10;?\x07'],
|
||||
['OSC 11 background-color', '\x1b]11;?\x07'],
|
||||
|
||||
@@ -35,8 +35,10 @@ export type TerminalModes = {
|
||||
applicationCursor: boolean
|
||||
alternateScreen: boolean
|
||||
/** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed
|
||||
* parity ONLY. Produced but not yet consumed — the re-seed consumer is
|
||||
* slice-3 work; do not mistake this field for live snapshot parity.
|
||||
* parity ONLY. Consumed by the daemon warm-reattach path: the spawn
|
||||
* result threads them into seedHeadlessTerminal, which re-applies them to
|
||||
* the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags)
|
||||
* so hidden `CSI ? u` answers the real flags instead of ?0u.
|
||||
* rehydrateSequences must never push these into a renderer xterm —
|
||||
* POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative
|
||||
* (terminal-query-authority.md §kitty). */
|
||||
|
||||
+188
-2
@@ -160,7 +160,11 @@ import {
|
||||
rebindLocalProviderListeners,
|
||||
unregisterSshPtyProvider
|
||||
} from './pty'
|
||||
import { _resetHiddenRendererPtyDeliveryGateForTest } from './pty-hidden-delivery-gate'
|
||||
import {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest,
|
||||
isHiddenRendererPty
|
||||
} from './pty-hidden-delivery-gate'
|
||||
import { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate'
|
||||
import {
|
||||
encodePowerShellCommand,
|
||||
@@ -430,11 +434,12 @@ describe('registerPtyHandlers', () => {
|
||||
const spawn = vi.fn(async (options: { sessionId?: string }) => ({
|
||||
id: options.sessionId ?? 'daemon-pty'
|
||||
}))
|
||||
const write = vi.fn()
|
||||
let dataHandler: ((payload: { id: string; data: string }) => void) | null = null
|
||||
let exitHandler: ((payload: { id: string; code: number }) => void) | null = null
|
||||
setLocalPtyProvider({
|
||||
spawn,
|
||||
write: vi.fn(),
|
||||
write,
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
@@ -463,6 +468,7 @@ describe('registerPtyHandlers', () => {
|
||||
} as never)
|
||||
return {
|
||||
spawn,
|
||||
write,
|
||||
emitData: (id: string, data: string) => dataHandler?.({ id, data }),
|
||||
emitExit: (id: string, code = 0) => exitHandler?.({ id, code })
|
||||
}
|
||||
@@ -5425,6 +5431,186 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hidden-at-spawn mark (initiallyHidden)', () => {
|
||||
// terminal-query-authority.md §races: the renderer declares hidden-at-
|
||||
// spawn so main marks the PTY before its first byte — the spawn-time
|
||||
// query window where neither side replied (the non-codex DA1 loss) is
|
||||
// closed by the gate + responder owning queries from byte one.
|
||||
function createRuntimeMock() {
|
||||
return {
|
||||
setPtyController: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(() => 42),
|
||||
getPtyOutputSequence: vi.fn(() => 42),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
|
||||
registerPreAllocatedHandleForPty: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
it('marks a daemon PTY hidden before spawn resolves so byte zero is gated', async () => {
|
||||
vi.useFakeTimers()
|
||||
const runtime = createRuntimeMock()
|
||||
const daemon = installObservableDaemonTestProvider()
|
||||
const spawnGate = makeDeferred()
|
||||
daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => {
|
||||
await spawnGate.promise
|
||||
return { id: options.sessionId ?? 'daemon-pty' }
|
||||
})
|
||||
try {
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const spawnPromise = handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'daemon-session',
|
||||
initiallyHidden: true
|
||||
}) as Promise<{ id: string }>
|
||||
// Let the handler run up to the awaited provider.spawn.
|
||||
await Promise.resolve()
|
||||
mainWindow.webContents.send.mockClear()
|
||||
|
||||
// Daemon PTYs can emit prompt bytes before spawn() resolves — the
|
||||
// pre-spawn mark must already gate them.
|
||||
expect(isHiddenRendererPty('daemon-session')).toBe(true)
|
||||
daemon.emitData('daemon-session', 'pre-spawn prompt\x1b[c')
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(runtime.onPtyData).toHaveBeenCalledWith(
|
||||
'daemon-session',
|
||||
'pre-spawn prompt\x1b[c',
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
|
||||
id: 'daemon-session',
|
||||
reason: 'hidden-drop',
|
||||
markerSeq: 42
|
||||
})
|
||||
|
||||
spawnGate.resolve()
|
||||
const result = await spawnPromise
|
||||
expect(isHiddenRendererPty(result.id)).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the pre-spawn hidden mark when the spawn fails', async () => {
|
||||
const daemon = installObservableDaemonTestProvider()
|
||||
daemon.spawn.mockRejectedValue(new Error('spawn exploded'))
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'daemon-session',
|
||||
initiallyHidden: true
|
||||
})
|
||||
).rejects.toThrow('spawn exploded')
|
||||
|
||||
// A later visible attach reusing this session id must not start gated.
|
||||
expect(isHiddenRendererPty('daemon-session')).toBe(false)
|
||||
})
|
||||
|
||||
it('marks local PTYs hidden after spawn, before their first data task', async () => {
|
||||
vi.useFakeTimers()
|
||||
const mockProc = createMockProc()
|
||||
spawnMock.mockReturnValue(mockProc.proc)
|
||||
try {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const spawnResult = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
initiallyHidden: true
|
||||
})) as { id: string }
|
||||
mainWindow.webContents.send.mockClear()
|
||||
|
||||
expect(isHiddenRendererPty(spawnResult.id)).toBe(true)
|
||||
mockProc.emitData('first chunk')
|
||||
vi.advanceTimersByTime(8)
|
||||
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
|
||||
id: spawnResult.id,
|
||||
reason: 'hidden-drop'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps spawns without the flag delivering to the renderer (visible unchanged)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const mockProc = createMockProc()
|
||||
spawnMock.mockReturnValue(mockProc.proc)
|
||||
try {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const spawnResult = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp'
|
||||
})) as { id: string }
|
||||
mainWindow.webContents.send.mockClear()
|
||||
|
||||
expect(isHiddenRendererPty(spawnResult.id)).toBe(false)
|
||||
mockProc.emitData('visible output')
|
||||
vi.advanceTimersByTime(8)
|
||||
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
|
||||
id: spawnResult.id,
|
||||
data: 'visible output'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('answers DA1 from the model on the first chunk of a hidden-at-spawn PTY', async () => {
|
||||
// End-to-end through a REAL runtime: spawn-marked → first chunk dropped
|
||||
// → runtime emulator parses the query → reply written to the provider
|
||||
// input path (the renderer never saw the bytes; main is the answerer).
|
||||
const daemon = installObservableDaemonTestProvider()
|
||||
const runtime = new OrcaRuntimeService({
|
||||
getRepo: () => undefined,
|
||||
getRepos: () => [],
|
||||
addRepo: () => {},
|
||||
updateRepo: () => undefined as never,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: () => undefined as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} }) as never,
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: '',
|
||||
terminalMainSideEffectAuthority: true,
|
||||
terminalHiddenDeliveryGate: true,
|
||||
terminalModelQueryAuthority: true
|
||||
})
|
||||
} as never)
|
||||
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'daemon-session',
|
||||
initiallyHidden: true
|
||||
})) as { id: string }
|
||||
|
||||
daemon.emitData(result.id, '\x1b[c')
|
||||
// Settle the per-PTY emulator writeChain (and the reply it forwards).
|
||||
await runtime.serializeMainTerminalBuffer(result.id)
|
||||
|
||||
expect(daemon.write).toHaveBeenCalledWith(result.id, '\x1b[?1;2c')
|
||||
})
|
||||
})
|
||||
|
||||
it('caps pending renderer delivery per PTY with oldest-drop and one restore marker', async () => {
|
||||
vi.useFakeTimers()
|
||||
const mockProc = createMockProc()
|
||||
|
||||
+49
-1
@@ -2215,6 +2215,12 @@ export function registerPtyHandlers(
|
||||
worktreeId?: string
|
||||
sessionId?: string
|
||||
shellOverride?: string
|
||||
// Why: hidden-at-spawn declaration (terminal-query-authority.md
|
||||
// §races) — the renderer knows at spawn time that no visible view
|
||||
// will consume this PTY's bytes, so main marks it hidden BEFORE the
|
||||
// first byte and the gate + model responder own spawn-time queries.
|
||||
// The renderer never sets this while the codex startup window runs.
|
||||
initiallyHidden?: boolean
|
||||
// Why: closes the SIGKILL race documented in INVESTIGATION.md by
|
||||
// letting main patch + sync-flush the (worktreeId, tabId, leafId →
|
||||
// ptyId) binding before pty:spawn returns. Only the renderer's
|
||||
@@ -2497,6 +2503,20 @@ export function registerPtyHandlers(
|
||||
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
||||
: undefined
|
||||
}
|
||||
const initiallyHidden = args.initiallyHidden === true
|
||||
// Why pre-spawn for daemon-host sessions (id minted up front): daemon
|
||||
// PTYs can emit prompt bytes before spawn() resolves, and the hidden
|
||||
// mark must beat the first byte so the gate + model responder own
|
||||
// spawn-time queries (terminal-query-authority.md §races). Other
|
||||
// providers cannot emit until spawn resolves; the post-spawn mark
|
||||
// below is byte-zero-safe for them.
|
||||
const preSpawnHiddenMarkId =
|
||||
initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined
|
||||
? effectiveSessionAppId
|
||||
: null
|
||||
if (preSpawnHiddenMarkId !== null) {
|
||||
markHiddenRendererPty(preSpawnHiddenMarkId)
|
||||
}
|
||||
let result: PtySpawnResult
|
||||
try {
|
||||
if (preAllocatedHandle) {
|
||||
@@ -2504,6 +2524,11 @@ export function registerPtyHandlers(
|
||||
}
|
||||
result = await provider.spawn(spawnOptions)
|
||||
} catch (err) {
|
||||
// Why: a failed spawn must not leave a stale hidden mark on a session
|
||||
// id a later visible attach may reuse.
|
||||
if (preSpawnHiddenMarkId !== null) {
|
||||
unmarkHiddenRendererPty(preSpawnHiddenMarkId)
|
||||
}
|
||||
const rawMessage = err instanceof Error ? err.message : String(err)
|
||||
const spawnError = normalizeNodePtySpawnError(err)
|
||||
if (effectiveSessionAppId !== undefined) {
|
||||
@@ -2561,6 +2586,18 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
ptyOwnership.set(result.id, args.connectionId ?? null)
|
||||
if (initiallyHidden) {
|
||||
// Why marked synchronously before any await below: local/SSH provider
|
||||
// data events dispatch on later tasks, so this is still ahead of the
|
||||
// first byte's delivery decision. Idempotent for daemon hosts already
|
||||
// marked pre-spawn; the renderer's first visibility sync re-marks or
|
||||
// unmarks (emitting the restore marker) through the Phase-4 path.
|
||||
markHiddenRendererPty(result.id)
|
||||
if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) {
|
||||
// Defense: never strand a mark on an id the provider renamed.
|
||||
unmarkHiddenRendererPty(preSpawnHiddenMarkId)
|
||||
}
|
||||
}
|
||||
// Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY
|
||||
// determination from the spawn record before the headless seed below,
|
||||
// so the runtime emulator's DA1 override exists from byte zero.
|
||||
@@ -2664,7 +2701,18 @@ export function registerPtyHandlers(
|
||||
? { cols: result.snapshotCols, rows: result.snapshotRows }
|
||||
: undefined
|
||||
if (typeof result.snapshot === 'string' && result.snapshot.length > 0) {
|
||||
runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize)
|
||||
// Why kitty flags ride seed metadata: the snapshot string omits
|
||||
// them by design (renderer kitty reset stays authoritative), but
|
||||
// the re-seeded emulator must answer hidden `CSI ? u` with the
|
||||
// flags the still-running app pushed (terminal-query-authority.md).
|
||||
runtime.seedHeadlessTerminal(
|
||||
result.id,
|
||||
result.snapshot,
|
||||
seedSize,
|
||||
typeof result.snapshotKittyKeyboardFlags === 'number'
|
||||
? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags }
|
||||
: {}
|
||||
)
|
||||
} else if (
|
||||
result.coldRestore &&
|
||||
typeof result.coldRestore.scrollback === 'string' &&
|
||||
|
||||
@@ -70,6 +70,11 @@ export type PtySpawnResult = {
|
||||
* writing the snapshot so ANSI cursor positions land correctly. */
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
/** Kitty keyboard flags persisted in the daemon snapshot, threaded so the
|
||||
* re-seeded runtime emulator answers hidden `CSI ? u` with the real flags
|
||||
* (terminal-query-authority.md §kitty). Never replayed into a renderer
|
||||
* xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */
|
||||
snapshotKittyKeyboardFlags?: number
|
||||
/** True when the spawn reattached to an existing daemon session. */
|
||||
isReattach?: boolean
|
||||
/** True when the reattached session uses the alternate screen buffer
|
||||
|
||||
@@ -772,6 +772,10 @@ type RuntimeHeadlessTerminal = {
|
||||
|
||||
type HeadlessSeedMetadata = {
|
||||
cwd?: string | null
|
||||
/** Persisted kitty flags from the daemon snapshot, re-applied to the fresh
|
||||
* emulator so hidden `CSI ? u` answers the real flags instead of ?0u
|
||||
* (terminal-query-authority.md §kitty). */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
type RuntimePtyController = {
|
||||
@@ -4083,6 +4087,13 @@ export class OrcaRuntimeService {
|
||||
// Why: seed writes never set forwardQueryReplies — the main-side
|
||||
// replay guard. A snapshot containing old queries must answer no one.
|
||||
await state.emulator.write(data)
|
||||
// Why AFTER the seed write: the snapshot payload cannot carry kitty
|
||||
// pushes (rehydrateSequences deliberately omits them), but ordering
|
||||
// behind it keeps the parse deterministic. Unflagged like the seed —
|
||||
// re-applying flags must answer no one.
|
||||
if (typeof metadata.kittyKeyboardFlags === 'number') {
|
||||
await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags)
|
||||
}
|
||||
if (metadata.cwd !== undefined) {
|
||||
state.emulator.setCwd(metadata.cwd)
|
||||
}
|
||||
@@ -4245,6 +4256,11 @@ export class OrcaRuntimeService {
|
||||
// disposeHeadlessTerminal, and daemon respawns reuse session ids — a
|
||||
// stale link's reply must never reach a successor PTY under this id.
|
||||
if (state !== null && this.headlessTerminals.get(ptyId) === state) {
|
||||
// Why this write is safe pre-shell-ready: daemon Session.write
|
||||
// QUEUES (never drops) input while the POSIX shell-ready gate is
|
||||
// pending and flushes at the ready marker or the 15s
|
||||
// SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query
|
||||
// reply is delayed at most that bound, not lost.
|
||||
this.ptyController?.write(ptyId, reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,37 @@ describe('main-side replay guard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () => {
|
||||
it('answers ?u with the persisted snapshot flags after a re-seed, silently applied', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-kitty')
|
||||
|
||||
// Daemon warm-reattach threads modes.kittyKeyboardFlags through the
|
||||
// spawn result into the seed; applying them is a seed-side write and
|
||||
// must answer no one (main-side replay guard).
|
||||
runtime.seedHeadlessTerminal('pty-kitty', 'restored prompt', undefined, {
|
||||
kittyKeyboardFlags: 5
|
||||
})
|
||||
await settle(runtime, 'pty-kitty')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-kitty', '\x1b[?u', Date.now())
|
||||
await settle(runtime, 'pty-kitty')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?5u'])
|
||||
})
|
||||
|
||||
it('answers ?0u when the snapshot carried no flags (fresh-shell paths)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-kitty0')
|
||||
|
||||
runtime.seedHeadlessTerminal('pty-kitty0', 'restored prompt')
|
||||
runtime.onPtyData('pty-kitty0', '\x1b[?u', Date.now())
|
||||
await settle(runtime, 'pty-kitty0')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?0u'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ingestion-time ownership capture', () => {
|
||||
const DA1 = '\x1b[c'
|
||||
|
||||
|
||||
@@ -908,6 +908,10 @@ export type PreloadApi = {
|
||||
// Preserved from the deleted index.d.ts PtyApi duplicate during the
|
||||
// single-source-of-truth collapse (see docs/preload-typecheck-hole.md §1).
|
||||
shellOverride?: string
|
||||
// Why: hidden-at-spawn declaration — main marks the PTY hidden before
|
||||
// its first byte so the delivery gate + model responder own spawn-time
|
||||
// queries (terminal-query-authority.md §races).
|
||||
initiallyHidden?: boolean
|
||||
// Why: closes the SIGKILL race documented in INVESTIGATION.md — main
|
||||
// sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before
|
||||
// pty:spawn returns. Only the renderer's daemon-host path threads these.
|
||||
|
||||
@@ -650,6 +650,10 @@ const api = {
|
||||
worktreeId?: string
|
||||
sessionId?: string
|
||||
shellOverride?: string
|
||||
// Why: hidden-at-spawn declaration — main marks the PTY hidden before
|
||||
// its first byte so the delivery gate + model responder own spawn-time
|
||||
// queries (terminal-query-authority.md §races).
|
||||
initiallyHidden?: boolean
|
||||
// Why: closes the SIGKILL race documented in INVESTIGATION.md by
|
||||
// letting main patch + sync-flush the (worktreeId, tabId, leafId →
|
||||
// ptyId) binding before pty:spawn returns. Only the renderer's
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility'
|
||||
import { applyDocumentTheme } from './lib/document-theme'
|
||||
import { getSystemPrefersDark } from './lib/terminal-theme'
|
||||
import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance'
|
||||
import { isEditableTarget } from './lib/editable-target'
|
||||
import { getSelectedTextForFileSearch } from './lib/file-search-selection'
|
||||
import { useShortcutLabel } from './hooks/useShortcutLabel'
|
||||
@@ -721,6 +722,14 @@ function App(): React.JSX.Element {
|
||||
// Load settings first so a persisted remote runtime does not boot against
|
||||
// the local filesystem and then hydrate stale local workspace state.
|
||||
await actions.fetchSettings()
|
||||
// Why here: hidden-at-launch PTYs (background terminal reconnects,
|
||||
// agent sessions) can query OSC 10/11 before any terminal pane mounts
|
||||
// and main's responder is silent-until-first-push. Publish composed
|
||||
// view attributes as soon as settings exist, before any spawn below.
|
||||
publishTerminalViewAttributesAtAppStart(
|
||||
useAppStore.getState().settings,
|
||||
getSystemPrefersDark()
|
||||
)
|
||||
await actions.fetchRepos()
|
||||
await actions.fetchProjectGroups()
|
||||
await actions.fetchAllWorktrees()
|
||||
|
||||
@@ -3338,6 +3338,37 @@ describe('connectPanePty', () => {
|
||||
expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true)
|
||||
})
|
||||
|
||||
it('declares hidden-at-spawn on connect for hidden non-codex panes', async () => {
|
||||
enableMainAuthority()
|
||||
const deps = createDeps({ isVisibleRef: { current: false } })
|
||||
const { transport } = await connectHiddenPane(deps)
|
||||
// Why: waiting for the first dataCallback sync left a spawn-time query
|
||||
// window where neither side replied (the non-codex DA1 loss). The flag
|
||||
// lets main mark the PTY hidden before its first byte.
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initiallyHidden: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps visible spawns undeclared (visible spawn unchanged)', async () => {
|
||||
enableMainAuthority()
|
||||
const deps = createDeps()
|
||||
const { transport } = await connectHiddenPane(deps)
|
||||
expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden')
|
||||
})
|
||||
|
||||
it('never declares hidden-at-spawn while the codex startup window is active', async () => {
|
||||
enableMainAuthority()
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: { command: 'codex' }
|
||||
})
|
||||
const { transport } = await connectHiddenPane(deps)
|
||||
// Codex startup probes need live renderer delivery for the 10s window;
|
||||
// a spawn-time hidden mark would gate them (codex spawns keep delivery).
|
||||
expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden')
|
||||
})
|
||||
|
||||
it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => {
|
||||
enableMainAuthority()
|
||||
mockStoreState.settings = {
|
||||
|
||||
@@ -1993,6 +1993,7 @@ export function connectPanePty(
|
||||
url: '',
|
||||
cols,
|
||||
rows,
|
||||
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
callbacks: {
|
||||
onData: dataCallback,
|
||||
onReplayData: replayDataCallback,
|
||||
@@ -2212,6 +2213,24 @@ export function connectPanePty(
|
||||
)
|
||||
}
|
||||
|
||||
// Why: hidden/parked panes used to mark hidden only at the first
|
||||
// dataCallback sync, leaving a spawn-time window where neither side
|
||||
// answered queries (the non-codex DA1 loss). Declaring hidden on the
|
||||
// spawn IPC lets main mark the PTY before its first byte. Codex startups
|
||||
// are excluded — their startup window needs live renderer delivery, and
|
||||
// the window predicate is checked at connect time (same tick the flag is
|
||||
// sent), so the two decisions cannot disagree. Remote-runtime PTYs are
|
||||
// never gate-markable (no local main transit).
|
||||
function shouldDeclareHiddenAtSpawn(): boolean {
|
||||
return (
|
||||
hiddenDeliveryGateActive &&
|
||||
!runtimeEnvironmentId &&
|
||||
!disposed &&
|
||||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current) &&
|
||||
!isHiddenStartupRendererQueryWindowActive()
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hidden-delivery gate sync (Phase 4) ─────────────────────────────
|
||||
// Why: marks this pane's PTY hidden in main while no visible view needs
|
||||
// its bytes; main then drops delivery after model ingestion and reveal
|
||||
@@ -3428,6 +3447,7 @@ export function connectPanePty(
|
||||
cols,
|
||||
rows,
|
||||
sessionId: pendingSessionId,
|
||||
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
callbacks: {
|
||||
onData: dataCallback,
|
||||
onReplayData: replayDataCallback,
|
||||
@@ -3565,6 +3585,7 @@ export function connectPanePty(
|
||||
cols,
|
||||
rows,
|
||||
sessionId: deferredReattachSessionId,
|
||||
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
callbacks: {
|
||||
onData: dataCallback,
|
||||
onReplayData: replayDataCallback,
|
||||
|
||||
@@ -314,6 +314,12 @@ export type PtyTransport = {
|
||||
/** Daemon session ID for reattach. When provided, the daemon reconnects
|
||||
* to an existing session instead of creating a new one. */
|
||||
sessionId?: string
|
||||
/** Hidden-at-spawn declaration (terminal-query-authority.md): no visible
|
||||
* view will consume this PTY's bytes, so main marks it hidden BEFORE the
|
||||
* first byte and the gate + model responder own spawn-time queries.
|
||||
* Never set while the codex startup window would run, and ignored by
|
||||
* remote-runtime transports (their PTYs are not gate-markable). */
|
||||
initiallyHidden?: boolean
|
||||
callbacks: {
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
|
||||
@@ -562,6 +562,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
command,
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
||||
// Why: hidden-at-spawn mark must land in main before the PTY's
|
||||
// first byte, so it rides the spawn IPC instead of the pane's
|
||||
// first visibility sync (terminal-query-authority.md).
|
||||
...(options.initiallyHidden ? { initiallyHidden: true } : {}),
|
||||
worktreeId,
|
||||
...(tabId ? { tabId } : {}),
|
||||
...(leafId ? { leafId } : {}),
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
hexToRgba,
|
||||
installMode2031Handlers,
|
||||
maybePushMode2031Flip,
|
||||
mode2031SequenceFor
|
||||
mode2031SequenceFor,
|
||||
publishTerminalViewAttributesAtAppStart
|
||||
} from './terminal-appearance'
|
||||
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
|
||||
import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher'
|
||||
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
|
||||
|
||||
function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): {
|
||||
isConnected: () => boolean
|
||||
@@ -437,6 +440,71 @@ describe('applyTerminalAppearance theme assignment', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('publishTerminalViewAttributesAtAppStart', () => {
|
||||
// Phase 6 prerequisite (terminal-query-authority.md): hidden-at-launch
|
||||
// PTYs can query OSC 10/11 before any terminal pane mounts; the app-start
|
||||
// publication must go out with no pane manager involved at all.
|
||||
it('publishes composed attributes without any pane mount and dedupes repeats', () => {
|
||||
_resetTerminalViewAttributesPublisherForTest()
|
||||
const sent: TerminalViewAttributes[] = []
|
||||
const send = (attributes: TerminalViewAttributes): boolean => {
|
||||
sent.push(attributes)
|
||||
return true
|
||||
}
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
|
||||
expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(true)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]!.ansi).toHaveLength(256)
|
||||
expect(sent[0]!.cursorStyle).toBe(settings.terminalCursorStyle ?? 'block')
|
||||
|
||||
expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(false)
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('makes the later pane-mount applyTerminalAppearance a deduped no-op re-push', () => {
|
||||
_resetTerminalViewAttributesPublisherForTest()
|
||||
const publishMock = vi.fn()
|
||||
;(globalThis as unknown as { window: unknown }).window = {
|
||||
api: { pty: { publishTerminalViewAttributes: publishMock } }
|
||||
}
|
||||
try {
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
publishTerminalViewAttributesAtAppStart(settings, true)
|
||||
expect(publishMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The first pane mount composes the identical app-global snapshot, so
|
||||
// the publisher dedupe keeps it a single push.
|
||||
const manager = {
|
||||
getPanes: () => [],
|
||||
setPaneLigaturesEnabled: vi.fn(),
|
||||
setPaneStyleOptions: vi.fn()
|
||||
} as unknown as PaneManager
|
||||
applyTerminalAppearance(
|
||||
manager,
|
||||
settings,
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
'false',
|
||||
new Map(),
|
||||
new Map()
|
||||
)
|
||||
expect(publishMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
_resetTerminalViewAttributesPublisherForTest()
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes nothing before settings are loaded', () => {
|
||||
_resetTerminalViewAttributesPublisherForTest()
|
||||
const send = vi.fn(() => true)
|
||||
expect(publishTerminalViewAttributesAtAppStart(null, true, send)).toBe(false)
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hexToRgba', () => {
|
||||
it('converts 6-char hex to rgba', () => {
|
||||
expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)')
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
|
||||
import { HEX_COLOR_RE } from '../../../../shared/color-validation'
|
||||
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
|
||||
import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher'
|
||||
|
||||
export { mode2031SequenceFor }
|
||||
@@ -196,6 +197,28 @@ export function composeActiveTerminalTheme(
|
||||
return theme
|
||||
}
|
||||
|
||||
/** App-start publication (terminal-query-authority.md §Phase 6
|
||||
* prerequisites): hidden-at-launch PTYs can query OSC 10/11 before any
|
||||
* terminal pane mounts, and main's responder is silent-until-first-push.
|
||||
* Composes the same theme applyTerminalAppearance would and publishes it
|
||||
* through the same deduped publisher, so the later pane-mount apply is a
|
||||
* no-op re-push. Returns whether a publish actually went out. */
|
||||
export function publishTerminalViewAttributesAtAppStart(
|
||||
settings: GlobalSettings | null | undefined,
|
||||
systemPrefersDark: boolean,
|
||||
send?: (attributes: TerminalViewAttributes) => boolean
|
||||
): boolean {
|
||||
if (!settings) {
|
||||
return false
|
||||
}
|
||||
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
|
||||
const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
|
||||
const theme = composeActiveTerminalTheme(baseTheme, settings)
|
||||
return send !== undefined
|
||||
? publishTerminalViewAttributes(theme, appearance.mode, settings, send)
|
||||
: publishTerminalViewAttributes(theme, appearance.mode, settings)
|
||||
}
|
||||
|
||||
// Value equality over composed ITheme objects (flat string slots plus the
|
||||
// extendedAnsi string array), used to gate the per-pane options.theme write.
|
||||
function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean {
|
||||
|
||||
Reference in New Issue
Block a user