mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
Merge nwparker/term-speed-2-architecture-docs into orca-performance base
Revives the terminal model/view architecture chain (hidden view parking, hidden delivery gate, side-effect authority in main, model query authority — all kill-switched, defaults on) on top of orca-performance's terminal performance fixes. 34 conflicted files resolved with these decisions: - Both sides' features kept and kill-switchable. orca-performance's fixes preserved exactly: parse-clocked scheduler drains (9e8bb2243), windowed retained-tail redraw (4e08a28cd), throttled wait-blocked check (66f20258e), #7139/#7150 cooperative drain, backlog caps, wedge guards, and probe-certified replay release. - orca-runtime.ts onPtyData: chain's per-PTY title tracker + side-effect facts replace the inline OSC title blocks; our scheduleWaitBlockedCheck and redraw-cursor plumbing retained; refreshPtyForegroundAgent re-grafted into applyTrackedPtyTitle on status transitions. - pty.ts: chain's hidden-gate drop sites, restore markers, initiallyHidden spawn marking, ConPTY DA1 record, and view-attribute/delivery-interest IPC wired through our newer spawn/delivery structure. The chain's flat 2 MB pending cap was NOT taken: our scrollback-scaled cap (#7150, empty-drop + droppedOutput sentinel) wins and now also emits the chain's one-per-episode pty:modelRestoreNeeded 'pending-cap' marker when the gate is enabled. Our 8 MB renderer in-flight high water kept over the chain's 2 MB. - pty-connection.ts: chain's gate sync, model-restore channel, side-effect fact consumer, and post-restore seq reconciliation composed with our hidden-startup query grammar, synchronized-frame latency machinery, and probe-certified restore. Phase 6 (skip-grammar deletion) intentionally NOT taken: our side evolved that grammar past the chain's base and #7150 machinery builds on it; with the gate on it is dormant (main drops hidden bytes), with switches off it remains the byte-identical fallback. - Shared module moves adopted (agent-title-*, github-links, bell detector, command-code, PR-link detector) with our newer bounded regex-free OSC parsers wired through the chain's scanner classes; our pi-synthetic/Devin/ MiMo agent additions ported into the split modules. - daemon-pty-adapter: chain's sleep-restore cache + our checkpoint cooldown, teardown flag, and oscLinks-carrying cold-restore payload combined. - rpc/methods/terminal.ts: chain's ACK-gated multiplex output + recovery snapshots composed with our budgeted mobile snapshots and resize re-stream. - Tests adapted only where they encoded superseded cadence: watermark counts (2 MB->8 MB), pending-cap trim -> empty-drop sentinel, initial snapshot frame seq (layout seq -> snapshot output seq), mock module paths. Contract tests: 881 passed. Renderer terminal suites: 1895 passed. Full main runtime+ipc: 2974 passed. Typecheck (3 configs) clean. Pre-existing baseline failures (pty-subprocess WSL, PR sidebar, Linear prompt, SidebarToolbar) are unrelated and fail identically on7839fb9db. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -18,6 +18,7 @@ if (reportPaths.length === 0) {
|
||||
const BUDGETS = {
|
||||
maxMedianKeyLatencyMs: 75,
|
||||
maxWorstKeyLatencyMs: 300,
|
||||
maxRevisitLatencyMs: 300,
|
||||
maxTimerDriftMs: 150,
|
||||
maxScrollLatencyMs: 150,
|
||||
maxRestoreLatencyMs: 1000,
|
||||
@@ -80,6 +81,12 @@ function validateRow(row) {
|
||||
BUDGETS.maxWorstKeyLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'revisit latency',
|
||||
parseMs(row.revisit, 'revisit', row, failures),
|
||||
BUDGETS.maxRevisitLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'timer drift',
|
||||
parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures),
|
||||
@@ -113,6 +120,14 @@ function validateRow(row) {
|
||||
parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures),
|
||||
BUDGETS.maxRendererDroppedBacklogs
|
||||
)
|
||||
// Why: parked-memory rows carry heap/view-count metrics with no latency
|
||||
// budget; recognize them so memory-only scenarios pass the gate instead of
|
||||
// tripping the "no recognized budget metrics" guard.
|
||||
for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) {
|
||||
if (parseCount(row[fieldName], fieldName, row, failures) != null) {
|
||||
checkedMetricCount += 1
|
||||
}
|
||||
}
|
||||
if (checkedMetricCount === 0) {
|
||||
failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
'frames=180',
|
||||
'median=2.9ms',
|
||||
'worst=5.9ms',
|
||||
'revisit=42.0ms',
|
||||
'maxTimerDrift=12.1ms',
|
||||
'scroll=149.9ms',
|
||||
'restore=642.0ms',
|
||||
@@ -82,6 +83,7 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
'frames=60',
|
||||
'median=76.0ms',
|
||||
'worst=301.0ms',
|
||||
'revisit=301.0ms',
|
||||
'maxTimerDrift=151.0ms',
|
||||
'scroll=151.0ms',
|
||||
'restore=1001.0ms',
|
||||
@@ -96,6 +98,7 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms')
|
||||
expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms')
|
||||
expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms')
|
||||
expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms')
|
||||
expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms')
|
||||
expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms')
|
||||
@@ -116,6 +119,31 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
expect(result.stderr).toContain('no recognized budget metrics found')
|
||||
})
|
||||
|
||||
it('accepts revisit-only marker rows as budgeted perf evidence', () => {
|
||||
const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184')
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
|
||||
})
|
||||
|
||||
it('accepts parked-memory rows that carry only heap and view-count metrics', () => {
|
||||
const reportPath = writeReport(
|
||||
'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1',
|
||||
'opencode-parked-memory'
|
||||
)
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
|
||||
})
|
||||
|
||||
it('fails OpenCode annotation rows that contain no budget metrics', () => {
|
||||
const reportPath = writeReport('panes=1 frames=60')
|
||||
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
budgetFailures,
|
||||
collectTerminalPerfRows,
|
||||
compareScenarios,
|
||||
escapeHtml,
|
||||
formatLargeValue,
|
||||
formatMs,
|
||||
readJsonReport,
|
||||
scenarioTitle
|
||||
} from './terminal-perf-report-rows.mjs'
|
||||
import { basename, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html'
|
||||
|
||||
// Why: every tracked metric is lower-is-better, so delta coloring and the
|
||||
// regression table share one direction rule.
|
||||
const MS_METRICS = [
|
||||
{ key: 'medianMs', label: 'Typing median', chart: true },
|
||||
{ key: 'worstMs', label: 'Typing worst', chart: true },
|
||||
{ key: 'scrollMs', label: 'Active scroll', chart: true },
|
||||
{ key: 'restoreMs', label: 'Restore', chart: true },
|
||||
{ key: 'revisitMs', label: 'Revisit marker', chart: true },
|
||||
{ key: 'maxTimerDriftMs', label: 'Timer drift', chart: false }
|
||||
]
|
||||
|
||||
const COUNT_METRICS = [
|
||||
{ key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' },
|
||||
{ key: 'mainPeakInFlightChars', label: 'Main in-flight chars' },
|
||||
{ key: 'mainPeakPendingChars', label: 'Main pending chars' },
|
||||
{ key: 'hiddenSkippedChars', label: 'Hidden skipped chars' },
|
||||
{ key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' },
|
||||
// Why: parked-memory scenarios are table-only — heap/view counts have no
|
||||
// ms trend story, so they stay out of the charts.
|
||||
{ key: 'heapUsedMB', label: 'Renderer JS heap (MB)' },
|
||||
{ key: 'liveTerminals', label: 'Live xterm instances' },
|
||||
{ key: 'livePaneManagers', label: 'Live pane managers' }
|
||||
]
|
||||
|
||||
const SERIES_COLORS = {
|
||||
medianMs: '#2563eb',
|
||||
worstMs: '#dc2626',
|
||||
scrollMs: '#d97706',
|
||||
restoreMs: '#7c3aed',
|
||||
revisitMs: '#0d9488'
|
||||
}
|
||||
|
||||
const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/
|
||||
|
||||
export function parseHtmlReportArgs(argv, env = process.env) {
|
||||
const args = [...argv]
|
||||
if (args[0] === '--') {
|
||||
args.shift()
|
||||
}
|
||||
|
||||
const inputs = []
|
||||
let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
if (arg === '--output' || arg === '-o') {
|
||||
const next = args[index + 1]
|
||||
if (!next || next.startsWith('-')) {
|
||||
throw new Error(`${arg} requires a path`)
|
||||
}
|
||||
outputPath = next
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--output=')) {
|
||||
outputPath = arg.slice('--output='.length)
|
||||
continue
|
||||
}
|
||||
const labeled = arg.match(LABELED_INPUT_RE)
|
||||
if (labeled) {
|
||||
inputs.push({ label: labeled[1], path: labeled[2] })
|
||||
} else {
|
||||
inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg })
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.length === 0) {
|
||||
throw new Error(
|
||||
'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]<playwright-json>... --output <report.html>'
|
||||
)
|
||||
}
|
||||
return { inputs, outputPath }
|
||||
}
|
||||
|
||||
// ── Trend data ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildMatrix(revisions) {
|
||||
const scenarios = new Map()
|
||||
for (const revision of revisions) {
|
||||
for (const row of revision.rows) {
|
||||
if (!scenarios.has(row.scenario)) {
|
||||
scenarios.set(row.scenario, new Map())
|
||||
}
|
||||
scenarios.get(row.scenario).set(revision.label, row)
|
||||
}
|
||||
}
|
||||
const orderedScenarios = [...scenarios.keys()].sort(compareScenarios)
|
||||
return { scenarios, orderedScenarios }
|
||||
}
|
||||
|
||||
function niceCeil(value) {
|
||||
if (value <= 0) {
|
||||
return 1
|
||||
}
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value))
|
||||
for (const step of [1, 2, 2.5, 5, 10]) {
|
||||
if (value <= step * magnitude) {
|
||||
return step * magnitude
|
||||
}
|
||||
}
|
||||
return 10 * magnitude
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
function renderTrendChart({ scenario, byRevision, revisions, title }) {
|
||||
const metrics = MS_METRICS.filter(
|
||||
(metric) =>
|
||||
metric.chart &&
|
||||
revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null)
|
||||
)
|
||||
if (metrics.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const width = 560
|
||||
const height = 230
|
||||
const pad = { left: 52, right: 14, top: 30, bottom: 38 }
|
||||
const plotW = width - pad.left - pad.right
|
||||
const plotH = height - pad.top - pad.bottom
|
||||
const maxValue = Math.max(
|
||||
1,
|
||||
...metrics.flatMap((metric) =>
|
||||
revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0)
|
||||
)
|
||||
)
|
||||
const yMax = niceCeil(maxValue * 1.15)
|
||||
const xFor = (index) =>
|
||||
pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1))
|
||||
const yFor = (value) => pad.top + plotH - (plotH * value) / yMax
|
||||
|
||||
const parts = []
|
||||
parts.push(
|
||||
`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(title)}" class="trend-chart">`
|
||||
)
|
||||
parts.push(`<text x="${pad.left}" y="16" class="chart-title">${escapeHtml(title)}</text>`)
|
||||
// Horizontal gridlines + y labels
|
||||
const ticks = 4
|
||||
for (let tick = 0; tick <= ticks; tick += 1) {
|
||||
const value = (yMax * tick) / ticks
|
||||
const y = yFor(value)
|
||||
parts.push(
|
||||
`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="gridline"/>`
|
||||
)
|
||||
parts.push(
|
||||
`<text x="${pad.left - 6}" y="${y + 3}" class="axis-label" text-anchor="end">${value % 1 === 0 ? value : value.toFixed(1)}</text>`
|
||||
)
|
||||
}
|
||||
// X labels
|
||||
revisions.forEach((revision, index) => {
|
||||
parts.push(
|
||||
`<text x="${xFor(index)}" y="${height - pad.bottom + 16}" class="axis-label" text-anchor="middle">${escapeHtml(revision.label)}</text>`
|
||||
)
|
||||
})
|
||||
// Series
|
||||
for (const metric of metrics) {
|
||||
const color = SERIES_COLORS[metric.key] ?? '#475569'
|
||||
const points = revisions
|
||||
.map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] }))
|
||||
.filter((point) => point.value != null)
|
||||
if (points.length === 0) {
|
||||
continue
|
||||
}
|
||||
const path = points
|
||||
.map(
|
||||
(point, order) =>
|
||||
`${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}`
|
||||
)
|
||||
.join(' ')
|
||||
parts.push(`<path d="${path}" fill="none" stroke="${color}" stroke-width="2"/>`)
|
||||
for (const point of points) {
|
||||
const x = xFor(point.index)
|
||||
const y = yFor(point.value)
|
||||
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}"/>`)
|
||||
parts.push(
|
||||
`<text x="${x.toFixed(1)}" y="${(y - 7).toFixed(1)}" class="point-label" text-anchor="middle" fill="${color}">${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}</text>`
|
||||
)
|
||||
}
|
||||
}
|
||||
parts.push('</svg>')
|
||||
|
||||
const legend = metrics
|
||||
.map((metric) => {
|
||||
const color = SERIES_COLORS[metric.key] ?? '#475569'
|
||||
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(metric.label)}</span>`
|
||||
})
|
||||
.join('')
|
||||
return `<figure class="chart-card" data-scenario="${escapeHtml(scenario)}">${parts.join('')}<figcaption class="legend">${legend} <span class="legend-unit">ms — lower is better</span></figcaption></figure>`
|
||||
}
|
||||
|
||||
function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) {
|
||||
if (baseline == null || latest == null) {
|
||||
return '<td class="delta">—</td>'
|
||||
}
|
||||
const diff = latest - baseline
|
||||
const pct = baseline === 0 ? null : (diff / baseline) * 100
|
||||
let cls = 'neutral'
|
||||
if (zeroBudget) {
|
||||
cls = latest > 0 ? 'worse' : 'better'
|
||||
} else if (pct != null && Math.abs(pct) >= 5) {
|
||||
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
|
||||
} else if (baseline === 0 && diff !== 0) {
|
||||
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
|
||||
}
|
||||
const pctLabel =
|
||||
pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%`
|
||||
const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}`
|
||||
return `<td class="delta ${cls}">${escapeHtml(pctLabel)} <span class="delta-abs">(${escapeHtml(diffLabel)})</span></td>`
|
||||
}
|
||||
|
||||
function renderScenarioTable({ scenario, byRevision, revisions, title }) {
|
||||
const metricRows = []
|
||||
const allMetrics = [...MS_METRICS, ...COUNT_METRICS]
|
||||
for (const metric of allMetrics) {
|
||||
const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key])
|
||||
if (values.every((value) => value == null)) {
|
||||
continue
|
||||
}
|
||||
const isMs = MS_METRICS.includes(metric)
|
||||
const format = isMs ? formatMs : formatLargeValue
|
||||
const cells = values
|
||||
.map((value) => `<td>${value == null ? '—' : escapeHtml(format(value))}</td>`)
|
||||
.join('')
|
||||
const baseline = values.find((value) => value != null)
|
||||
const latest = [...values].reverse().find((value) => value != null)
|
||||
metricRows.push(
|
||||
`<tr><th scope="row">${escapeHtml(metric.label)}</th>${cells}${deltaCell(baseline, latest, {
|
||||
zeroBudget: metric.key === 'rendererDroppedBacklogs'
|
||||
})}</tr>`
|
||||
)
|
||||
}
|
||||
if (metricRows.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const headers = revisions.map((revision) => `<th>${escapeHtml(revision.label)}</th>`).join('')
|
||||
return `<section class="scenario-block">
|
||||
<h3>${escapeHtml(title)} <span class="scenario-id">${escapeHtml(scenario)}</span></h3>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Metric</th>${headers}<th>Δ first → last</th></tr></thead>
|
||||
<tbody>${metricRows.join('')}</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
}
|
||||
|
||||
function renderHeadline(revisions, matrix) {
|
||||
if (revisions.length < 2) {
|
||||
return ''
|
||||
}
|
||||
const first = revisions[0]
|
||||
const last = revisions.at(-1)
|
||||
const cards = []
|
||||
for (const scenario of matrix.orderedScenarios) {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const baseRow = byRevision.get(first.label)
|
||||
const lastRow = byRevision.get(last.label)
|
||||
if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) {
|
||||
continue
|
||||
}
|
||||
const diff = lastRow.medianMs - baseRow.medianMs
|
||||
const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100
|
||||
const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse'
|
||||
cards.push(`<div class="card ${cls}">
|
||||
<div class="card-title">${escapeHtml(scenarioTitle(scenario, lastRow))}</div>
|
||||
<div class="card-value">${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}</div>
|
||||
<div class="card-sub">typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)</div>
|
||||
</div>`)
|
||||
}
|
||||
if (cards.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return `<section><h2>Baseline vs latest</h2><div class="cards">${cards.join('')}</div></section>`
|
||||
}
|
||||
|
||||
function renderBudgets(latestRevision) {
|
||||
const failures = []
|
||||
for (const row of latestRevision.rows) {
|
||||
for (const failure of budgetFailures(row)) {
|
||||
failures.push(`${row.scenario}: ${failure}`)
|
||||
}
|
||||
}
|
||||
const status =
|
||||
failures.length === 0 ? '<span class="pass">Pass</span>' : '<span class="fail">Fail</span>'
|
||||
const failureList =
|
||||
failures.length === 0
|
||||
? ''
|
||||
: `<ul>${failures.map((failure) => `<li>${escapeHtml(failure)}</li>`).join('')}</ul>`
|
||||
return `<section><h2>Budget status — ${escapeHtml(latestRevision.label)}</h2>
|
||||
<p>${latestRevision.rows.length} scenario rows checked: ${status}</p>${failureList}</section>`
|
||||
}
|
||||
|
||||
function renderInputsMeta(revisions) {
|
||||
const items = revisions
|
||||
.map((revision) => {
|
||||
const stats = revision.stats
|
||||
const statsLabel = stats
|
||||
? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky`
|
||||
: ''
|
||||
const failNote =
|
||||
stats && stats.unexpected > 0
|
||||
? ' <span class="meta-warn">(failed assertions at this revision; metrics still recorded)</span>'
|
||||
: ''
|
||||
return `<li><strong>${escapeHtml(revision.label)}</strong> — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}</li>`
|
||||
})
|
||||
.join('')
|
||||
return `<ol class="inputs">${items}</ol>`
|
||||
}
|
||||
|
||||
function renderRawDetails(revisions) {
|
||||
return revisions
|
||||
.map((revision) => {
|
||||
const rows = revision.rows
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr><td>${escapeHtml(row.scenario)}</td><td>${row.panes ?? '—'}</td><td>${escapeHtml(formatMs(row.medianMs))}</td><td>${escapeHtml(formatMs(row.worstMs))}</td><td>${escapeHtml(formatMs(row.scrollMs))}</td><td>${escapeHtml(formatMs(row.restoreMs))}</td><td>${escapeHtml(formatMs(row.revisitMs))}</td><td>${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}</td><td>${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}</td><td>${row.rendererDroppedBacklogs ?? '—'}</td></tr>`
|
||||
)
|
||||
.join('')
|
||||
return `<details><summary>Raw rows — ${escapeHtml(revision.label)}</summary>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Scenario</th><th>Panes</th><th>Median</th><th>Worst</th><th>Scroll</th><th>Restore</th><th>Revisit</th><th>Renderer peak</th><th>Hidden skipped</th><th>Drops</th></tr></thead>
|
||||
<tbody>${rows}</tbody></table></details>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const PAGE_CSS = `
|
||||
:root { color-scheme: light; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; }
|
||||
h1 { font-size: 24px; margin-bottom: 4px; }
|
||||
h2 { font-size: 18px; margin: 28px 0 10px; }
|
||||
h3 { font-size: 15px; margin: 18px 0 6px; }
|
||||
.meta { color: #64748b; font-size: 13px; }
|
||||
.inputs { font-size: 13px; color: #334155; padding-left: 20px; }
|
||||
.meta-warn { color: #b45309; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
|
||||
.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; }
|
||||
.card.better { border-left-color: #16a34a; }
|
||||
.card.worse { border-left-color: #dc2626; }
|
||||
.card.neutral { border-left-color: #94a3b8; }
|
||||
.card-title { font-size: 12px; color: #64748b; }
|
||||
.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; }
|
||||
.card-sub { font-size: 11px; color: #94a3b8; }
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; }
|
||||
.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
|
||||
.trend-chart { width: 100%; height: auto; }
|
||||
.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; }
|
||||
.gridline { stroke: #e2e8f0; stroke-width: 1; }
|
||||
.axis-label { font-size: 10px; fill: #64748b; }
|
||||
.point-label { font-size: 10px; font-weight: 600; }
|
||||
.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
.legend-unit { color: #94a3b8; margin-left: auto; }
|
||||
.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; }
|
||||
.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; }
|
||||
table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
||||
table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; }
|
||||
table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; }
|
||||
table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; }
|
||||
td.delta.better { color: #15803d; font-weight: 600; }
|
||||
td.delta.worse { color: #b91c1c; font-weight: 600; }
|
||||
td.delta.neutral { color: #64748b; }
|
||||
.delta-abs { font-weight: 400; color: #94a3b8; }
|
||||
.pass { color: #15803d; font-weight: 700; }
|
||||
.fail { color: #b91c1c; font-weight: 700; }
|
||||
details { margin: 8px 0; }
|
||||
summary { cursor: pointer; font-size: 13px; color: #334155; }
|
||||
`
|
||||
|
||||
function renderHtml({ generatedAt, revisions }) {
|
||||
const matrix = buildMatrix(revisions)
|
||||
const charts =
|
||||
revisions.length >= 2
|
||||
? matrix.orderedScenarios
|
||||
.map((scenario) => {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const anyRow = [...byRevision.values()][0]
|
||||
return renderTrendChart({
|
||||
scenario,
|
||||
byRevision,
|
||||
revisions,
|
||||
title: scenarioTitle(scenario, anyRow)
|
||||
})
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
: ''
|
||||
const tables = matrix.orderedScenarios
|
||||
.map((scenario) => {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const anyRow = [...byRevision.values()][0]
|
||||
return renderScenarioTable({
|
||||
scenario,
|
||||
byRevision,
|
||||
revisions,
|
||||
title: scenarioTitle(scenario, anyRow)
|
||||
})
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Terminal Performance Over Time</title>
|
||||
<style>${PAGE_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Terminal Performance Over Time</h1>
|
||||
<p class="meta">Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.</p>
|
||||
${renderInputsMeta(revisions)}
|
||||
${renderHeadline(revisions, matrix)}
|
||||
${charts ? `<section><h2>Trends across revisions</h2><div class="charts">${charts}</div></section>` : ''}
|
||||
<section><h2>Metric detail by scenario</h2>${tables}</section>
|
||||
${renderBudgets(revisions.at(-1))}
|
||||
<section><h2>Raw data</h2>${renderRawDetails(revisions)}</section>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
export function generateTerminalPerfHtmlReport({
|
||||
inputs,
|
||||
inputPaths,
|
||||
outputPath,
|
||||
now = new Date()
|
||||
}) {
|
||||
// Why: older callers (the scale report gate) pass bare inputPaths.
|
||||
const resolvedInputs =
|
||||
inputs ??
|
||||
(inputPaths ?? []).map((path) => ({
|
||||
label: basename(path).replace(/\.json$/i, ''),
|
||||
path
|
||||
}))
|
||||
const revisions = resolvedInputs.map(({ label, path }) => {
|
||||
const report = readJsonReport(path)
|
||||
return {
|
||||
label,
|
||||
path,
|
||||
stats: report.stats ?? null,
|
||||
rows: collectTerminalPerfRows(report, label)
|
||||
}
|
||||
})
|
||||
const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0)
|
||||
if (totalRows === 0) {
|
||||
throw new Error('No opencode terminal perf annotations found in the provided reports')
|
||||
}
|
||||
const html = renderHtml({ generatedAt: now.toISOString(), revisions })
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
writeFileSync(outputPath, html)
|
||||
const latestFailures = revisions
|
||||
.at(-1)
|
||||
.rows.reduce((sum, row) => sum + budgetFailures(row).length, 0)
|
||||
return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures }
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
|
||||
if (isMain) {
|
||||
try {
|
||||
const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2))
|
||||
const result = generateTerminalPerfHtmlReport({ inputs, outputPath })
|
||||
console.log(
|
||||
`Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
generateTerminalPerfHtmlReport,
|
||||
parseHtmlReportArgs
|
||||
} from './generate-terminal-perf-html-report.mjs'
|
||||
|
||||
const tempDirs = []
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function writeReport(
|
||||
annotationDescription,
|
||||
annotationType = 'opencode-scale-same-workspace-25',
|
||||
reportName = 'report.json'
|
||||
) {
|
||||
const dir = makeTempDir()
|
||||
const reportPath = join(dir, reportName)
|
||||
writeFileSync(
|
||||
reportPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: annotationType,
|
||||
description: annotationDescription
|
||||
},
|
||||
{
|
||||
type: 'browser-unrelated',
|
||||
description: 'median=999.0ms'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
return reportPath
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('generate-terminal-perf-html-report', () => {
|
||||
it('parses labeled and bare input paths plus output flags', () => {
|
||||
expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({
|
||||
inputs: [
|
||||
{ label: 'a', path: 'a.json' },
|
||||
{ label: 'b', path: 'b.json' }
|
||||
],
|
||||
outputPath: 'out.html'
|
||||
})
|
||||
expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual(
|
||||
{
|
||||
inputs: [
|
||||
{ label: 'main', path: 'runs/0-main.json' },
|
||||
{ label: '#5038 final', path: 'runs/4-final.json' }
|
||||
],
|
||||
outputPath: 'test-results/terminal-perf-impact-report.html'
|
||||
}
|
||||
)
|
||||
expect(
|
||||
parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' })
|
||||
).toEqual({
|
||||
inputs: [{ label: 'a', path: 'a.json' }],
|
||||
outputPath: 'env.html'
|
||||
})
|
||||
expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path')
|
||||
expect(() => parseHtmlReportArgs([])).toThrow('Usage:')
|
||||
})
|
||||
|
||||
it('writes a single-run report with scenario tables and budget status', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=25',
|
||||
'frames=60',
|
||||
'median=12.4ms',
|
||||
'worst=44.8ms',
|
||||
'revisit=28.6ms',
|
||||
'scroll=61.0ms',
|
||||
'restore=320.0ms',
|
||||
'maxTimerDrift=8.0ms',
|
||||
'rendererPeakQueuedChars=2048',
|
||||
'mainPeakInFlightChars=4096',
|
||||
'heldAckChars=1024',
|
||||
'hiddenSkippedChars=512',
|
||||
'rendererDroppedBacklogs=0'
|
||||
].join(' ')
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({
|
||||
inputPaths: [reportPath],
|
||||
outputPath,
|
||||
now: new Date('2026-06-09T10:00:00.000Z')
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 })
|
||||
expect(html).toContain('<!doctype html>')
|
||||
expect(html).toContain('Terminal Performance Over Time')
|
||||
expect(html).toContain('2026-06-09T10:00:00.000Z')
|
||||
expect(html).toContain('Same workspace panes — 25 panes')
|
||||
expect(html).toContain('opencode-scale-same-workspace-25')
|
||||
expect(html).toContain('28.6ms')
|
||||
expect(html).toContain('Pass')
|
||||
// Why: one run has no over-time story; the trend section must not render.
|
||||
expect(html).not.toContain('Trends across revisions')
|
||||
expect(html).not.toContain('browser-unrelated')
|
||||
})
|
||||
|
||||
it('renders parked-memory heap and live view counts as table metrics', () => {
|
||||
const reportPath = writeReport(
|
||||
'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1',
|
||||
'opencode-parked-memory'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
// Why: heapUsedMB has no budget — a memory row alone must not fail gates.
|
||||
expect(result.budgetFailureCount).toBe(0)
|
||||
expect(html).toContain('Parked hidden terminal memory — 8 panes')
|
||||
expect(html).toContain('Renderer JS heap (MB)')
|
||||
expect(html).toContain('142.5')
|
||||
expect(html).toContain('Live xterm instances')
|
||||
expect(html).toContain('Live pane managers')
|
||||
})
|
||||
|
||||
it('marks over-budget rows as failures for the latest run', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=100',
|
||||
'median=80.0ms',
|
||||
'worst=301.0ms',
|
||||
'revisit=301.0ms',
|
||||
'rendererPeakQueuedChars=2097153',
|
||||
'rendererDroppedBacklogs=1'
|
||||
].join(' '),
|
||||
'opencode-scale-cross-workspace-100'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result.budgetFailureCount).toBe(5)
|
||||
expect(html).toContain('Fail')
|
||||
expect(html).toContain('medianMs 80 > 75')
|
||||
expect(html).toContain('Cross-workspace hidden panes')
|
||||
})
|
||||
|
||||
it('renders ordered revisions with trend charts and baseline deltas', () => {
|
||||
const mainReport = writeReport(
|
||||
'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'main.json'
|
||||
)
|
||||
const middleReport = writeReport(
|
||||
'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'backpressure.json'
|
||||
)
|
||||
const finalReport = writeReport(
|
||||
'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'final.json'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({
|
||||
inputs: [
|
||||
{ label: 'main', path: mainReport },
|
||||
{ label: 'backpressure', path: middleReport },
|
||||
{ label: 'final', path: finalReport }
|
||||
],
|
||||
outputPath
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result.rowCount).toBe(3)
|
||||
expect(html).toContain('Baseline vs latest')
|
||||
expect(html).toContain('Trends across revisions')
|
||||
expect(html).toContain('trend-chart')
|
||||
expect(html).toContain('>main<')
|
||||
expect(html).toContain('>backpressure<')
|
||||
expect(html).toContain('>final<')
|
||||
// Why: median 50 -> 20 is a 60% improvement and must read as better.
|
||||
expect(html).toContain('delta better')
|
||||
expect(html).toContain('-60%')
|
||||
expect(html).toContain('50.0ms → 20.0ms')
|
||||
})
|
||||
|
||||
it('renders missing scenarios at older revisions as gaps, not zeros', () => {
|
||||
const mainReport = writeReport(
|
||||
'panes=25 median=50.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'main.json'
|
||||
)
|
||||
const finalReport = makeTempDir()
|
||||
const finalPath = join(finalReport, 'final.json')
|
||||
writeFileSync(
|
||||
finalPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale-same-workspace-25',
|
||||
description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0'
|
||||
},
|
||||
{
|
||||
type: 'opencode-revisit-pressure',
|
||||
description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
generateTerminalPerfHtmlReport({
|
||||
inputs: [
|
||||
{ label: 'main', path: mainReport },
|
||||
{ label: 'final', path: finalPath }
|
||||
],
|
||||
outputPath
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(html).toContain('Revisit under pressure')
|
||||
expect(html).toContain('<td>—</td>')
|
||||
})
|
||||
|
||||
it('fails when reports contain no terminal perf annotations', () => {
|
||||
const reportPath = writeReport('median=12.0ms', 'browser-unrelated')
|
||||
|
||||
expect(() =>
|
||||
generateTerminalPerfHtmlReport({
|
||||
inputPaths: [reportPath],
|
||||
outputPath: join(makeTempDir(), 'report.html')
|
||||
})
|
||||
).toThrow('No opencode terminal perf annotations found')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const DEFAULT_REPORT_PATH = 'test-results/terminal-scale-perf-report.json'
|
||||
const DEFAULT_HTML_REPORT_PATH = 'test-results/terminal-perf-impact-report.html'
|
||||
|
||||
export function parseReportGateArgs(argv, env = process.env) {
|
||||
const forwardedArgs = [...argv]
|
||||
@@ -114,7 +115,20 @@ export function runTerminalScalePerfReportGate({
|
||||
spawnSyncImpl,
|
||||
env
|
||||
)
|
||||
return exitCode(budgetResult)
|
||||
const budgetExitCode = exitCode(budgetResult)
|
||||
if (budgetExitCode !== 0) {
|
||||
return budgetExitCode
|
||||
}
|
||||
|
||||
const htmlReportPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_HTML_REPORT_PATH
|
||||
const htmlResult = runNodeScript(
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs',
|
||||
[reportPath, '--output', htmlReportPath],
|
||||
'inherit',
|
||||
spawnSyncImpl,
|
||||
env
|
||||
)
|
||||
return exitCode(htmlResult)
|
||||
}
|
||||
|
||||
if (process.argv[1] === import.meta.filename) {
|
||||
|
||||
@@ -77,7 +77,8 @@ describe('run-terminal-scale-perf-report-gate', () => {
|
||||
expect(calls.map((call) => call.args[0])).toEqual([
|
||||
'config/scripts/run-terminal-scale-perf-e2e.mjs',
|
||||
'config/scripts/summarize-terminal-perf-report.mjs',
|
||||
'config/scripts/check-terminal-perf-report-budgets.mjs'
|
||||
'config/scripts/check-terminal-perf-report-budgets.mjs',
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs'
|
||||
])
|
||||
expect(calls[0].args).toEqual([
|
||||
'config/scripts/run-terminal-scale-perf-e2e.mjs',
|
||||
@@ -92,6 +93,12 @@ describe('run-terminal-scale-perf-report-gate', () => {
|
||||
'config/scripts/check-terminal-perf-report-budgets.mjs',
|
||||
reportPath
|
||||
])
|
||||
expect(calls[3].args).toEqual([
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs',
|
||||
reportPath,
|
||||
'--output',
|
||||
'test-results/terminal-perf-impact-report.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the report path from env when no flag is provided', () => {
|
||||
@@ -107,6 +114,28 @@ describe('run-terminal-scale-perf-report-gate', () => {
|
||||
expect(calls[1].args).toEqual(['config/scripts/summarize-terminal-perf-report.mjs', reportPath])
|
||||
})
|
||||
|
||||
it('uses the HTML report path from env when provided', () => {
|
||||
const reportPath = tempReportPath()
|
||||
const { calls, spawnSyncImpl } = makeSpawnSync()
|
||||
|
||||
const status = runTerminalScalePerfReportGate({
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'tmp/terminal-report.html',
|
||||
ORCA_E2E_TERMINAL_PERF_REPORT_PATH: reportPath
|
||||
},
|
||||
spawnSyncImpl
|
||||
})
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(calls[3].args).toEqual([
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs',
|
||||
reportPath,
|
||||
'--output',
|
||||
'tmp/terminal-report.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the report when Playwright clears the target report directory', () => {
|
||||
const reportPath = tempReportPath()
|
||||
const { spawnSyncImpl } = makeSpawnSync({
|
||||
|
||||
@@ -25,6 +25,7 @@ function printMarkdownTable(rows) {
|
||||
['Frames', 'frames'],
|
||||
['Median', 'median'],
|
||||
['Worst', 'worst'],
|
||||
['Revisit', 'revisit'],
|
||||
['Scroll', 'scroll'],
|
||||
['Restore', 'restore'],
|
||||
['Max Drift', 'maxTimerDrift'],
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const BUDGETS = {
|
||||
medianMs: 75,
|
||||
worstMs: 300,
|
||||
revisitMs: 300,
|
||||
maxTimerDriftMs: 150,
|
||||
scrollMs: 150,
|
||||
restoreMs: 1000,
|
||||
rendererQueuedChars: 2 * 1024 * 1024,
|
||||
rendererPeakQueuedChars: 2 * 1024 * 1024,
|
||||
rendererDroppedBacklogs: 0
|
||||
}
|
||||
|
||||
const SCENARIO_LABELS = [
|
||||
['opencode-scale-same-workspace', 'Same workspace panes'],
|
||||
['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'],
|
||||
['opencode-scale-pressure', 'ACK-backpressured PTYs'],
|
||||
['opencode-scale-hidden-pressure', 'Hidden real PTYs'],
|
||||
['opencode-cross-workspace-typing', 'Cross-workspace typing'],
|
||||
['opencode-main-pressure', 'Main renderer pressure'],
|
||||
['opencode-hidden-pressure', 'Hidden pressure'],
|
||||
['opencode-revisit-pressure', 'Revisit under pressure'],
|
||||
// Why: the prefix also matches opencode-parked-memory-disabled, so both
|
||||
// parked-memory scenarios group under one label.
|
||||
['opencode-parked-memory', 'Parked hidden terminal memory']
|
||||
]
|
||||
|
||||
export function readJsonReport(path) {
|
||||
const raw = readFileSync(path, 'utf8')
|
||||
const start = raw.indexOf('{')
|
||||
const end = raw.lastIndexOf('}')
|
||||
if (start === -1 || end <= start) {
|
||||
throw new Error(`${path}: no JSON object found`)
|
||||
}
|
||||
return JSON.parse(raw.slice(start, end + 1))
|
||||
}
|
||||
|
||||
function parseAnnotationDescription(description) {
|
||||
const values = {}
|
||||
for (const part of description.split(/\s+/)) {
|
||||
const index = part.indexOf('=')
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
values[part.slice(0, index)] = part.slice(index + 1)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export function collectTerminalPerfRows(report, source) {
|
||||
const rows = []
|
||||
const visitSuite = (suite) => {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const annotation of test.annotations ?? []) {
|
||||
if (!annotation.type.startsWith('opencode-')) {
|
||||
continue
|
||||
}
|
||||
rows.push(
|
||||
normalizeRow({
|
||||
source,
|
||||
scenario: annotation.type,
|
||||
...parseAnnotationDescription(annotation.description ?? '')
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of suite.suites ?? []) {
|
||||
visitSuite(child)
|
||||
}
|
||||
}
|
||||
for (const suite of report.suites ?? []) {
|
||||
visitSuite(suite)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function parseMs(value) {
|
||||
const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function parseCount(value) {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
}
|
||||
const count = Number(value)
|
||||
return Number.isFinite(count) ? count : null
|
||||
}
|
||||
|
||||
function normalizeRow(row) {
|
||||
return {
|
||||
...row,
|
||||
group: scenarioGroup(row.scenario),
|
||||
panes: parseCount(row.panes),
|
||||
frames: parseCount(row.frames),
|
||||
medianMs: parseMs(row.median),
|
||||
worstMs: parseMs(row.worst),
|
||||
revisitMs: parseMs(row.revisit),
|
||||
maxTimerDriftMs: parseMs(row.maxTimerDrift),
|
||||
scrollMs: parseMs(row.scroll),
|
||||
restoreMs: parseMs(row.restore),
|
||||
rendererQueuedChars: parseCount(row.rendererQueuedChars),
|
||||
rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars),
|
||||
rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs),
|
||||
mainPeakPendingChars: parseCount(row.mainPeakPendingChars),
|
||||
mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars),
|
||||
heldAckChars: parseCount(row.heldAckChars),
|
||||
hiddenSkippedChars: parseCount(row.hiddenSkippedChars),
|
||||
// Why: parked-memory annotations report a fractional MB heap figure plus
|
||||
// live renderer view counts; Number() keeps the MB float intact.
|
||||
heapUsedMB: parseCount(row.heapUsedMB),
|
||||
liveTerminals: parseCount(row.liveTerminals),
|
||||
livePaneManagers: parseCount(row.livePaneManagers)
|
||||
}
|
||||
}
|
||||
|
||||
export function scenarioGroup(scenario) {
|
||||
for (const [prefix, label] of SCENARIO_LABELS) {
|
||||
if (scenario.startsWith(prefix)) {
|
||||
return label
|
||||
}
|
||||
}
|
||||
return 'Other terminal scenarios'
|
||||
}
|
||||
|
||||
function scenarioSortKey(scenario) {
|
||||
const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix))
|
||||
const paneMatch = scenario.match(/-(\d+)$/)
|
||||
return [
|
||||
prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex,
|
||||
paneMatch ? Number(paneMatch[1]) : 0,
|
||||
scenario
|
||||
]
|
||||
}
|
||||
|
||||
export function compareScenarios(a, b) {
|
||||
const ka = scenarioSortKey(a)
|
||||
const kb = scenarioSortKey(b)
|
||||
if (ka[0] !== kb[0]) {
|
||||
return ka[0] - kb[0]
|
||||
}
|
||||
if (ka[1] !== kb[1]) {
|
||||
return ka[1] - kb[1]
|
||||
}
|
||||
return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0
|
||||
}
|
||||
|
||||
export function scenarioTitle(scenario, row) {
|
||||
const group = scenarioGroup(scenario)
|
||||
if (row?.panes != null) {
|
||||
return `${group} — ${row.panes} panes`
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
export function budgetFailures(row) {
|
||||
const failures = []
|
||||
for (const [key, budget] of Object.entries(BUDGETS)) {
|
||||
const value = row[key]
|
||||
if (value == null) {
|
||||
continue
|
||||
}
|
||||
if (value > budget) {
|
||||
failures.push(`${key} ${value} > ${budget}`)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
export function formatMs(value) {
|
||||
if (value == null) {
|
||||
return '—'
|
||||
}
|
||||
return `${value.toFixed(1)}ms`
|
||||
}
|
||||
|
||||
export function formatLargeValue(value) {
|
||||
if (value == null) {
|
||||
return '—'
|
||||
}
|
||||
if (value >= 1024 * 1024) {
|
||||
return `${(value / (1024 * 1024)).toFixed(2)}M`
|
||||
}
|
||||
if (value >= 1024) {
|
||||
return `${Math.round(value / 1024)}k`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# Terminal Hidden View Parking
|
||||
|
||||
Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch
|
||||
`terminalHiddenViewParking` (default on). See
|
||||
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the
|
||||
invariants this design extends and the full phase list.
|
||||
|
||||
## Problem
|
||||
|
||||
Hidden terminal panes keep a full renderer xterm instance alive (buffer,
|
||||
scrollback, DOM, addons). At many-worktree scale this is the dominant renderer
|
||||
memory cost, and it forces every hidden byte through renderer-side write/skip
|
||||
decisions. The main-process model (daemon + runtime headless emulators) already
|
||||
ingests every byte and can serve restorable snapshots, so the renderer view for
|
||||
a long-hidden pane is redundant state.
|
||||
|
||||
A previous attempt shipped and was reverted the same day. The post-mortem
|
||||
finding: parking unmounted the pane component, which also tore down the
|
||||
renderer's PTY byte parsers — and those parsers are the only source of bell
|
||||
notifications, title-transition agent-complete notifications, and tab titles.
|
||||
A parked worktree whose agent finished would never notify. This design keeps
|
||||
those side effects alive while parked.
|
||||
|
||||
## Design
|
||||
|
||||
### Park policy (renderer)
|
||||
|
||||
A pure policy module decides which hidden terminal tabs may park:
|
||||
|
||||
- Cold-park hysteresis: a tab must be hidden for 30s before parking.
|
||||
- Hot-retain working set: recently visible worktrees/tabs are retained
|
||||
(5 minutes, bounded count) so quick tab switches never pay a re-hydrate.
|
||||
- Eligibility excludes: visible panes, hidden-measuring startup probes,
|
||||
activity-portal panes, tabs with pending startup commands or pending
|
||||
activation spawns, floating-panel tabs, and any tab whose PTY is not
|
||||
snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded).
|
||||
- Kill switch: `settings.terminalHiddenViewParking === false` disables parking
|
||||
entirely.
|
||||
|
||||
### Park mechanics
|
||||
|
||||
Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer
|
||||
renders null for parked tabs). This is the same teardown that tab-group moves
|
||||
already exercise: transports detach but the PTY session, daemon model, and tab
|
||||
state all survive. The xterm instance, its buffers, DOM, and WebGL/addon
|
||||
resources are released.
|
||||
|
||||
### Parked watcher (the piece the reverted attempt lacked)
|
||||
|
||||
While a tab is parked, a pane-less watcher
|
||||
(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its
|
||||
consumption mode is decided once at watcher start:
|
||||
|
||||
- **Main side-effect authority on (default):** the watcher is purely
|
||||
fact-driven — it registers exactly one `pty:sideEffect` fact consumer and
|
||||
parses no bytes. Titles, agent working/idle/exited transitions, BEL
|
||||
attention, and PR links arrive as main-tracker facts and drive the same
|
||||
policy callbacks a mounted pane uses. With the hidden-delivery gate also on,
|
||||
the watcher marks the PTY hidden so main stops renderer byte delivery
|
||||
entirely; the DECSET 2031 color-scheme subscribe arrives as main's
|
||||
`2031-subscribe` fact and the watcher replies out-of-band via
|
||||
`transport.sendInput`.
|
||||
- **Kill switch off:** the watcher subscribes to raw bytes through the
|
||||
dispatcher sidecar mechanism (the same mechanism background agent launches
|
||||
use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2
|
||||
titles (all-titles ordering, live-path normalization), the title-transition
|
||||
agent tracker (completion notification, prompt-cache timer), the OSC-aware
|
||||
stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031
|
||||
byte responder (`parked-terminal-mode2031-responder.ts`, whose
|
||||
`subscribeToPtyData` registration doubles as the delivery-interest signal).
|
||||
|
||||
The two modes drive one shared policy-callback block, so flipping the kill
|
||||
switch never changes notification semantics. Main's synthetic
|
||||
agent-title/permission frames feed the main tracker directly and arrive as
|
||||
facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off
|
||||
mode.
|
||||
|
||||
Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside
|
||||
hidden-dropped chunks are answered by main's model responder
|
||||
([`terminal-query-authority.md`](./terminal-query-authority.md)); in
|
||||
kill-switch-off byte mode only the 2031 reply is answered and Command Code
|
||||
output is not scraped, matching the pre-gate status quo.
|
||||
|
||||
### Reveal
|
||||
|
||||
Revealing a parked tab remounts the pane subtree and rides the existing
|
||||
reattach path: fresh xterm via `openTerminal` (unicode provider activation
|
||||
before any write), daemon model snapshot > relay replay > cold restore
|
||||
precedence, replay-guarded so snapshot-embedded queries never answer, then
|
||||
`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is
|
||||
disposed before the pane handlers re-register.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. PTY reads never stop; parking only changes renderer-side view lifetime.
|
||||
2. Bell, agent-completion, title, and PR-link side effects keep working while
|
||||
parked (watcher parity tests).
|
||||
3. Reveal shows model-correct output (visual gates: hidden TUI restore, long
|
||||
table, rendering golden) and accepts input immediately.
|
||||
4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers
|
||||
or strand parked state.
|
||||
5. Memory: parked tabs hold no xterm buffers; renderer memory scales with
|
||||
visible panes.
|
||||
|
||||
## Relation to later phases (all shipped)
|
||||
|
||||
Side-effect authority in main (Phase 3) replaced the watcher's byte parsing
|
||||
with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4)
|
||||
stops hidden byte delivery in main, moving the parked 2031 reply from the
|
||||
byte sidecar to the `2031-subscribe` fact; the model query responder
|
||||
(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser
|
||||
mode survives only behind the kill switches. Parking still excludes
|
||||
remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher
|
||||
would return as a byte parser only if remote-runtime tabs — whose bytes never
|
||||
transit local main — ever became parkable.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Terminal Model/View Contract
|
||||
|
||||
## Goal
|
||||
|
||||
Terminal output should have one authoritative model path and many disposable
|
||||
views. A renderer xterm is the fast interactive view, but it must not be the
|
||||
only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists.
|
||||
|
||||
This contract defines the boundary the shipped terminal stack implements — and
|
||||
that future terminal work must preserve — without changing the query-response
|
||||
behavior that real shells and TUIs depend on. See [Architecture
|
||||
Status](#architecture-status) for the shipped phases.
|
||||
|
||||
## Terms
|
||||
|
||||
- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY,
|
||||
or remote runtime PTY.
|
||||
- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this
|
||||
is mostly the headless emulator plus retained read transcript state.
|
||||
- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop
|
||||
subscriber, or CLI read page consuming model state and live output.
|
||||
- **Snapshot:** A bounded model serialization that can restore a view without
|
||||
replaying an unbounded byte log.
|
||||
- **Transcript:** The retained output contract for `orca terminal read`; it is
|
||||
line/cursor oriented and distinct from a screen snapshot.
|
||||
|
||||
## Non-Negotiable Invariants
|
||||
|
||||
1. PTY reads do not stop to protect renderer performance. Backpressure may bound
|
||||
delivery to views, but terminal state, notifications, titles, and agent
|
||||
status keep advancing from the PTY stream.
|
||||
2. Active visible terminal input/output stays on the lowest-latency path. Bulk
|
||||
hidden or background output must not delay keystroke-sized foreground redraws.
|
||||
3. Hidden views do not own unbounded output memory. Main's hidden-delivery
|
||||
gate drops renderer-bound bytes for hidden-marked PTYs after model
|
||||
ingestion and emits an out-of-band restore marker
|
||||
(`pty:modelRestoreNeeded`) so the view restores from the model on reveal.
|
||||
With the gate's kill switches off, hidden bytes ride a bounded renderer
|
||||
queue whose overflow latches the same model restore.
|
||||
4. Returning to a hidden or slept terminal must show model-correct output. A
|
||||
stale or replaced view may be cleared and replayed from a snapshot, but it
|
||||
must not show a warning fallback when model recovery is available.
|
||||
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. Main buffer snapshots report the pending-delivery start sequence
|
||||
(`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a
|
||||
restore without misreading foreign sequence domains as duplicates.
|
||||
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.
|
||||
8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy
|
||||
the same model/view contract or explicitly report that model recovery is
|
||||
unavailable.
|
||||
|
||||
## Current Owners
|
||||
|
||||
| Responsibility | Current owner |
|
||||
| --- | --- |
|
||||
| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` |
|
||||
| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` |
|
||||
| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` |
|
||||
| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` |
|
||||
| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` |
|
||||
| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` |
|
||||
| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` |
|
||||
| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` |
|
||||
| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` |
|
||||
| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` |
|
||||
|
||||
## Snapshot Contract
|
||||
|
||||
A model snapshot must include:
|
||||
|
||||
- terminal dimensions used to produce the snapshot;
|
||||
- enough ANSI state to rehydrate xterm before snapshot content;
|
||||
- bounded screen and scrollback content;
|
||||
- title and cwd metadata when known;
|
||||
- source metadata that distinguishes headless/model snapshots from renderer
|
||||
fallback snapshots;
|
||||
- monotonic ordering metadata for live-output reconciliation when available.
|
||||
|
||||
A snapshot must not:
|
||||
|
||||
- include unbounded transcript history;
|
||||
- answer terminal queries while replaying into the model;
|
||||
- overwrite newer live view output with older model output;
|
||||
- hide that recovery was unavailable for a PTY surface.
|
||||
|
||||
## View Contract
|
||||
|
||||
A renderer or remote view may:
|
||||
|
||||
- write active visible output immediately;
|
||||
- budget visible inactive output;
|
||||
- stop receiving hidden output entirely while main's hidden-delivery gate owns
|
||||
the bytes (model restore on reveal);
|
||||
- request fresh snapshots for restore, mobile subscription, or explicit remote
|
||||
snapshot recovery.
|
||||
|
||||
A view must:
|
||||
|
||||
- keep live-output buffers bounded while a snapshot is in flight;
|
||||
- apply generation or sequence checks before replaying a snapshot;
|
||||
- refresh/repaint after replay when xterm/WebGL needs an explicit paint;
|
||||
- keep side effects such as title, bell, cwd, and agent status flowing from the
|
||||
PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte
|
||||
delivery is budgeted, gated, or parked.
|
||||
|
||||
## Transcript Contract
|
||||
|
||||
The retained read transcript is not a screen dump. It must preserve:
|
||||
|
||||
- uncursored bounded latest preview behavior;
|
||||
- cursor reads over completed retained lines;
|
||||
- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`;
|
||||
- partial-line duplication rules;
|
||||
- `truncated`, `limited`, and total count metadata;
|
||||
- bounded memory for long partial lines and large output bursts.
|
||||
|
||||
Snapshot optimizations must be tested against this transcript contract instead
|
||||
of assuming xterm scrollback serialization can replace it.
|
||||
|
||||
## Required Contract Tests
|
||||
|
||||
Before moving more runtime behavior behind the model/view boundary, add or
|
||||
extend tests that prove:
|
||||
|
||||
- headless snapshots rehydrate rich alternate-screen TUI state;
|
||||
- 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;
|
||||
- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs;
|
||||
- remote runtime multiplex output remains ACK bounded and can request recovery
|
||||
snapshots;
|
||||
- mobile subscribers receive bounded snapshots without unbounded pending live
|
||||
output;
|
||||
- retained terminal reads remain pageable and bounded after large output.
|
||||
|
||||
Current coverage is spread across:
|
||||
|
||||
- `src/main/daemon/headless-emulator.test.ts`
|
||||
- `src/main/daemon/session.test.ts`
|
||||
- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers,
|
||||
`pendingDeliveryStartSeq`)
|
||||
- `src/main/ipc/pty-hidden-delivery-gate.test.ts`
|
||||
- `src/main/runtime/mobile-subscribe-integration.test.ts`
|
||||
- `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/shared/terminal-output-side-effects.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.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-hidden-view-parking.spec.ts`
|
||||
- `tests/e2e/terminal-parked-memory.spec.ts`
|
||||
- `tests/e2e/terminal-sleep-wake-restore.spec.ts`
|
||||
- `tests/e2e/terminal-output-scheduler.spec.ts`
|
||||
- `tests/e2e/artificial-opencode-terminal-load.spec.ts`
|
||||
|
||||
## Architecture Status
|
||||
|
||||
All six phases of the terminal model/view architecture are shipped; the kill
|
||||
switches noted in parentheses default on:
|
||||
|
||||
1. **Hidden view parking** — "Park hidden terminal views behind a byte
|
||||
watcher": hidden terminal tabs unmount their xterm after a cold-park
|
||||
hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects
|
||||
alive while parked (`terminalHiddenViewParking`). See
|
||||
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md).
|
||||
2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory":
|
||||
renderer heap and live-terminal counts gate parking in the perf suite
|
||||
(`tests/e2e/terminal-parked-memory.spec.ts`).
|
||||
3. **Side-effect authority in main** — "Track terminal titles in main with
|
||||
all-titles ordering", "Move terminal side-effect authority to a main facts
|
||||
channel", "Complete terminal side-effect facts coverage", "Finish terminal
|
||||
side-effect authority migration": every local/daemon/SSH PTY byte is
|
||||
side-effect-parsed once in main and delivered as `pty:sideEffect` facts
|
||||
(`terminalMainSideEffectAuthority`). See
|
||||
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md).
|
||||
4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views":
|
||||
main drops renderer-bound bytes for hidden-marked PTYs after model
|
||||
ingestion; delivery-interest registrations exempt sidecar byte consumers,
|
||||
out-of-band restore markers latch model restore, and
|
||||
`pendingDeliveryStartSeq` reconciles live output racing a restore
|
||||
(`terminalHiddenDeliveryGate`).
|
||||
5. **Model query authority** — "Answer hidden terminal queries from the
|
||||
model", "Bridge renderer view attributes to the model responder", "Align
|
||||
query authority contract and spawn-time ownership": hidden-dropped queries
|
||||
are answered by the runtime emulator plus renderer-pushed view attributes,
|
||||
and hidden-at-spawn PTYs are marked before byte one
|
||||
(`terminalModelQueryAuthority`). See
|
||||
[`terminal-query-authority.md`](./terminal-query-authority.md).
|
||||
6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the
|
||||
renderer's per-chunk hidden-skip eligibility grammar and the 10s codex
|
||||
startup query window are deleted; the kill-switch-off fallback is the
|
||||
bounded background queue with overflow-latched model restore.
|
||||
|
||||
Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a
|
||||
local repaint quirk. Renderer fallback paths retire only when their kill
|
||||
switches do, and only after the equivalent model path has platform and TUI
|
||||
golden coverage.
|
||||
@@ -0,0 +1,326 @@
|
||||
# Terminal Query Authority
|
||||
|
||||
Status: Shipped — Phase 5 of the terminal model/view architecture, kill
|
||||
switch `terminalModelQueryAuthority` (default on). Builds on
|
||||
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this
|
||||
phase **amends invariant 6**),
|
||||
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md)
|
||||
(Phase 3), and the Phase-4 hidden-delivery gate
|
||||
(`src/main/ipc/pty-hidden-delivery-gate.ts`).
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion
|
||||
(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`).
|
||||
Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks
|
||||
waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang
|
||||
TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4
|
||||
hidden skip latch had the same hole (only mode 2031 and the 10s codex startup
|
||||
window answered), so this is not a regression — it is the long-standing gap
|
||||
this phase closes.
|
||||
|
||||
Contract invariant 6 ("the model must never answer queries") was written
|
||||
against a real bug: the daemon emulator replying ahead of the renderer with
|
||||
default-xterm values (the OSC-11 default-black-background race,
|
||||
`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger
|
||||
was never "the model answers" — it was **two answerers for the same bytes**,
|
||||
one of them with wrong values. Phase 5 keeps the singularity and fixes the
|
||||
values.
|
||||
|
||||
## Decision: the delivery decision is the reply decision
|
||||
|
||||
Main answers a query **iff main dropped the chunk that carried it**. The same
|
||||
per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that
|
||||
decides renderer delivery decides reply ownership, evaluated once,
|
||||
synchronously, at ingestion:
|
||||
|
||||
- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via
|
||||
`Terminal.onData` → `transport.sendInput`, unchanged.
|
||||
- Hidden-marked, no delivery interest → chunk dropped → main answers from the
|
||||
runtime headless emulator, via the provider input path (`provider.write`,
|
||||
same path as `pty:write`; daemon shell-ready write gating and the SSH relay
|
||||
write apply unchanged).
|
||||
- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both
|
||||
sides).
|
||||
|
||||
This is structurally exactly-one-responder: a chunk is delivered or dropped,
|
||||
never both, and each side only answers bytes it actually parsed live. The
|
||||
mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist
|
||||
from Phase 4 and are reused, not duplicated.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- **Fact-based renderer replies per query class** (the mode-2031 pattern
|
||||
generalized): needs a main-side detection grammar per query, a fact round
|
||||
trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the
|
||||
emulator is the only state for a hidden pane. The 2031 fact stays because it
|
||||
is subscription registration, not a state query.
|
||||
- **Emulator always answers**: re-creates the OSC-11 double-reply race for
|
||||
visible panes. Never.
|
||||
|
||||
## Mechanism: forwarded emulator onData, not a new grammar
|
||||
|
||||
`HeadlessEmulator` has `onData` wiring behind a per-write capture flag.
|
||||
For static and model-state queries, xterm core **is** the query grammar: the
|
||||
runtime emulator runs the same xterm version with equivalent options as the
|
||||
renderer pane, so main's reply set equals the visible renderer's by
|
||||
construction — verified empirically against the bundled headless build:
|
||||
DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including
|
||||
DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS
|
||||
(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching
|
||||
visible behavior today. The headless build has **no theme service**: OSC
|
||||
4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option
|
||||
set, so the view-attribute class is answered by responder-registered parser
|
||||
handlers instead (below) — never by core defaults.
|
||||
|
||||
Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and
|
||||
attached to the emulator `writeChain` link (the mark can flip between
|
||||
ingestion and an async write; the decision must not be re-read at reply time):
|
||||
|
||||
1. gate enabled (`terminalMainSideEffectAuthority` and
|
||||
`terminalHiddenDeliveryGate` both on) AND new kill switch
|
||||
`terminalModelQueryAuthority !== false`;
|
||||
2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData`
|
||||
— same module state, same tick as the drop sites);
|
||||
3. the write is live PTY data — never `seedHeadlessTerminal`,
|
||||
`maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay
|
||||
(main-side replay guard, mirror of the renderer's `replay-guard.ts`);
|
||||
4. no remote view subscriber is attached to the PTY (runtime terminal-RPC
|
||||
subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop
|
||||
xterm receiving the multiplexed stream answers with view authority, exactly
|
||||
like a visible local pane. Legacy JSON `terminal.subscribe` streams **do**
|
||||
register as view subscribers and suppress, even when the consumer is a
|
||||
read-only watcher — deliberately conservative, because the stream may feed
|
||||
an older live xterm view and a withheld reply (the pre-Phase-5 status quo)
|
||||
is strictly safer than a double reply. Consumers that never register a
|
||||
stream (CLI `terminal.read`, automation observers) do not suppress — they
|
||||
also do not answer; that bounded no-reply case matches today's behavior.
|
||||
|
||||
Everything the emulator emits outside a forwarding window is discarded, which
|
||||
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
|
||||
triggered by option mutations).
|
||||
|
||||
## Reply classes
|
||||
|
||||
| Class | Queries | Answer source |
|
||||
| --- | --- | --- |
|
||||
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
|
||||
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
|
||||
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
|
||||
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
|
||||
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
|
||||
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
|
||||
|
||||
### View-attribute bridge
|
||||
|
||||
Renderer→main push, `pty:terminalViewAttributes` — one global snapshot,
|
||||
not per-PTY: the composed terminal `ITheme` (from
|
||||
`applyTerminalAppearance`, `terminal-appearance.ts`),
|
||||
`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme
|
||||
mode (`resolveTerminalColorSchemeMode` — the same source as the existing
|
||||
hidden 2031 reply). Pushed on renderer startup and on every theme/settings
|
||||
apply.
|
||||
|
||||
Main consumes it two ways:
|
||||
|
||||
- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options
|
||||
inside the replay guard; xterm core then answers DECRQSS DECSCUSR and
|
||||
DECRQM 12 with renderer-true values (verified working headless).
|
||||
- Palette and color-scheme replies come from responder-registered parser
|
||||
handlers on the emulator (`registerOscHandler` 4/10/11/12,
|
||||
`registerCsiHandler` for DSR ?996n), because the headless core cannot
|
||||
answer them. The OSC handlers see SET payloads too, so runtime OSC
|
||||
4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are
|
||||
tracked per PTY and layered over the pushed base palette — matching what
|
||||
the renderer's theme service reports for a visible pane.
|
||||
|
||||
Staleness rules: replies use the last push; a theme flip is stale for at most
|
||||
one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push).
|
||||
**Before the first push main answers no view-attribute query** — a fabricated
|
||||
default would resurrect the default-black OSC-11 bug; silence is the
|
||||
documented hidden status quo.
|
||||
|
||||
### Kitty keyboard flags
|
||||
|
||||
`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching
|
||||
`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low:
|
||||
for the write-only daemon use, keyboard state never alters serialization; the
|
||||
change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring
|
||||
them, and lets the responder answer `CSI ? u` with the flags the hidden app
|
||||
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. 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
|
||||
|
||||
The provider kind is known main-side: mirror `isLocalNativeWindowsPty`
|
||||
(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon
|
||||
provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the
|
||||
emulator parser (the main-side twin of
|
||||
`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by
|
||||
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; the hidden-at-spawn loss window is
|
||||
closed by the slice-3 `initiallyHidden` spawn flag (races section).
|
||||
|
||||
## Suppression: when main never replies
|
||||
|
||||
- Visible or unmarked PTY (chunk was delivered).
|
||||
- Renderer delivery interest registered (chunk was delivered to a sidecar).
|
||||
- Remote-runtime (`remote:`) PTYs — never markable
|
||||
(`isHiddenDeliveryGateManagedPty`), bytes never transit local main.
|
||||
- Remote view subscriber attached (mobile/web/remote desktop owns replies).
|
||||
- Seed/hydration/snapshot writes into the emulator, and option pushes.
|
||||
- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an
|
||||
independent off switch for the responder alone.
|
||||
- The **daemon** emulator: never, under any setting. The responder lives in
|
||||
main's runtime only; `session.test.ts:163-190` stays pinned verbatim.
|
||||
|
||||
## Transition races
|
||||
|
||||
Worst cases, per direction:
|
||||
|
||||
- **visible→hidden**: chunks delivered between the visibility flip and the
|
||||
mark landing in main are hidden-skipped by the renderer write path without
|
||||
query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden
|
||||
skip behavior, bounded by one renderer→main IPC hop. After the mark lands,
|
||||
main answers everything it drops.
|
||||
- **hidden→visible**: unmark consumes the drop latch and emits the restore
|
||||
marker; the snapshot replay is replay-guarded, so queries main already
|
||||
answered are never re-answered from the snapshot; post-unmark live chunks
|
||||
are answered by xterm once (restore-queued live chunks reply late, not
|
||||
twice).
|
||||
- **Split queries across the drop/deliver boundary**: neither parser saw the
|
||||
whole sequence → no reply; the restore marker resets renderer cross-chunk
|
||||
state and replay hygiene resets the parser. At-most-once holds.
|
||||
|
||||
Safe-side rule per class: duplicates are structurally impossible (one decision
|
||||
point per chunk); where the race costs anything it costs a missing reply.
|
||||
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 answers it from the renderer xterm. A PTY spawned hidden 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 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
|
||||
|
||||
1. Exactly one party may answer any query, chosen by the chunk's delivery
|
||||
decision: delivered → the consuming live view's xterm; dropped → main's
|
||||
model responder; replayed/seeded → no one. The decision is captured once,
|
||||
synchronously, at ingestion.
|
||||
2. Main answers only from live PTY bytes parsed by the runtime emulator —
|
||||
never from snapshot, seed, hydration, or option-push writes.
|
||||
3. View-attribute answers are renderer-true or absent: no reply is ever
|
||||
fabricated from emulator defaults (the OSC-11 lesson).
|
||||
4. The daemon emulator stays write-only; daemon subprocess query writes stay
|
||||
zero (`session.test.ts` pins are permanent).
|
||||
5. Reply parity is structural for static and model-state classes: same xterm
|
||||
core, equivalent options, no hand-rolled grammar — the only overrides are
|
||||
the documented ConPTY DA1 variant and the view-attribute parser handlers
|
||||
the headless core cannot serve.
|
||||
6. Remote views keep view authority; main yields whenever a remote view
|
||||
subscriber is attached.
|
||||
|
||||
**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is
|
||||
replaced by:
|
||||
|
||||
> 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.
|
||||
|
||||
The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11,
|
||||
or theme-sensitive queries" splits into: daemon emulator never answers
|
||||
(unchanged pins) / runtime responder answers only hidden-dropped chunks. The
|
||||
side-effect authority matrix row "DECSET 2031 reply — query authority stays
|
||||
with the view (contract invariant 6)" gains a pointer here; its reply path is
|
||||
otherwise untouched in this phase.
|
||||
|
||||
## Test strategy
|
||||
|
||||
- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs
|
||||
interest-suppressed; each reply class; seed/hydrate silence; remote-
|
||||
subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip
|
||||
between ingestion and async emulator write (captured decision wins).
|
||||
- Parity harness: shared query byte fixtures through a renderer-configured
|
||||
xterm (onData capture) and through the responder; assert byte-identical
|
||||
replies for static + model-state classes, and for view-attribute classes
|
||||
after an attribute push.
|
||||
- `session.test.ts:163-190`: assertions stay; the comment is updated to name
|
||||
the main responder (not "the renderer") as the hidden answerer.
|
||||
- E2E: hidden `claude /theme` reports the configured theme; hidden TUI
|
||||
blocked on CPR/DA unblocks while gated; reveal shows no stray reply
|
||||
fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and
|
||||
`terminal-hidden-view-parking.spec.ts` stay green.
|
||||
|
||||
## Cut-offs (shipped as three stacked slices)
|
||||
|
||||
1. **Responder core.** Emulator onData wiring + per-write capture + main
|
||||
replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`);
|
||||
static + model-state classes; ConPTY DA1 override; remote-subscriber
|
||||
suppression; `terminalModelQueryAuthority` switch; unit + parity tests.
|
||||
Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock.
|
||||
2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor
|
||||
option application under the guard, responder OSC/DSR parser handlers with
|
||||
per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e.
|
||||
3. **Contract alignment.** Invariant-6 amendment in the contract doc, test
|
||||
bullet split, `session.test.ts` comment, side-effect matrix pointer, and
|
||||
the Phase 6 prerequisites below recorded as accepted.
|
||||
|
||||
## Phase 6 (delete skip grammar + startup window): prerequisites from this design
|
||||
|
||||
Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s
|
||||
codex startup renderer-query window are deleted. Kill-switch-off hidden panes
|
||||
fall back to the pre-grammar path — hidden bytes ride the bounded background
|
||||
scheduler queue; overflow latches the model-snapshot restore — and never run
|
||||
a per-chunk content scan.
|
||||
|
||||
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. Phase 6
|
||||
removed the codex exclusion with the window: codex spawns are main-owned
|
||||
from byte zero too, the responder answering their startup probes.
|
||||
- **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`). The codex window was removed
|
||||
with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches
|
||||
as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains
|
||||
worth adding.
|
||||
- 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.
|
||||
- **2031 consolidation** (optional follow-up): move the subscription registry
|
||||
into the responder (the headless core cannot serve 997 pushes any more than
|
||||
it can ?996n) and push 997 flips from the attribute cache, retiring the
|
||||
`2031-subscribe` fact reply, the parked responder, and the parked-tab
|
||||
theme-flip gap.
|
||||
@@ -0,0 +1,252 @@
|
||||
# Terminal Side-Effect Authority
|
||||
|
||||
Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch
|
||||
`terminalMainSideEffectAuthority` (default on). Builds on
|
||||
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and
|
||||
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1).
|
||||
|
||||
## Problem
|
||||
|
||||
Main parses every local/daemon/SSH PTY byte before renderer delivery
|
||||
(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`:
|
||||
side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL
|
||||
watchers; SSH feeds the same path from `wireUpPtyEvents` in
|
||||
`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects
|
||||
users see — bell unread/notifications, title transitions, agent-complete
|
||||
notifications, command lifecycle, PR links — were derived a second time by
|
||||
renderer byte parsers. That duplication forced Phase 1's watcher to parse
|
||||
bytes, forced main to fabricate synthetic OSC title frames over `pty:data`
|
||||
just so renderer parsers could see them, and blocked Phase 4 from ever
|
||||
stopping hidden byte delivery. Phase 3 made main the side-effect parser for
|
||||
every PTY whose bytes transit local main; the renderer byte parsers
|
||||
(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte
|
||||
mode) survive only for remote-runtime PTYs and the kill-switch-off fallback.
|
||||
|
||||
## Authority Matrix
|
||||
|
||||
"Main" means parsed once in `onPtyData` and delivered as derived facts.
|
||||
Remote-runtime PTYs (`remote:`) never transit local main; the renderer
|
||||
(`remote-runtime-pty-transport.ts:74`) stays their parser permanently.
|
||||
|
||||
| Side effect | local-daemon | SSH | remote-runtime |
|
||||
| --- | --- | --- | --- |
|
||||
| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) |
|
||||
| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer |
|
||||
| BEL attention (OSC-aware stateful detector) | main | main | renderer |
|
||||
| OSC 133;D command-finished exit code | main | main | renderer |
|
||||
| GitHub PR-link scan | main | main | renderer |
|
||||
| Command Code output scrape | main (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 | 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
|
||||
|
||||
- The side-effect core shared with the renderer processor lives in
|
||||
`src/shared/terminal-output-side-effects.ts`: all-titles ordering via
|
||||
`extractAllOscTitles` (coalesced working→idle transitions are why last-title
|
||||
is insufficient — issue #1083), `normalizeTerminalTitle`, the literal
|
||||
`cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`,
|
||||
`src/shared/agent-detection.ts`), the `createAgentStatusTracker`
|
||||
transitions, the stale-working-title 3s timer
|
||||
(`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector
|
||||
(`src/shared/terminal-bell-detector.ts`).
|
||||
- One tracker per PTY on `OrcaRuntimeService`, lazily created like
|
||||
`agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the
|
||||
stale-title timer).
|
||||
- It replaced the chunk-level last-title extraction in `onPtyData`: titles
|
||||
feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters,
|
||||
and pending-message delivery see intermediate transitions instead of only
|
||||
the chunk's last title. PTY/leaf records keep the **raw** last title
|
||||
(worktree `ps` and mobile tab titles expect raw); emitted facts carry
|
||||
`(normalizedTitle, rawTitle)` like `onTitleChange`.
|
||||
- No deferred drain in main — the renderer's setTimeout(0) batching
|
||||
(`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which
|
||||
does not exist in main. Main applies synchronously and batches the IPC per
|
||||
flush.
|
||||
- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own
|
||||
last-title scan, untouched: synthetic titles must never reach it.
|
||||
|
||||
## Event Transport: `pty:sideEffect`
|
||||
|
||||
One batched main→renderer channel (`window.api.pty.onSideEffect`,
|
||||
`src/preload/index.ts`). It is **not** routed through the pty dispatcher:
|
||||
the renderer fact-consumer registry
|
||||
(`terminal-side-effect-facts-handler.ts`) subscribes directly via
|
||||
`window.api.pty.onSideEffect` — one channel subscription per renderer, with
|
||||
exactly one registered fact consumer per PTY. Events are **facts, not
|
||||
decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title),
|
||||
`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries
|
||||
`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf
|
||||
records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY
|
||||
`outputSequence`.
|
||||
|
||||
Ordering rules:
|
||||
|
||||
1. Per-PTY in-order; facts from one chunk are emitted in byte order (status
|
||||
payloads, then titles in sequence, then bell — the renderer drain's order).
|
||||
2. Deliberately **not** synchronized with `pty:data`: side effects must keep
|
||||
advancing while renderer delivery is ACK-gated (contract invariant 1). A
|
||||
completion title may reach the store before the visible xterm paints the
|
||||
final output; that is acceptable — attention/title state is out-of-band UI
|
||||
state, and today's renderer drain already decouples by many batches under
|
||||
timer throttling.
|
||||
3. No attention replay: facts emitted while no renderer is subscribed are
|
||||
dropped. On transport attach/park-handoff the renderer pulls a title-only
|
||||
snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces
|
||||
the eager-buffer behavior where replay restores titles but is barred from
|
||||
bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The
|
||||
store handler ignores a replay title older (by `outputSequence`) than the
|
||||
last live title fact it applied.
|
||||
|
||||
## Renderer Store Handler (policy stays in the renderer)
|
||||
|
||||
Notification semantics, all preserved across the authority flip:
|
||||
|
||||
- BEL marks worktree+tab unread unconditionally — including the focused pane
|
||||
(`onBell`, `pty-connection.ts`); pane unread only behind
|
||||
`experimentalTerminalAttention`; keydown clears unread
|
||||
(`onTerminalKeyDown`, `pty-connection.ts`).
|
||||
- BEL's OS notification is delayed 250 ms and yields to a pending
|
||||
agent-task-complete (`scheduleTerminalBellNotification`,
|
||||
`pty-connection.ts`).
|
||||
- working→idle starts the Claude cache timer (null settings = not hydrated,
|
||||
treat enabled) and schedules completion with 250 ms grace + 1500 ms max
|
||||
wait + detail-wait store subscription
|
||||
(`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` /
|
||||
`AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`,
|
||||
`agent-task-complete-policy.ts`).
|
||||
- Completion unread is suppressed only for the exact visible foreground pane
|
||||
(`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread
|
||||
has no such check.
|
||||
- Dispatch-time liveness/staleness guards
|
||||
(`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's
|
||||
5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`,
|
||||
`src/main/ipc/notifications.ts`) remain the final gates.
|
||||
|
||||
These need live renderer store state (PTY/layout maps, pane visibility,
|
||||
settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer:
|
||||
the pane-independent per-paneKey handler module
|
||||
(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and
|
||||
subsumes both `pty-connection.ts`'s callbacks and the parked watcher's
|
||||
callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) —
|
||||
one policy path whether the tab is mounted, hidden, or parked. Main holds
|
||||
**no** notification timers; only the stale-title timer (parser state) lives
|
||||
in main.
|
||||
|
||||
## Synthetic Frame Reroute
|
||||
|
||||
`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`,
|
||||
`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`,
|
||||
so synthetic agent-title/BEL frames enter the per-PTY tracker directly —
|
||||
**not** `onPtyData`, so emulator state, tails, transcripts, and stats never
|
||||
see them. The decorative-frame visibility gating
|
||||
(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data`
|
||||
copy survives only in kill-switch-off mode, where renderer parsers still
|
||||
need the bytes. The visible xterm renders nothing from titles, but
|
||||
`pane.terminal.onTitleChange` feeds `registerPtyTitleSource`
|
||||
(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile
|
||||
parity); main prefers its own tracked title over renderer snapshot
|
||||
`lastTitle` in both serialize paths. Under main authority synthetic frames
|
||||
no longer produce phantom ACKs for bytes main never metered (`ackPtyData`,
|
||||
`pty-dispatcher.ts`).
|
||||
|
||||
## Migration Switch and Double-Fire Prevention
|
||||
|
||||
Authority is structural per PTY kind — the predicate is "bytes transit local
|
||||
main", exactly the `shouldOwnAgentStatusInRenderer` split
|
||||
(`pty-connection.ts`). One renderer-consulted kill switch
|
||||
(`settings.terminalMainSideEffectAuthority`, default on, mirroring
|
||||
`terminalHiddenViewParking`): when on, IPC transports and the parked watcher
|
||||
do not register byte parsers for local/SSH and the store handler consumes
|
||||
`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect`
|
||||
events are ignored. Main always parses and emits (its internal consumers need
|
||||
the tracker regardless); main consults the same setting only to keep the
|
||||
legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly
|
||||
one consumer per fact at any time — decided at transport/watcher creation, so
|
||||
no per-chunk race.
|
||||
|
||||
## Sidecar Consumers and Phase 4
|
||||
|
||||
Keep renderer byte access (input pacing / raw-output consumers, not side
|
||||
effects): `agent-paste-draft.ts` (DECSET 2004 readiness),
|
||||
`launch-agent-background-session.ts` (startup-injection pacing, onData
|
||||
passthrough), `automation-session-observer.ts` (onData passthrough), and
|
||||
`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for
|
||||
parked tabs while the delivery gate is off). Their duplicated local OSC 9999
|
||||
store writes are gated off under main authority (the `onAgentStatus`
|
||||
automation callbacks still fire; only the racing `setAgentStatus` store
|
||||
writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active
|
||||
`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a
|
||||
ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main
|
||||
authoritative, the parked watcher is purely fact-driven: byte parsing exists
|
||||
only in kill-switch-off mode, and the 2031 reply comes from the
|
||||
`2031-subscribe` fact when the gate is on (the byte responder sidecar only
|
||||
when it is off). The watcher file is deleted outright only when the kill
|
||||
switch retires — it returns as a byte parser only if remote-runtime tabs
|
||||
ever become parkable.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Every byte is side-effect-parsed exactly once, by exactly one authority,
|
||||
chosen structurally per PTY kind.
|
||||
2. Attention facts never replay: snapshot/eager/attach replays restore title
|
||||
state only.
|
||||
3. Notification policy (grace timers, yielding, suppression, dispatch guards)
|
||||
lives with the renderer store; main emits facts with ordering metadata.
|
||||
4. Side-effect facts keep flowing while renderer byte delivery is
|
||||
backpressured, parked, or stopped by the hidden-delivery gate.
|
||||
5. Synthetic agent frames feed the model tracker, never the emulator, tails,
|
||||
transcripts, or stats.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte
|
||||
fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning
|
||||
OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under
|
||||
fake timers, OSC 133;D, split PR URLs) run through the renderer
|
||||
`createPtyOutputProcessor` and the main tracker; assert identical ordered
|
||||
fact sequences.
|
||||
- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle
|
||||
parity, tui-idle waiter transitions, synthetic ingestion); store-handler
|
||||
tests reusing `parked-terminal-byte-watcher.test.ts` scenarios.
|
||||
- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring,
|
||||
`parked-terminal-byte-watcher.test.ts` (retires with the watcher);
|
||||
`pty-transport*.test.ts` stay (processor remains for remote + kill switch).
|
||||
- E2E gates that must stay green throughout: `terminal-attention.spec.ts`,
|
||||
`droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`,
|
||||
`terminal-parked-memory.spec.ts`; add main-authority bell/completion cases
|
||||
(parked tab, focused-pane suppression, kill switch off). SSH parity is
|
||||
exercised manually per the SSH test procedure before each slice ships.
|
||||
|
||||
## Cut-Offs (shipped as four stacked slices)
|
||||
|
||||
1. **Shared tracker in main.** Extract the processor core to shared, run the
|
||||
per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity
|
||||
tests. Main-internal consumers only; no IPC or renderer change.
|
||||
2. **Authority flip.** `pty:sideEffect` channel, renderer store handler,
|
||||
titles/bell/tracker authority to main for local+SSH behind the kill
|
||||
switch; parked watcher stops byte parsing for those kinds.
|
||||
3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`;
|
||||
OSC 133;D and PR-link facts; mobile `lastTitle` source preference.
|
||||
4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked
|
||||
watcher shrunk to fact-driven mode (deletion waits on kill-switch
|
||||
retirement), Phase 4 delivery-interest registration documented in the gate
|
||||
design.
|
||||
|
||||
## Open Items
|
||||
|
||||
- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic
|
||||
checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon
|
||||
`Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is
|
||||
derived from real PTY bytes only — synthetic hook title frames never reach
|
||||
the daemon process, so that field cannot carry hook-driven titles. Today no
|
||||
restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots
|
||||
surface only the ANSI payload), so there is nothing to fix. Main-side
|
||||
consumers of the renderer serializer's `lastTitle` (mobile snapshot reads
|
||||
and the headless hydration seed) prefer main's tracked title. If a future
|
||||
consumer starts reading checkpoint `lastTitle`, it must route through the
|
||||
same tracked-title preference.
|
||||
- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is
|
||||
removed, the parked watcher's byte-parser mode, the renderer transport
|
||||
parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all
|
||||
become dead code and the watcher byte path can be deleted outright.
|
||||
@@ -1,5 +1,9 @@
|
||||
# Terminal Main-Owned State
|
||||
|
||||
This document covers the hidden-output recovery slice. The broader terminal
|
||||
model/view boundary is defined in
|
||||
[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md).
|
||||
|
||||
## Problem
|
||||
|
||||
Hidden and background terminal panes cannot rely on renderer memory as the only
|
||||
@@ -51,6 +55,13 @@ already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for
|
||||
local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated
|
||||
and can serialize it.
|
||||
|
||||
Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default
|
||||
on — see the contract's Architecture Status), main drops hidden renderer-bound
|
||||
bytes after model ingestion and emits an out-of-band restore marker, so a
|
||||
gated hidden pane accumulates no renderer backlog at all. The overflow path
|
||||
below is the fallback for kill-switch-off mode and for hidden PTYs with an
|
||||
active delivery-interest sidecar.
|
||||
|
||||
The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded:
|
||||
|
||||
1. The scheduler replaces the queued backlog with a small warning fallback.
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs",
|
||||
"test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs",
|
||||
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
|
||||
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
|
||||
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
|
||||
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
|
||||
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
|
||||
@@ -877,6 +877,105 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('checkpoints before keep-history shutdown so sleep can cold restore latest output', async () => {
|
||||
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
|
||||
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = 10_000
|
||||
|
||||
try {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const { id } = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: 'sleep-checkpoint'
|
||||
})
|
||||
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
|
||||
|
||||
lastSubprocess._simulateData('latest before sleep\r\n')
|
||||
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
|
||||
|
||||
expect(checkpointSpy).toHaveBeenCalledWith(
|
||||
id,
|
||||
expect.objectContaining({ snapshotAnsi: expect.stringContaining('latest before sleep') })
|
||||
)
|
||||
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true)
|
||||
|
||||
const restored = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: id
|
||||
})
|
||||
expect(restored.coldRestore?.scrollback).toContain('latest before sleep')
|
||||
historyAdapter.ackColdRestore(id)
|
||||
|
||||
const remountAfterAck = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: id
|
||||
})
|
||||
expect(remountAfterAck.coldRestore).toBeUndefined()
|
||||
} finally {
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
|
||||
}
|
||||
})
|
||||
|
||||
it('cold restores the second sleep/wake cycle with post-wake output', async () => {
|
||||
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
|
||||
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = 10_000
|
||||
|
||||
try {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const { id } = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: 'sleep-wake-cycles'
|
||||
})
|
||||
|
||||
lastSubprocess._simulateData('first cycle content\r\n')
|
||||
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
|
||||
const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json')
|
||||
const checkpointPath = join(historyDir, getHistorySessionDirName(id), 'checkpoint.json')
|
||||
// Why: keep-history sleep stays unclean so cold restore remains eligible;
|
||||
// the final checkpoint is the deterministic handoff signal.
|
||||
expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull()
|
||||
expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain(
|
||||
'first cycle content'
|
||||
)
|
||||
|
||||
const firstWake = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: id
|
||||
})
|
||||
expect(firstWake.coldRestore?.scrollback).toContain('first cycle content')
|
||||
historyAdapter.ackColdRestore(id)
|
||||
expect(historyAdapter.hasPty(id)).toBe(true)
|
||||
|
||||
lastSubprocess._simulateData('second cycle content\r\n')
|
||||
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
|
||||
expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull()
|
||||
expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain(
|
||||
'second cycle content'
|
||||
)
|
||||
|
||||
const secondWake = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: id
|
||||
})
|
||||
expect(secondWake.coldRestore?.scrollback).toContain('second cycle content')
|
||||
} finally {
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
|
||||
}
|
||||
})
|
||||
|
||||
it('writes meta.json with endedAt on exit', async () => {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DaemonClient } from './client'
|
||||
import { getMacDaemonSystemResolverHealth } from './daemon-health'
|
||||
import { HistoryManager } from './history-manager'
|
||||
import { HistoryReader } from './history-reader'
|
||||
import type { ColdRestoreInfo } from './history-reader'
|
||||
import { mintPtySessionId, parsePtySessionId } from './pty-session-id'
|
||||
import { supportsPtyStartupBarrier } from './shell-ready'
|
||||
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session'
|
||||
@@ -80,6 +81,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// mount → ??? The sticky cache returns the same cold restore data on the
|
||||
// second mount until the renderer explicitly acknowledges it.
|
||||
private coldRestoreCache = new Map<string, ColdRestorePayload>()
|
||||
private sleepRestoreSessionIds = new Set<string>()
|
||||
private activeSessionIds = new Set<string>()
|
||||
private dirtySessionVersions = new Map<string, number>()
|
||||
// Why: a cold-restored session is a fresh shell whose on-disk checkpoint and
|
||||
@@ -200,6 +202,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// but should still return the cached cold restore data.
|
||||
const cachedRestore = this.coldRestoreCache.get(sessionId)
|
||||
if (cachedRestore) {
|
||||
// Why: wake after sleep also lands here, and the slept session's active
|
||||
// tracking and history writer were dropped when sleep killed the PTY.
|
||||
// Without re-registering both, checkpoints stop after wake and the
|
||||
// second sleep/wake cycle restores a blank terminal.
|
||||
this.activeSessionIds.add(sessionId)
|
||||
if (this.historyManager) {
|
||||
this.historyManager.reopenSession(sessionId)
|
||||
}
|
||||
return {
|
||||
id: sessionId,
|
||||
pid,
|
||||
@@ -215,17 +225,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// an unclean shutdown → return saved scrollback so the renderer can
|
||||
// display the previous terminal content.
|
||||
if (result.isNew && restoreInfo) {
|
||||
// Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer
|
||||
// (vim/less/htop); normal sessions use the full snapshot + rehydrate.
|
||||
// Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback)
|
||||
// would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone*
|
||||
// (no rehydrateSequences — they start with \x1b[?1049h, which the
|
||||
// renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as
|
||||
// normal scrollback. An empty snapshot still yields null → no-op.
|
||||
const isAltScreen = restoreInfo.modes.alternateScreen
|
||||
const scrollback = isAltScreen
|
||||
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
|
||||
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
|
||||
const coldRestore = this.buildColdRestorePayload(restoreInfo)
|
||||
// Why: use registerWriter (not openSession) to avoid deleting the
|
||||
// existing checkpoint.json. If the revived daemon crashes again before
|
||||
// the next 5s tick, the checkpoint is the only recovery data available.
|
||||
@@ -237,8 +237,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// within one adapter) must not defer this re-anchor.
|
||||
this.lastFullCheckpointAt.delete(sessionId)
|
||||
}
|
||||
if (scrollback) {
|
||||
const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks }
|
||||
if (coldRestore) {
|
||||
this.coldRestoreCache.set(sessionId, coldRestore)
|
||||
return { id: sessionId, pid, coldRestore }
|
||||
}
|
||||
@@ -277,12 +276,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
|
||||
}
|
||||
@@ -313,15 +320,27 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
|
||||
// Why: sleep/exact-stop must preserve restorable terminal history,
|
||||
// so force a final checkpoint before killing the daemon session.
|
||||
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
|
||||
// Force a final snapshot so wake can restore the pane users left.
|
||||
if (opts.keepHistory) {
|
||||
if (this.checkpointInFlight) {
|
||||
await this.checkpointInFlight
|
||||
}
|
||||
await this.checkpointSessions([id], { final: true, teardown: true })
|
||||
const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null
|
||||
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
|
||||
if (coldRestore) {
|
||||
this.coldRestoreCache.set(id, coldRestore)
|
||||
this.sleepRestoreSessionIds.add(id)
|
||||
}
|
||||
}
|
||||
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
|
||||
this.activeSessionIds.delete(id)
|
||||
this.dirtySessionVersions.delete(id)
|
||||
this.coldRestoreCache.delete(id)
|
||||
if (!opts.keepHistory) {
|
||||
this.coldRestoreCache.delete(id)
|
||||
this.sleepRestoreSessionIds.delete(id)
|
||||
}
|
||||
// Why: the !keepHistory close path doesn't take a final checkpoint, so a
|
||||
// session stranded in sessionsNeedingFullCheckpoint would never be cleared.
|
||||
// (Under keepHistory the final checkpoint above already cleared the flag, so
|
||||
@@ -357,12 +376,30 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
|
||||
ackColdRestore(sessionId: string): void {
|
||||
this.coldRestoreCache.delete(sessionId)
|
||||
this.sleepRestoreSessionIds.delete(sessionId)
|
||||
}
|
||||
|
||||
clearTombstone(sessionId: string): void {
|
||||
this.killedSessionTombstones.delete(sessionId)
|
||||
}
|
||||
|
||||
private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null {
|
||||
// Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer
|
||||
// (vim/less/htop); normal sessions use the full snapshot + rehydrate.
|
||||
// Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback)
|
||||
// would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone*
|
||||
// (no rehydrateSequences — they start with \x1b[?1049h, which the
|
||||
// renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as
|
||||
// normal scrollback. An empty snapshot still yields null → no-op.
|
||||
const scrollback = restoreInfo.modes.alternateScreen
|
||||
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
|
||||
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
|
||||
if (!scrollback) {
|
||||
return null
|
||||
}
|
||||
return { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks }
|
||||
}
|
||||
|
||||
async sendSignal(id: string, signal: string): Promise<void> {
|
||||
await this.client.request('signal', { sessionId: id, signal })
|
||||
}
|
||||
@@ -976,7 +1013,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
} else if (event.event === 'exit') {
|
||||
this.activeSessionIds.delete(event.sessionId)
|
||||
this.dirtySessionVersions.delete(event.sessionId)
|
||||
this.coldRestoreCache.delete(event.sessionId)
|
||||
if (!this.sleepRestoreSessionIds.has(event.sessionId)) {
|
||||
this.coldRestoreCache.delete(event.sessionId)
|
||||
}
|
||||
// Why: an exited session can never be checkpointed again, so its pending
|
||||
// full-checkpoint flag is dead state. Without this, a cold-restored
|
||||
// session that exits before its first checkpoint leaks a permanent entry.
|
||||
|
||||
@@ -98,6 +98,49 @@ describe('HeadlessEmulator', () => {
|
||||
uri: 'https://example.com/issue/1234'
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes split synchronized rich TUI frames for model-backed replay', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 12 })
|
||||
const richFrame = [
|
||||
'\x1b[?2026h',
|
||||
'\x1b[?1049h',
|
||||
'\x1b[2J\x1b[H',
|
||||
'\x1b[?25l',
|
||||
'\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n',
|
||||
'\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n',
|
||||
'\x1b[2;36m│ status streaming │\x1b[0m\r\n',
|
||||
'\x1b[2;36m╰────────────────────────────╯\x1b[0m',
|
||||
'\x1b[6;4H\x1b[?25h',
|
||||
'\x1b[?2026l'
|
||||
].join('')
|
||||
|
||||
// Why: hidden rich TUI bytes may arrive split across DEC 2026 frame
|
||||
// boundaries; model/view work needs the headless model to preserve the
|
||||
// final visible state before renderer writes can be removed.
|
||||
await emulator.write(richFrame.slice(0, 17))
|
||||
await emulator.write(richFrame.slice(17, 91))
|
||||
await emulator.write(richFrame.slice(91))
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.modes.alternateScreen).toBe(true)
|
||||
expect(snapshot.snapshotAnsi).toContain('Codex rich restore')
|
||||
expect(snapshot.snapshotAnsi).toContain('🟢')
|
||||
expect(snapshot.snapshotAnsi).toContain('███░')
|
||||
expect(snapshot.snapshotAnsi).toContain('╭')
|
||||
expect(snapshot.snapshotAnsi).not.toContain('\x1b[?2026h')
|
||||
|
||||
const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows })
|
||||
try {
|
||||
await replay.write(snapshot.rehydrateSequences + snapshot.snapshotAnsi)
|
||||
const replayed = replay.getSnapshot()
|
||||
expect(replayed.modes.alternateScreen).toBe(true)
|
||||
expect(replayed.snapshotAnsi).toContain('Codex rich restore')
|
||||
expect(replayed.snapshotAnsi).toContain('🟢')
|
||||
expect(replayed.snapshotAnsi).toContain('███░')
|
||||
} finally {
|
||||
replay.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('OSC-7 CWD tracking', () => {
|
||||
@@ -305,6 +348,49 @@ describe('HeadlessEmulator', () => {
|
||||
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks kitty keyboard flags for emulator re-seed parity', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0)
|
||||
|
||||
await emulator.write('\x1b[=5;1u')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5)
|
||||
})
|
||||
|
||||
it('round-trips a pushed CSI > 1 u flag through the core-internals read path', async () => {
|
||||
// Why: getKittyKeyboardFlags reads _core.coreService.kittyKeyboard.flags,
|
||||
// a private xterm surface. If an xterm upgrade breaks that path this
|
||||
// must fail loudly instead of the responder silently answering ?0u.
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
|
||||
await emulator.write('\x1b[>1u')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(1)
|
||||
})
|
||||
|
||||
it('snapshots the active-buffer kitty flags (alt screen keeps its own set)', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
// Kitty flags are per screen buffer: entering the alt screen swaps to
|
||||
// its own (empty) flag set, exactly what a CSI ? u reply would report.
|
||||
await emulator.write('\x1b[=5;1u\x1b[?1049h')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0)
|
||||
|
||||
await emulator.write('\x1b[=3;1u')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(3)
|
||||
|
||||
await emulator.write('\x1b[?1049l')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5)
|
||||
})
|
||||
|
||||
it('never pushes kitty flags into rehydrateSequences', async () => {
|
||||
// Why: POST_REPLAY_REATTACH_RESET's deliberate kitty reset must stay
|
||||
// authoritative for renderer replays (terminal-query-authority.md).
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('\x1b[?1049h\x1b[=5;1u')
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.modes.kittyKeyboardFlags).toBe(5)
|
||||
expect(snapshot.rehydrateSequences).not.toContain('u')
|
||||
})
|
||||
|
||||
it('tracks split SGR mouse reporting sequences', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ import { Terminal } from '@xterm/headless'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
|
||||
import { extractLastOscTitle } from '../../shared/agent-detection'
|
||||
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
|
||||
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
|
||||
import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction'
|
||||
import { parseFileUriPath } from './osc7-file-uri'
|
||||
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
|
||||
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
|
||||
import {
|
||||
installTerminalViewAttributeResponder,
|
||||
type TerminalViewAttributeResponder
|
||||
} from './terminal-view-attribute-responder'
|
||||
import type { TerminalSnapshot, TerminalModes } from './types'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
|
||||
@@ -14,33 +18,54 @@ export type HeadlessEmulatorOptions = {
|
||||
cols: number
|
||||
rows: number
|
||||
scrollback?: number
|
||||
/** Phase-5 model query responder sink (terminal-query-authority.md).
|
||||
* When set, xterm-core auto-replies generated while parsing a write
|
||||
* flagged `forwardQueryReplies` are forwarded here; all other emissions
|
||||
* (seeds, hydration, snapshot replay, unsolicited core pushes) are
|
||||
* discarded. The daemon Session must NEVER pass this — its emulator
|
||||
* stays write-only forever (contract invariant: the daemon never
|
||||
* answers). */
|
||||
onQueryReply?: (reply: string) => void
|
||||
}
|
||||
|
||||
export type HeadlessEmulatorWriteOptions = {
|
||||
/** Reply ownership captured at ingestion for this exact chunk. Default
|
||||
* false is the main-side replay guard (twin of the renderer's
|
||||
* replay-guard.ts): seed/hydration/snapshot writes never forward. */
|
||||
forwardQueryReplies?: boolean
|
||||
}
|
||||
|
||||
type TerminalWithSynchronousWrite = Terminal & {
|
||||
_core?: {
|
||||
writeSync?: (data: string) => void
|
||||
// Why: kitty keyboard flags are not on the public IModes; read the core
|
||||
// service state the CSI =/>/< u handlers mutate.
|
||||
coreService?: {
|
||||
kittyKeyboard?: { flags?: number }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SCROLLBACK = 5000
|
||||
const OSC_SCAN_TAIL_LIMIT = 4096
|
||||
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
|
||||
// Keep parser state far beyond normal mode lists while still bounding memory.
|
||||
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
|
||||
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
|
||||
// Keep in sync with the renderer twin in terminal-capability-replies.ts
|
||||
// (main must not import renderer modules).
|
||||
const CONPTY_DA1_RESPONSE = '\x1b[?61;4c'
|
||||
|
||||
export class HeadlessEmulator {
|
||||
private terminal: Terminal
|
||||
private serializer: SerializeAddon
|
||||
private cwd: string | null = null
|
||||
private lastTitle: string | null = null
|
||||
private oscScanTail = ''
|
||||
private privateModeScanTail = ''
|
||||
private mouseTrackingMode: MouseTrackingMode = 'none'
|
||||
private sgrMouseMode = false
|
||||
private sgrMousePixelsMode = false
|
||||
private oscText = new TerminalOscCwdTitleScanner()
|
||||
private mouseModes = new TerminalMouseModeMirror()
|
||||
private restoredOscLinks: TerminalOscLinkRange[] = []
|
||||
private disposed = false
|
||||
private onQueryReply: ((reply: string) => void) | null
|
||||
private conptyDa1OverrideInstalled = false
|
||||
private viewAttributeResponder: TerminalViewAttributeResponder | null = null
|
||||
// Why: replies must be scoped to the exact write that carried the query.
|
||||
// The window opens around the parse of a forward-flagged chunk and closes
|
||||
// with it, so seeds/snapshots and unsolicited core emissions (e.g. native
|
||||
// 997 pushes from option mutations) can never leak to the PTY.
|
||||
private queryReplyForwardingDepth = 0
|
||||
|
||||
constructor(opts: HeadlessEmulatorOptions) {
|
||||
this.terminal = new Terminal({
|
||||
@@ -48,7 +73,12 @@ export class HeadlessEmulator {
|
||||
rows: opts.rows,
|
||||
scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK,
|
||||
allowProposedApi: true,
|
||||
logLevel: 'off'
|
||||
logLevel: 'off',
|
||||
// Why: parity with the renderer's buildDefaultTerminalOptions — parse
|
||||
// CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app
|
||||
// actually pushed. Write-only daemon use is unaffected: keyboard state
|
||||
// never alters serialization (terminal-query-authority.md §kitty).
|
||||
vtExtensions: { kittyKeyboard: true }
|
||||
})
|
||||
|
||||
this.serializer = new SerializeAddon()
|
||||
@@ -62,33 +92,136 @@ export class HeadlessEmulator {
|
||||
this.terminal.loadAddon(new Unicode11Addon())
|
||||
activateOrcaTerminalUnicodeProvider(this.terminal)
|
||||
|
||||
// Why no onData wiring: this emulator exists purely for state tracking
|
||||
// (snapshots, cwd, mode flags). It MUST NOT respond to terminal query
|
||||
// sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses
|
||||
// data in-process synchronously before `handleSubprocessData` forwards
|
||||
// it to the renderer over IPC, so any reply it emits would land on the
|
||||
// shell's stdin ahead of the renderer's xterm reply and win the race.
|
||||
// The renderer is the authoritative responder (it has the real theme,
|
||||
// cursor position, and paste mode); a daemon-side reply would be a
|
||||
// double-reply with wrong values. OSC 11 was the visible casualty:
|
||||
// Claude Code's /theme auto always saw the emulator's default-black
|
||||
// background regardless of Orca's configured terminal theme.
|
||||
// Why onData is gated behind onQueryReply: by default this emulator is
|
||||
// pure state tracking and MUST NOT respond to terminal query sequences
|
||||
// (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The daemon emulator parses data
|
||||
// in-process synchronously before `handleSubprocessData` forwards it to
|
||||
// the renderer over IPC, so any reply it emitted would land on the
|
||||
// shell's stdin ahead of the renderer's xterm reply and win the race —
|
||||
// a double-reply with default-xterm values (OSC 11 default-black was
|
||||
// the visible casualty). Only main's runtime per-PTY emulators pass a
|
||||
// sink, and even then replies flow only for chunks the hidden-delivery
|
||||
// gate DROPPED, where the renderer never sees the bytes and main is the
|
||||
// single answerer. See docs/reference/terminal-query-authority.md.
|
||||
this.onQueryReply = opts.onQueryReply ?? null
|
||||
if (this.onQueryReply) {
|
||||
this.terminal.onData((reply) => this.emitQueryReply(reply))
|
||||
}
|
||||
}
|
||||
|
||||
write(data: string): Promise<void> {
|
||||
/** Main-side twin of the renderer's terminal-capability-replies.ts:
|
||||
* ConPTY 1.22+ blocks at spawn waiting for a DA1 reply, and the override
|
||||
* variant (`CSI ?61;4c`) must win. Returning true consumes the query so
|
||||
* xterm core's default `?1;2c` cannot double-reply (custom CSI handlers
|
||||
* run before core's; false falls through). The reply still routes through
|
||||
* the forwarding window, so replayed/seeded bytes never answer. */
|
||||
installConptyPrimaryDeviceAttributesOverride(): void {
|
||||
// Why idempotent: the spawn mark can land after daemon stream data
|
||||
// already created the emulator, so the override is installed both at
|
||||
// creation and retrofitted at mark time — never stacked.
|
||||
if (this.conptyDa1OverrideInstalled) {
|
||||
return
|
||||
}
|
||||
this.conptyDa1OverrideInstalled = true
|
||||
this.terminal.parser.registerCsiHandler({ final: 'c' }, (params) => {
|
||||
const isPrimaryQuery = params.length === 0 || (params.length === 1 && params[0] === 0)
|
||||
if (!isPrimaryQuery) {
|
||||
return false
|
||||
}
|
||||
this.emitQueryReply(CONPTY_DA1_RESPONSE)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Phase-5 slice-2 view-attribute bridge: the headless core has no theme
|
||||
* service, so OSC 4/10/11/12 queries and DSR ?996n are answered from the
|
||||
* renderer's pushed attributes via these parser handlers — never from
|
||||
* emulator defaults. Runtime-only, like onQueryReply: the daemon Session
|
||||
* must NEVER call this (its emulator stays write-only forever). */
|
||||
installViewAttributeResponder(getBaseAttributes: () => TerminalViewAttributes | null): void {
|
||||
if (this.viewAttributeResponder) {
|
||||
return
|
||||
}
|
||||
this.viewAttributeResponder = installTerminalViewAttributeResponder({
|
||||
parser: this.terminal.parser,
|
||||
getBaseAttributes,
|
||||
// emitQueryReply keeps replies inside the per-chunk forwarding window,
|
||||
// so seeded/replayed view-attribute queries answer no one.
|
||||
emitReply: (reply) => this.emitQueryReply(reply)
|
||||
})
|
||||
}
|
||||
|
||||
/** Applies a renderer view-attribute push: cursor options make xterm core
|
||||
* answer DECRQSS DECSCUSR / DECRQM 12 renderer-true, and the per-PTY OSC
|
||||
* color overrides are dropped because a theme apply overwrites mutated
|
||||
* colors on visible panes too (ThemeService._setTheme parity). Option
|
||||
* writes happen outside any forwarding window, so any core emission they
|
||||
* trigger is discarded (main-side replay guard). */
|
||||
applyPushedViewAttributes(attributes: TerminalViewAttributes): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.terminal.options.cursorStyle = attributes.cursorStyle
|
||||
this.terminal.options.cursorBlink = attributes.cursorBlink
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/** Severs the reply sink at PTY teardown. Queued writeChain links may
|
||||
* still parse after dispose is requested, and daemon respawns reuse
|
||||
* session ids — a late reply must never reach a successor PTY. */
|
||||
disableQueryReplyForwarding(): void {
|
||||
this.onQueryReply = null
|
||||
}
|
||||
|
||||
write(data: string, opts: HeadlessEmulatorWriteOptions = {}): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (this.tryWriteSync(data)) {
|
||||
const forwardQueryReplies = opts.forwardQueryReplies === true
|
||||
if (this.tryWriteSync(data, { forwardQueryReplies })) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
this.scanInputForOscState(data)
|
||||
this.oscText.scan(data)
|
||||
// Why the sentinel: xterm parses queued writes asynchronously, so opening
|
||||
// the window at enqueue time would leak it over earlier queued unflagged
|
||||
// chunks (seed/hydration bytes parsing while depth > 0). Write callbacks
|
||||
// fire in FIFO parse order, so a zero-byte write whose callback opens the
|
||||
// window brackets the parse of exactly this chunk; the data callback
|
||||
// closes it.
|
||||
if (forwardQueryReplies) {
|
||||
this.terminal.write('', () => {
|
||||
this.queryReplyForwardingDepth += 1
|
||||
})
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.terminal.write(data, () => {
|
||||
if (forwardQueryReplies) {
|
||||
this.queryReplyForwardingDepth -= 1
|
||||
}
|
||||
// Why: snapshots combine serialized xterm state with mirrored mouse
|
||||
// modes. Commit the mirror only after xterm has parsed the same bytes.
|
||||
this.scanPrivateModes(data)
|
||||
this.mouseModes.scan(data)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
@@ -105,27 +238,27 @@ export class HeadlessEmulator {
|
||||
return this.tryWriteSync(data)
|
||||
}
|
||||
|
||||
private tryWriteSync(data: string): boolean {
|
||||
private tryWriteSync(data: string, opts: HeadlessEmulatorWriteOptions = {}): boolean {
|
||||
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
|
||||
if (typeof writeSync !== 'function') {
|
||||
return false
|
||||
}
|
||||
this.scanInputForOscState(data)
|
||||
this.oscText.scan(data)
|
||||
const forwardQueryReplies = opts.forwardQueryReplies === true
|
||||
if (forwardQueryReplies) {
|
||||
this.queryReplyForwardingDepth += 1
|
||||
}
|
||||
// Why: hidden renderer restore snapshots are requested immediately after
|
||||
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
|
||||
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
|
||||
this.scanPrivateModes(data)
|
||||
return true
|
||||
}
|
||||
|
||||
private scanInputForOscState(data: string): void {
|
||||
const oscInput = this.oscScanTail + data
|
||||
this.oscScanTail = this.extractOscScanTail(oscInput)
|
||||
this.scanOsc7(oscInput)
|
||||
const lastTitle = extractLastOscTitle(oscInput)
|
||||
if (lastTitle !== null) {
|
||||
this.lastTitle = lastTitle
|
||||
try {
|
||||
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
|
||||
} finally {
|
||||
if (forwardQueryReplies) {
|
||||
this.queryReplyForwardingDepth -= 1
|
||||
}
|
||||
}
|
||||
this.mouseModes.scan(data)
|
||||
return true
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
@@ -159,12 +292,12 @@ export class HeadlessEmulator {
|
||||
this.restoredOscLinks
|
||||
),
|
||||
rehydrateSequences: this.buildRehydrateSequences(modes),
|
||||
cwd: this.cwd,
|
||||
cwd: this.oscText.cwd,
|
||||
modes,
|
||||
cols: this.terminal.cols,
|
||||
rows: this.terminal.rows,
|
||||
scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows,
|
||||
lastTitle: this.lastTitle ?? undefined
|
||||
lastTitle: this.oscText.lastTitle ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,15 +315,15 @@ export class HeadlessEmulator {
|
||||
}
|
||||
|
||||
getCwd(): string | null {
|
||||
return this.cwd
|
||||
return this.oscText.cwd
|
||||
}
|
||||
|
||||
setCwd(cwd: string | null): void {
|
||||
this.cwd = cwd
|
||||
this.oscText.cwd = cwd
|
||||
}
|
||||
|
||||
setLastTitle(title: string): void {
|
||||
this.lastTitle = title
|
||||
this.oscText.lastTitle = title
|
||||
}
|
||||
|
||||
setRestoredOscLinks(links: TerminalOscLinkRange[] | undefined): void {
|
||||
@@ -207,88 +340,6 @@ export class HeadlessEmulator {
|
||||
this.terminal.dispose()
|
||||
}
|
||||
|
||||
private scanOsc7(data: string): void {
|
||||
scanOsc7Uris(data, (uri) => {
|
||||
this.parseOsc7Uri(uri)
|
||||
})
|
||||
}
|
||||
|
||||
private extractOscScanTail(input: string): string {
|
||||
return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT)
|
||||
}
|
||||
|
||||
private scanPrivateModes(data: string): void {
|
||||
const input = this.privateModeScanTail + data
|
||||
this.privateModeScanTail = this.extractPrivateModeScanTail(input)
|
||||
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
|
||||
const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = privateModeRe.exec(input)) !== null) {
|
||||
if (match[0] === '\x1bc') {
|
||||
this.mouseTrackingMode = 'none'
|
||||
this.sgrMouseMode = false
|
||||
this.sgrMousePixelsMode = false
|
||||
continue
|
||||
}
|
||||
const params = match[1] ?? match[3]
|
||||
const enabled = (match[2] ?? match[4]) === 'h'
|
||||
for (const rawParam of params.split(';')) {
|
||||
if (rawParam === '') {
|
||||
continue
|
||||
}
|
||||
const param = Number(rawParam)
|
||||
if (!Number.isInteger(param)) {
|
||||
continue
|
||||
}
|
||||
if (param === 9) {
|
||||
this.mouseTrackingMode = enabled ? 'x10' : 'none'
|
||||
}
|
||||
if (param === 1000) {
|
||||
this.mouseTrackingMode = enabled ? 'vt200' : 'none'
|
||||
}
|
||||
if (param === 1002) {
|
||||
this.mouseTrackingMode = enabled ? 'drag' : 'none'
|
||||
}
|
||||
if (param === 1003) {
|
||||
this.mouseTrackingMode = enabled ? 'any' : 'none'
|
||||
}
|
||||
if (param === 1006) {
|
||||
this.sgrMouseMode = enabled
|
||||
this.sgrMousePixelsMode = false
|
||||
}
|
||||
if (param === 1016) {
|
||||
this.sgrMouseMode = false
|
||||
this.sgrMousePixelsMode = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractPrivateModeScanTail(input: string): string {
|
||||
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
|
||||
if (start === -1) {
|
||||
return ''
|
||||
}
|
||||
const tail = input.slice(start)
|
||||
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
|
||||
return ''
|
||||
}
|
||||
if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
|
||||
return tail
|
||||
}
|
||||
if (tail.startsWith('\x1b[?')) {
|
||||
return this.isIncompletePrivateModeParams(tail.slice(3)) ? tail : ''
|
||||
}
|
||||
if (tail.startsWith('\x9b?')) {
|
||||
return this.isIncompletePrivateModeParams(tail.slice(2)) ? tail : ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private isIncompletePrivateModeParams(params: string): boolean {
|
||||
return /^[0-9;]*$/.test(params)
|
||||
}
|
||||
|
||||
private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string {
|
||||
if (!modes.alternateScreen) {
|
||||
return snapshotAnsi
|
||||
@@ -304,29 +355,34 @@ export class HeadlessEmulator {
|
||||
return snapshotAnsi.slice(start + alternateScreenMarker.length)
|
||||
}
|
||||
|
||||
private parseOsc7Uri(uri: string): void {
|
||||
const parsed = parseFileUriPath(uri)
|
||||
if (parsed) {
|
||||
this.cwd = parsed
|
||||
}
|
||||
}
|
||||
|
||||
private getModes(): TerminalModes {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const mouseTrackingMode = this.mouseTrackingMode
|
||||
const mouseTrackingMode = this.mouseModes.mouseTrackingMode
|
||||
return {
|
||||
bracketedPaste: this.terminal.modes.bracketedPasteMode,
|
||||
mouseTracking: mouseTrackingMode !== 'none',
|
||||
mouseTrackingMode,
|
||||
sgrMouseMode: this.sgrMouseMode,
|
||||
sgrMousePixelsMode: this.sgrMousePixelsMode,
|
||||
sgrMouseMode: this.mouseModes.sgrMouseMode,
|
||||
sgrMousePixelsMode: this.mouseModes.sgrMousePixelsMode,
|
||||
applicationCursor:
|
||||
buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false,
|
||||
alternateScreen: buffer.type === 'alternate'
|
||||
alternateScreen: buffer.type === 'alternate',
|
||||
kittyKeyboardFlags: this.getKittyKeyboardFlags()
|
||||
}
|
||||
}
|
||||
|
||||
private getKittyKeyboardFlags(): number {
|
||||
const flags = (this.terminal as TerminalWithSynchronousWrite)._core?.coreService?.kittyKeyboard
|
||||
?.flags
|
||||
return typeof flags === 'number' ? flags : 0
|
||||
}
|
||||
|
||||
private buildRehydrateSequences(modes: TerminalModes): string {
|
||||
// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and
|
||||
// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C
|
||||
// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for
|
||||
// emulator re-seed parity only; a re-seeded emulator answers ?0u and
|
||||
// protocol-conformant programs re-push.
|
||||
const seqs: string[] = []
|
||||
if (modes.alternateScreen) {
|
||||
seqs.push('\x1b[?1049h')
|
||||
|
||||
@@ -132,6 +132,24 @@ export class HistoryManager {
|
||||
})
|
||||
}
|
||||
|
||||
// Why: wake after sleep re-spawns a session whose history was closed by the
|
||||
// sleep-time kill. Re-register the writer without deleting checkpoint.json
|
||||
// (still the only recovery data until the next tick) and clear endedAt so
|
||||
// the next sleep can cold-restore this session again.
|
||||
reopenSession(sessionId: string): void {
|
||||
this.disabledSessions.delete(sessionId)
|
||||
this.registerWriter(sessionId)
|
||||
const writer = this.writers.get(sessionId)
|
||||
if (!writer) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.updateMeta(writer.dir, { endedAt: null, exitCode: null })
|
||||
} catch (err) {
|
||||
this.handleWriteError(sessionId, err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends one take batch to the incremental log. Returns 'needs-checkpoint'
|
||||
* when the log is at capacity — the caller must take a full snapshot, which
|
||||
* subsumes the un-appended records (they were already applied to the live
|
||||
|
||||
@@ -166,14 +166,22 @@ 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'],
|
||||
['OSC 12 cursor-color', '\x1b]12;?\x1b\\'],
|
||||
['DA1 device-attributes', '\x1b[c'],
|
||||
['DSR cursor-position', '\x1b[6n']
|
||||
['DA2 secondary device-attributes', '\x1b[>c'],
|
||||
['DSR terminal status', '\x1b[5n'],
|
||||
['DSR cursor-position', '\x1b[6n'],
|
||||
['DECRPM bracketed-paste mode', '\x1b[?2004$p']
|
||||
])('does not reply to %s query', async (_label, query) => {
|
||||
createSession({ shellReadySupported: false })
|
||||
subprocess.simulateData(query)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { TerminalModes } from './types'
|
||||
|
||||
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
|
||||
|
||||
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
|
||||
// Keep parser state far beyond normal mode lists while still bounding memory.
|
||||
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
|
||||
|
||||
/**
|
||||
* Mirrors DECSET mouse-protocol/encoding state from the raw byte stream.
|
||||
* xterm's public modes API does not expose which mouse protocol is active,
|
||||
* so snapshots track it independently of the headless terminal; callers
|
||||
* must feed `scan()` the same bytes the terminal parsed, in order.
|
||||
*/
|
||||
export class TerminalMouseModeMirror {
|
||||
private scanTail = ''
|
||||
private trackingModeState: MouseTrackingMode = 'none'
|
||||
private sgrMouseModeState = false
|
||||
private sgrMousePixelsModeState = false
|
||||
|
||||
get mouseTrackingMode(): MouseTrackingMode {
|
||||
return this.trackingModeState
|
||||
}
|
||||
|
||||
get sgrMouseMode(): boolean {
|
||||
return this.sgrMouseModeState
|
||||
}
|
||||
|
||||
get sgrMousePixelsMode(): boolean {
|
||||
return this.sgrMousePixelsModeState
|
||||
}
|
||||
|
||||
scan(data: string): void {
|
||||
const input = this.scanTail + data
|
||||
this.scanTail = this.extractScanTail(input)
|
||||
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
|
||||
const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = privateModeRe.exec(input)) !== null) {
|
||||
if (match[0] === '\x1bc') {
|
||||
this.trackingModeState = 'none'
|
||||
this.sgrMouseModeState = false
|
||||
this.sgrMousePixelsModeState = false
|
||||
continue
|
||||
}
|
||||
const params = match[1] ?? match[3]
|
||||
const enabled = (match[2] ?? match[4]) === 'h'
|
||||
for (const rawParam of params.split(';')) {
|
||||
if (rawParam === '') {
|
||||
continue
|
||||
}
|
||||
const param = Number(rawParam)
|
||||
if (!Number.isInteger(param)) {
|
||||
continue
|
||||
}
|
||||
if (param === 9) {
|
||||
this.trackingModeState = enabled ? 'x10' : 'none'
|
||||
}
|
||||
if (param === 1000) {
|
||||
this.trackingModeState = enabled ? 'vt200' : 'none'
|
||||
}
|
||||
if (param === 1002) {
|
||||
this.trackingModeState = enabled ? 'drag' : 'none'
|
||||
}
|
||||
if (param === 1003) {
|
||||
this.trackingModeState = enabled ? 'any' : 'none'
|
||||
}
|
||||
if (param === 1006) {
|
||||
this.sgrMouseModeState = enabled
|
||||
this.sgrMousePixelsModeState = false
|
||||
}
|
||||
if (param === 1016) {
|
||||
this.sgrMouseModeState = false
|
||||
this.sgrMousePixelsModeState = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractScanTail(input: string): string {
|
||||
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
|
||||
if (start === -1) {
|
||||
return ''
|
||||
}
|
||||
const tail = input.slice(start)
|
||||
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
|
||||
return ''
|
||||
}
|
||||
if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
|
||||
return tail
|
||||
}
|
||||
if (tail.startsWith('\x1b[?')) {
|
||||
return this.isIncompleteParams(tail.slice(3)) ? tail : ''
|
||||
}
|
||||
if (tail.startsWith('\x9b?')) {
|
||||
return this.isIncompleteParams(tail.slice(2)) ? tail : ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private isIncompleteParams(params: string): boolean {
|
||||
return /^[0-9;]*$/.test(params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { extractLastOscTitle } from '../../shared/agent-detection'
|
||||
import { parseFileUriPath } from './osc7-file-uri'
|
||||
import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction'
|
||||
|
||||
const OSC_SCAN_TAIL_LIMIT = 4096
|
||||
|
||||
/** Mirror of the OSC sequences the emulator tracks outside xterm: OSC 7 cwd
|
||||
* updates and OSC 0/2 titles. Keeps an unterminated-sequence tail so
|
||||
* sequences split across PTY chunks still parse. Uses the bounded regex-free
|
||||
* scanners so giant pasted chunks stay cheap. */
|
||||
export class TerminalOscCwdTitleScanner {
|
||||
private scanTail = ''
|
||||
cwd: string | null = null
|
||||
lastTitle: string | null = null
|
||||
|
||||
scan(data: string): void {
|
||||
const input = this.scanTail + data
|
||||
this.scanTail = extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT)
|
||||
scanOsc7Uris(input, (uri) => {
|
||||
const parsed = parseFileUriPath(uri)
|
||||
if (parsed) {
|
||||
this.cwd = parsed
|
||||
}
|
||||
})
|
||||
const lastTitle = extractLastOscTitle(input)
|
||||
if (lastTitle !== null) {
|
||||
this.lastTitle = lastTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute
|
||||
* bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime
|
||||
* headless emulator. The headless xterm core has no theme service, so these
|
||||
* handlers compute replies from the renderer's pushed attribute snapshot,
|
||||
* with per-PTY OSC SET mutations layered on top — mirroring exactly what the
|
||||
* renderer's ThemeService reports for a visible pane. Replies route through
|
||||
* the caller's emit sink, which the slice-1 forwarding window already gates,
|
||||
* so seeded/replayed bytes and delivered chunks never produce a reply.
|
||||
*/
|
||||
import type { Terminal } from '@xterm/headless'
|
||||
import {
|
||||
formatXColorRgbSpec,
|
||||
parseXColorSpec,
|
||||
TERMINAL_VIEW_ANSI_COLOR_COUNT,
|
||||
type TerminalViewAttributes,
|
||||
type TerminalViewRgb
|
||||
} from '../../shared/terminal-view-attributes'
|
||||
|
||||
type ViewAttributeParser = Pick<Terminal['parser'], 'registerOscHandler' | 'registerCsiHandler'>
|
||||
|
||||
export type TerminalViewAttributeResponderDeps = {
|
||||
parser: ViewAttributeParser
|
||||
/** Last renderer push, or null before the first push. Null means SILENCE
|
||||
* for every view-attribute query — a fabricated default would resurrect
|
||||
* the default-black OSC-11 bug (design invariant 3). */
|
||||
getBaseAttributes: () => TerminalViewAttributes | null
|
||||
/** Must already be replay/forwarding-window gated by the caller. */
|
||||
emitReply: (reply: string) => void
|
||||
}
|
||||
|
||||
export type TerminalViewAttributeResponder = {
|
||||
/** A changed renderer attribute push replaces the whole palette, exactly
|
||||
* like xterm's ThemeService `_setTheme` overwrites OSC-SET-mutated colors
|
||||
* on a visible pane's theme apply. Identical re-pushes (fresh renderer
|
||||
* process) are filtered in main's store and never reach this. */
|
||||
clearColorOverrides: () => void
|
||||
}
|
||||
|
||||
type SpecialColorSlot = 'foreground' | 'background' | 'cursor'
|
||||
|
||||
// OSC 10/11/12 stack extra params onto consecutive slots (xterm's
|
||||
// _setOrReportSpecialColor): `OSC 10;?;?` reports foreground then background.
|
||||
const SPECIAL_COLOR_SLOTS: SpecialColorSlot[] = ['foreground', 'background', 'cursor']
|
||||
const SPECIAL_COLOR_IDENTS: Record<SpecialColorSlot, string> = {
|
||||
foreground: '10',
|
||||
background: '11',
|
||||
cursor: '12'
|
||||
}
|
||||
|
||||
function isValidColorIndex(value: number): boolean {
|
||||
return value >= 0 && value < TERMINAL_VIEW_ANSI_COLOR_COUNT
|
||||
}
|
||||
|
||||
// Mirror of xterm's rgb.relativeLuminance2 (common/Color.ts, WCAG formula) —
|
||||
// the math CoreBrowserTerminal._reportColorScheme answers ?996n with.
|
||||
function relativeLuminance([r, g, b]: TerminalViewRgb): number {
|
||||
const linear = (channel: number): number => {
|
||||
const c = channel / 255
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722
|
||||
}
|
||||
|
||||
export function installTerminalViewAttributeResponder(
|
||||
deps: TerminalViewAttributeResponderDeps
|
||||
): TerminalViewAttributeResponder {
|
||||
// Why per-instance maps: SET mutations are per PTY (one emulator per PTY);
|
||||
// they die with the emulator at teardown, like every other model state.
|
||||
// They deliberately survive a reveal→re-hide cycle even though the revealed
|
||||
// xterm restores without palette mutations (SerializeAddon emits no OSC
|
||||
// color SETs): the TUI never reset its SET, so holding it is
|
||||
// protocol-correct — the visible-side loss is the pre-existing restore
|
||||
// limitation, not this model's.
|
||||
const ansiOverrides = new Map<number, TerminalViewRgb>()
|
||||
const specialOverrides = new Map<SpecialColorSlot, TerminalViewRgb>()
|
||||
|
||||
const reportColor = (ident: string, rgb: TerminalViewRgb): void => {
|
||||
// Why ST (not BEL) and 16-bit channels: byte-for-byte parity with the
|
||||
// renderer xterm's reply (CoreBrowserTerminal._handleColorEvent).
|
||||
deps.emitReply(`\x1b]${ident};${formatXColorRgbSpec(rgb)}\x1b\\`)
|
||||
}
|
||||
|
||||
const handleSpecialColor = (data: string, offset: number): boolean => {
|
||||
const slots = data.split(';')
|
||||
for (let i = 0; i < slots.length; ++i, ++offset) {
|
||||
if (offset >= SPECIAL_COLOR_SLOTS.length) {
|
||||
break
|
||||
}
|
||||
const slot = SPECIAL_COLOR_SLOTS[offset]
|
||||
if (slots[i] === '?') {
|
||||
const base = deps.getBaseAttributes()
|
||||
if (base) {
|
||||
reportColor(SPECIAL_COLOR_IDENTS[slot], specialOverrides.get(slot) ?? base[slot])
|
||||
}
|
||||
} else {
|
||||
const rgb = parseXColorSpec(slots[i])
|
||||
if (rgb) {
|
||||
specialOverrides.set(slot, rgb)
|
||||
}
|
||||
}
|
||||
}
|
||||
// True consumes the sequence; the headless core's own OSC 10/11/12
|
||||
// handler only fires an onColor event nothing consumes.
|
||||
return true
|
||||
}
|
||||
|
||||
deps.parser.registerOscHandler(4, (data) => {
|
||||
const slots = data.split(';')
|
||||
while (slots.length > 1) {
|
||||
const idx = slots.shift() as string
|
||||
const spec = slots.shift() as string
|
||||
if (!/^\d+$/.exec(idx)) {
|
||||
continue
|
||||
}
|
||||
const index = parseInt(idx, 10)
|
||||
if (!isValidColorIndex(index)) {
|
||||
continue
|
||||
}
|
||||
if (spec === '?') {
|
||||
const base = deps.getBaseAttributes()
|
||||
if (base) {
|
||||
reportColor(`4;${index}`, ansiOverrides.get(index) ?? base.ansi[index])
|
||||
}
|
||||
} else {
|
||||
const rgb = parseXColorSpec(spec)
|
||||
if (rgb) {
|
||||
ansiOverrides.set(index, rgb)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
deps.parser.registerOscHandler(10, (data) => handleSpecialColor(data, 0))
|
||||
deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1))
|
||||
deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2))
|
||||
|
||||
// OSC 104/110/111/112 restore the themed color — dropping the override
|
||||
// falls back to the pushed base, the model twin of ThemeService.restoreColor.
|
||||
deps.parser.registerOscHandler(104, (data) => {
|
||||
if (!data) {
|
||||
ansiOverrides.clear()
|
||||
return true
|
||||
}
|
||||
for (const slot of data.split(';')) {
|
||||
if (/^\d+$/.exec(slot)) {
|
||||
ansiOverrides.delete(parseInt(slot, 10))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
deps.parser.registerOscHandler(110, () => {
|
||||
specialOverrides.delete('foreground')
|
||||
return true
|
||||
})
|
||||
deps.parser.registerOscHandler(111, () => {
|
||||
specialOverrides.delete('background')
|
||||
return true
|
||||
})
|
||||
deps.parser.registerOscHandler(112, () => {
|
||||
specialOverrides.delete('cursor')
|
||||
return true
|
||||
})
|
||||
|
||||
deps.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => {
|
||||
if (params[0] !== 996) {
|
||||
// Fall through to the core for every other private DSR (?6n CPR etc.).
|
||||
return false
|
||||
}
|
||||
const base = deps.getBaseAttributes()
|
||||
if (base) {
|
||||
// Why luminance and not base.colorSchemeMode: a visible xterm answers
|
||||
// ?996n from the relative luminance of the CURRENT (OSC-SET-mutated)
|
||||
// background vs foreground (CoreBrowserTerminal._reportColorScheme),
|
||||
// so a dark terminal theme in a light app mode still answers dark.
|
||||
// colorSchemeMode is the app mode and feeds the 2031/997 path only.
|
||||
const background = specialOverrides.get('background') ?? base.background
|
||||
const foreground = specialOverrides.get('foreground') ?? base.foreground
|
||||
const dark = relativeLuminance(background) < relativeLuminance(foreground)
|
||||
deps.emitReply(`\x1b[?997;${dark ? 1 : 2}n`)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return {
|
||||
clearColorOverrides: () => {
|
||||
ansiOverrides.clear()
|
||||
specialOverrides.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,15 @@ export type TerminalModes = {
|
||||
sgrMousePixelsMode?: boolean
|
||||
applicationCursor: boolean
|
||||
alternateScreen: boolean
|
||||
/** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed
|
||||
* 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). */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
// The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it
|
||||
|
||||
+27
-1
@@ -158,6 +158,7 @@ import {
|
||||
type SyntheticTitleSpinnerEntry
|
||||
} from './synthetic-title-spinner'
|
||||
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
|
||||
import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing'
|
||||
import { isCrashReportReason } from '../shared/crash-reporting'
|
||||
import {
|
||||
getSyntheticAgentTitleProfile,
|
||||
@@ -166,6 +167,7 @@ import {
|
||||
} from '../shared/synthetic-agent-title'
|
||||
import type { AgentStatusState } from '../shared/agent-status-types'
|
||||
import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import { KeybindingService } from './keybindings/keybinding-service'
|
||||
import { applyElectronProxySettings } from './network/proxy-settings'
|
||||
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
|
||||
@@ -1382,7 +1384,17 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool
|
||||
) {
|
||||
return
|
||||
}
|
||||
mainWindow.webContents.send('pty:data', { id: ptyId, data })
|
||||
// Why: feed the per-PTY tracker directly (never onPtyData — emulator state,
|
||||
// tails, transcripts, and stats must not see fabricated bytes) so synthetic
|
||||
// titles/BELs reach pty:sideEffect consumers when main holds side-effect
|
||||
// authority.
|
||||
runtime?.ingestSyntheticTitleFrame(ptyId, data)
|
||||
// Why: only the kill-switch-off renderer still byte-parses synthetic frames;
|
||||
// under main authority the copy would just mint phantom ACKs for unmetered
|
||||
// bytes (see synthetic-title-frame-routing.ts).
|
||||
if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) {
|
||||
mainWindow.webContents.send('pty:data', { id: ptyId, data })
|
||||
}
|
||||
}
|
||||
|
||||
function isSyntheticTitleWindowVisible(): boolean {
|
||||
@@ -1656,6 +1668,20 @@ app.whenReady().then(async () => {
|
||||
onTerminalAgentStatus: (event) => {
|
||||
agentHookServer.ingestTerminalStatus(event)
|
||||
},
|
||||
// Why: derived title/bell/agent facts ride one batched main→renderer
|
||||
// channel (terminal-side-effect-authority.md). The renderer's authority
|
||||
// kill switch decides whether to consume. Headless serve never creates a
|
||||
// window, so the dep is omitted entirely — the runtime then skips fact
|
||||
// batch construction and the per-chunk bell walk.
|
||||
...(isServeMode
|
||||
? {}
|
||||
: {
|
||||
onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('pty:sideEffect', batch)
|
||||
}
|
||||
}
|
||||
}),
|
||||
// Why: hook-reported agent status is the same source the desktop sidebar
|
||||
// reads. worktree.ps pulls it at query time so mobile shows the same agents.
|
||||
getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest,
|
||||
clearHiddenRendererPtyDeliveryState,
|
||||
getHiddenRendererPtyDeliveryDebug,
|
||||
isHiddenPtyDeliveryGateEnabled,
|
||||
markHiddenRendererPty,
|
||||
recordHiddenRendererPtyDataDrop,
|
||||
resetRendererScopedHiddenPtyDeliveryState,
|
||||
setRendererPtyDeliveryInterest,
|
||||
shouldDropHiddenRendererPtyData,
|
||||
unmarkHiddenRendererPty
|
||||
} from './pty-hidden-delivery-gate'
|
||||
|
||||
const PTY_ID = 'pty-1'
|
||||
|
||||
describe('pty hidden delivery gate', () => {
|
||||
beforeEach(() => {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest()
|
||||
})
|
||||
|
||||
it('only operates when both kill switches are on (default on)', () => {
|
||||
expect(isHiddenPtyDeliveryGateEnabled(undefined)).toBe(true)
|
||||
expect(isHiddenPtyDeliveryGateEnabled({})).toBe(true)
|
||||
expect(isHiddenPtyDeliveryGateEnabled({ terminalHiddenDeliveryGate: false })).toBe(false)
|
||||
expect(isHiddenPtyDeliveryGateEnabled({ terminalMainSideEffectAuthority: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('drops only hidden PTYs without registered delivery interest', () => {
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, { terminalHiddenDeliveryGate: false })).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
setRendererPtyDeliveryInterest(PTY_ID, true)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
setRendererPtyDeliveryInterest(PTY_ID, false)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('requests the restore marker exactly once per drop episode, re-armed by unmark', () => {
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true)
|
||||
expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(false)
|
||||
|
||||
// Why: unmark consumes the latch (and re-emits via its own return value);
|
||||
// the next hidden period's first drop reports again.
|
||||
unmarkHiddenRendererPty(PTY_ID)
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps drop memory when an already-dropped PTY is re-marked hidden', () => {
|
||||
// Why: a hidden remount or renderer reload re-marks without an unhide in
|
||||
// between — clearing the latch there would make reveal skip the restore.
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 10)
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true)
|
||||
})
|
||||
|
||||
it('reports drops on unhide so reveal can heal a replaced renderer view', () => {
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(false)
|
||||
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 10)
|
||||
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('clears renderer-scoped state on reload while preserving drop memory', () => {
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 10)
|
||||
setRendererPtyDeliveryInterest('pty-2', true)
|
||||
markHiddenRendererPty('pty-2')
|
||||
|
||||
resetRendererScopedHiddenPtyDeliveryState()
|
||||
|
||||
// Hidden marks and interest holds died with the old renderer process.
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({
|
||||
hiddenDeliveryGatedPtyCount: 0,
|
||||
deliveryInterestPtyCount: 0
|
||||
})
|
||||
// pty-2's leaked interest is gone: re-marking gates it again.
|
||||
markHiddenRendererPty('pty-2')
|
||||
expect(shouldDropHiddenRendererPtyData('pty-2', {})).toBe(true)
|
||||
// Drop memory survives so the new renderer's first unhide still restores.
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true)
|
||||
})
|
||||
|
||||
it('clears all per-PTY state on teardown and tracks debug counters', () => {
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
setRendererPtyDeliveryInterest('pty-2', true)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 7)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 5)
|
||||
|
||||
expect(getHiddenRendererPtyDeliveryDebug()).toEqual({
|
||||
hiddenDeliveryGatedPtyCount: 1,
|
||||
deliveryInterestPtyCount: 1,
|
||||
hiddenDeliveryDroppedChars: 12,
|
||||
hiddenDeliveryDroppedChunks: 2
|
||||
})
|
||||
|
||||
clearHiddenRendererPtyDeliveryState(PTY_ID)
|
||||
clearHiddenRendererPtyDeliveryState('pty-2')
|
||||
expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({
|
||||
hiddenDeliveryGatedPtyCount: 0,
|
||||
deliveryInterestPtyCount: 0
|
||||
})
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Main-side hidden-delivery gate for renderer PTY byte delivery (Phase 4 of
|
||||
* the terminal model/view architecture).
|
||||
*
|
||||
* The renderer marks a PTY hidden when no visible view consumes its bytes;
|
||||
* main then drops renderer-bound delivery AFTER model ingestion — the runtime
|
||||
* already parsed the chunk, and reveal restores from the model snapshot via
|
||||
* the existing seq-guarded machinery. Any renderer party that still needs raw
|
||||
* bytes (dispatcher sidecars, eager pre-mount buffers) registers delivery
|
||||
* interest, which suppresses the gate for that PTY.
|
||||
* See docs/reference/terminal-side-effect-authority.md (Open Items).
|
||||
*/
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
|
||||
export type HiddenPtyDeliveryGateSettings = Pick<
|
||||
GlobalSettings,
|
||||
'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate'
|
||||
>
|
||||
|
||||
const hiddenRendererPtys = new Set<string>()
|
||||
// Why: sidecar consumers (paste-draft pacing, background agent launches,
|
||||
// automation observers, the kill-switch-off parked 2031 responder) and eager
|
||||
// pre-mount buffers need live bytes even while no visible view exists. Any
|
||||
// registered interest suppresses the gate for that PTY.
|
||||
const deliveryInterestRendererPtys = new Set<string>()
|
||||
// Why: reveal must restore from the model only when bytes were actually
|
||||
// dropped. Doubles as the one-shot marker latch: the first gated drop emits a
|
||||
// restore marker, and the latch is consumed only by unmark (which re-emits)
|
||||
// or full PTY teardown — never by re-marking hidden, so drop memory survives
|
||||
// hidden remounts and renderer reloads.
|
||||
const droppedSinceHiddenPtys = new Set<string>()
|
||||
|
||||
let droppedHiddenDeliveryChars = 0
|
||||
let droppedHiddenDeliveryChunks = 0
|
||||
|
||||
/** Gate kill switches, both read main-side: the gate only operates under main
|
||||
* side-effect authority AND the gate-specific setting (both default on). */
|
||||
export function isHiddenPtyDeliveryGateEnabled(
|
||||
settings: HiddenPtyDeliveryGateSettings | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
settings?.terminalMainSideEffectAuthority !== false &&
|
||||
settings?.terminalHiddenDeliveryGate !== false
|
||||
)
|
||||
}
|
||||
|
||||
/** Renderer-reported "no visible view needs bytes" bit. Never clears drop
|
||||
* memory: a hidden remount or renderer reload re-marks an already-dropped
|
||||
* PTY, and erasing the latch there would make the eventual reveal skip the
|
||||
* restore. Unmark is the only consumer of the latch. */
|
||||
export function markHiddenRendererPty(id: string): void {
|
||||
hiddenRendererPtys.add(id)
|
||||
}
|
||||
|
||||
/** Clears the hidden bit. Returns whether bytes were dropped while hidden so
|
||||
* the caller can emit a restore marker to the now-visible renderer. */
|
||||
export function unmarkHiddenRendererPty(id: string): { droppedWhileHidden: boolean } {
|
||||
hiddenRendererPtys.delete(id)
|
||||
const droppedWhileHidden = droppedSinceHiddenPtys.delete(id)
|
||||
return { droppedWhileHidden }
|
||||
}
|
||||
|
||||
export function isHiddenRendererPty(id: string): boolean {
|
||||
return hiddenRendererPtys.has(id)
|
||||
}
|
||||
|
||||
/** Renderer-side ref-counted interest, surfaced as boolean transitions. */
|
||||
export function setRendererPtyDeliveryInterest(id: string, interested: boolean): void {
|
||||
if (interested) {
|
||||
deliveryInterestRendererPtys.add(id)
|
||||
} else {
|
||||
deliveryInterestRendererPtys.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldDropHiddenRendererPtyData(
|
||||
id: string,
|
||||
settings: HiddenPtyDeliveryGateSettings | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
isHiddenPtyDeliveryGateEnabled(settings) &&
|
||||
hiddenRendererPtys.has(id) &&
|
||||
!deliveryInterestRendererPtys.has(id)
|
||||
)
|
||||
}
|
||||
|
||||
/** Record one gated drop. Returns whether the caller should emit the one-shot
|
||||
* empty restore-marker chunk (first drop since this PTY went hidden). */
|
||||
export function recordHiddenRendererPtyDataDrop(
|
||||
id: string,
|
||||
chars: number
|
||||
): { shouldEmitRestoreMarker: boolean } {
|
||||
droppedHiddenDeliveryChars += chars
|
||||
droppedHiddenDeliveryChunks += 1
|
||||
if (droppedSinceHiddenPtys.has(id)) {
|
||||
return { shouldEmitRestoreMarker: false }
|
||||
}
|
||||
droppedSinceHiddenPtys.add(id)
|
||||
return { shouldEmitRestoreMarker: true }
|
||||
}
|
||||
|
||||
/** Renderer process replaced (reload / crash): its ref-counted interest
|
||||
* holds and hidden marks died with it, so keeping them would gate (or
|
||||
* force-feed) PTYs no live renderer party asked about. Drop memory is
|
||||
* preserved — surviving daemon/SSH PTYs may have dropped bytes the old
|
||||
* renderer never restored; the new renderer's first hidden/visible sync
|
||||
* re-marks or unmarks and the unmark path re-emits the restore marker. */
|
||||
export function resetRendererScopedHiddenPtyDeliveryState(): void {
|
||||
hiddenRendererPtys.clear()
|
||||
deliveryInterestRendererPtys.clear()
|
||||
}
|
||||
|
||||
/** Full per-PTY teardown — wired into clearProviderPtyState so every exit
|
||||
* path (local, daemon, SSH, connection teardown) releases gate state. */
|
||||
export function clearHiddenRendererPtyDeliveryState(id: string): void {
|
||||
hiddenRendererPtys.delete(id)
|
||||
deliveryInterestRendererPtys.delete(id)
|
||||
droppedSinceHiddenPtys.delete(id)
|
||||
}
|
||||
|
||||
export type HiddenRendererPtyDeliveryDebug = {
|
||||
hiddenDeliveryGatedPtyCount: number
|
||||
deliveryInterestPtyCount: number
|
||||
hiddenDeliveryDroppedChars: number
|
||||
hiddenDeliveryDroppedChunks: number
|
||||
}
|
||||
|
||||
export function getHiddenRendererPtyDeliveryDebug(): HiddenRendererPtyDeliveryDebug {
|
||||
return {
|
||||
hiddenDeliveryGatedPtyCount: hiddenRendererPtys.size,
|
||||
deliveryInterestPtyCount: deliveryInterestRendererPtys.size,
|
||||
hiddenDeliveryDroppedChars: droppedHiddenDeliveryChars,
|
||||
hiddenDeliveryDroppedChunks: droppedHiddenDeliveryChunks
|
||||
}
|
||||
}
|
||||
|
||||
export function resetHiddenRendererPtyDeliveryDebugCounters(): void {
|
||||
droppedHiddenDeliveryChars = 0
|
||||
droppedHiddenDeliveryChunks = 0
|
||||
}
|
||||
|
||||
/** Test seam: reset all module state between tests. */
|
||||
export function _resetHiddenRendererPtyDeliveryGateForTest(): void {
|
||||
hiddenRendererPtys.clear()
|
||||
deliveryInterestRendererPtys.clear()
|
||||
droppedSinceHiddenPtys.clear()
|
||||
resetHiddenRendererPtyDeliveryDebugCounters()
|
||||
}
|
||||
+843
-16
File diff suppressed because it is too large
Load Diff
+321
-4
@@ -87,6 +87,26 @@ import {
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { mergePersistedWindowsPath } from '../pty/windows-environment-path'
|
||||
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
|
||||
import {
|
||||
clearHiddenRendererPtyDeliveryState,
|
||||
getHiddenRendererPtyDeliveryDebug,
|
||||
isHiddenPtyDeliveryGateEnabled,
|
||||
markHiddenRendererPty,
|
||||
recordHiddenRendererPtyDataDrop,
|
||||
resetHiddenRendererPtyDeliveryDebugCounters,
|
||||
resetRendererScopedHiddenPtyDeliveryState,
|
||||
setRendererPtyDeliveryInterest,
|
||||
shouldDropHiddenRendererPtyData,
|
||||
unmarkHiddenRendererPty
|
||||
} from './pty-hidden-delivery-gate'
|
||||
import {
|
||||
clearNativeWindowsConptyPty,
|
||||
isNativeWindowsLocalPtySpawn,
|
||||
markNativeWindowsConptyPty
|
||||
} from '../runtime/terminal-model-query-authority'
|
||||
import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store'
|
||||
import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes'
|
||||
import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker'
|
||||
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
|
||||
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
|
||||
@@ -1045,6 +1065,12 @@ export function clearProviderPtyState(id: string): void {
|
||||
pendingHiddenRendererResizeOutputPtys.delete(id)
|
||||
deliveredHiddenRendererResizeOutputPtys.delete(id)
|
||||
clearStartupTerminalColorQueryReplies(id)
|
||||
// Why: every PTY teardown path funnels through here (local exit, daemon
|
||||
// shutdown, SSH exit/connection teardown) — hidden/interest gate bits must
|
||||
// not outlive the PTY or a reused map entry could silently gate a new one.
|
||||
clearHiddenRendererPtyDeliveryState(id)
|
||||
// Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id.
|
||||
clearNativeWindowsConptyPty(id)
|
||||
const paneKey = ptyPaneKey.get(id)
|
||||
const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false
|
||||
// Why: drop the memory-collector registration so a dead PTY does not keep
|
||||
@@ -1124,6 +1150,14 @@ let didFinishLoadHandler: (() => void) | null = null
|
||||
let didFinishLoadWebContents: WebContents | null = null
|
||||
let rendererLifecycleResetWebContents: WebContents | null = null
|
||||
let rendererLifecycleResetHandler: (() => void) | null = null
|
||||
// Why: the hidden-delivery gate's interest/hidden registries mirror renderer
|
||||
// state (ref-counted holds, per-pane hidden marks). A reload or renderer
|
||||
// crash destroys the owners without unregistering, so the registries are
|
||||
// reset whenever the renderer process is replaced
|
||||
// (resetRendererScopedHiddenPtyDeliveryState preserves drop memory).
|
||||
let rendererGateResetLoadHandler: (() => void) | null = null
|
||||
let rendererGateResetGoneHandler: (() => void) | null = null
|
||||
let rendererGateResetWebContents: WebContents | null = null
|
||||
|
||||
// Why: the "Restart daemon" path needs to re-bind provider→renderer listeners
|
||||
// against the freshly-created adapter after replaceDaemonProvider swaps the
|
||||
@@ -1151,6 +1185,11 @@ export type PtyRendererDeliveryDebugSnapshot = {
|
||||
peakRendererInFlightChars: number
|
||||
peakMaxRendererInFlightCharsByPty: number
|
||||
ackGatedFlushSkipCount: number
|
||||
hiddenDeliveryGatedPtyCount: number
|
||||
deliveryInterestPtyCount: number
|
||||
hiddenDeliveryDroppedChars: number
|
||||
hiddenDeliveryDroppedChunks: number
|
||||
pendingDroppedChars: number
|
||||
}
|
||||
|
||||
const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = {
|
||||
@@ -1166,7 +1205,12 @@ const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapsh
|
||||
peakMaxPendingCharsByPty: 0,
|
||||
peakRendererInFlightChars: 0,
|
||||
peakMaxRendererInFlightCharsByPty: 0,
|
||||
ackGatedFlushSkipCount: 0
|
||||
ackGatedFlushSkipCount: 0,
|
||||
hiddenDeliveryGatedPtyCount: 0,
|
||||
deliveryInterestPtyCount: 0,
|
||||
hiddenDeliveryDroppedChars: 0,
|
||||
hiddenDeliveryDroppedChunks: 0,
|
||||
pendingDroppedChars: 0
|
||||
}
|
||||
|
||||
let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({
|
||||
@@ -1226,6 +1270,23 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void
|
||||
webContents.on('destroyed', rendererLifecycleResetHandler)
|
||||
}
|
||||
|
||||
function clearRendererGateResetHandlers(): void {
|
||||
if (rendererGateResetWebContents) {
|
||||
if (rendererGateResetLoadHandler) {
|
||||
rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler)
|
||||
}
|
||||
if (rendererGateResetGoneHandler) {
|
||||
rendererGateResetWebContents.removeListener(
|
||||
'render-process-gone',
|
||||
rendererGateResetGoneHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
rendererGateResetLoadHandler = null
|
||||
rendererGateResetGoneHandler = null
|
||||
rendererGateResetWebContents = null
|
||||
}
|
||||
|
||||
// Why: the "Restart daemon" flow needs to detach listeners from the current
|
||||
// adapter *after* synthetic pty:exit events fan out (so the renderer receives
|
||||
// them) but *before* replaceDaemonProvider swaps in the new adapter (so the
|
||||
@@ -1277,6 +1338,7 @@ export function registerPtyHandlers(
|
||||
ipcMain.removeHandler('pty:settlePaneSerializer')
|
||||
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
|
||||
ipcMain.removeHandler('pty:getMainBufferSnapshot')
|
||||
ipcMain.removeHandler('pty:sideEffectSnapshot')
|
||||
ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot')
|
||||
ipcMain.removeHandler('pty:resetRendererDeliveryDebug')
|
||||
ipcMain.removeHandler('pty:writeAccepted')
|
||||
@@ -1370,10 +1432,14 @@ export function registerPtyHandlers(
|
||||
}
|
||||
|
||||
const pendingData = new Map<string, PendingPtyData>()
|
||||
// Why: one restore marker per overflow episode — cleared when the entry
|
||||
// fully drains so a later overflow re-marks the renderer exactly once.
|
||||
const pendingOverflowMarkedPtys = new Set<string>()
|
||||
const rendererInFlightCharsByPty = new Map<string, number>()
|
||||
const trustedTerminalHandleEnv = new Set<string>()
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let rendererInFlightTotalChars = 0
|
||||
let pendingDroppedChars = 0
|
||||
const PTY_BATCH_INTERVAL_MS = 8
|
||||
const PTY_BATCH_DRAIN_CONTINUE_MS = 1
|
||||
const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024
|
||||
@@ -1421,6 +1487,7 @@ export function registerPtyHandlers(
|
||||
pendingChars += chars
|
||||
maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars)
|
||||
}
|
||||
const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug()
|
||||
return {
|
||||
pendingPtyCount: pendingData.size,
|
||||
pendingChars,
|
||||
@@ -1434,7 +1501,9 @@ export function registerPtyHandlers(
|
||||
peakMaxPendingCharsByPty,
|
||||
peakRendererInFlightChars,
|
||||
peakMaxRendererInFlightCharsByPty,
|
||||
ackGatedFlushSkipCount
|
||||
ackGatedFlushSkipCount,
|
||||
...hiddenDeliveryDebug,
|
||||
pendingDroppedChars
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,6 +1525,8 @@ export function registerPtyHandlers(
|
||||
peakRendererInFlightChars = 0
|
||||
peakMaxRendererInFlightCharsByPty = 0
|
||||
ackGatedFlushSkipCount = 0
|
||||
pendingDroppedChars = 0
|
||||
resetHiddenRendererPtyDeliveryDebugCounters()
|
||||
recordPtyRendererDeliveryPressure()
|
||||
}
|
||||
|
||||
@@ -1563,6 +1634,26 @@ export function registerPtyHandlers(
|
||||
deliveredHiddenRendererResizeOutputPtys.delete(id)
|
||||
}
|
||||
|
||||
// Why: when main drops renderer delivery (hidden gate / pending cap), an
|
||||
// explicit out-of-band pty:modelRestoreNeeded signal tells the renderer to
|
||||
// latch model-restore-needed. It must NOT ride pty:data: an in-band empty
|
||||
// chunk is indistinguishable from a chunk fully consumed by renderer-side
|
||||
// OSC-9999 stripping, which spuriously restored visible panes.
|
||||
function sendModelRestoreNeededMarker(
|
||||
id: string,
|
||||
reason: PtyModelRestoreReason,
|
||||
markerSeq: number | undefined
|
||||
): void {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
mainWindow.webContents.send('pty:modelRestoreNeeded', {
|
||||
id,
|
||||
reason,
|
||||
...(typeof markerSeq === 'number' ? { markerSeq } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function getPendingPtyFlushEntries(): [string, PendingPtyData][] {
|
||||
const entries = Array.from(pendingData.entries())
|
||||
const active: [string, PendingPtyData][] = []
|
||||
@@ -1597,6 +1688,15 @@ export function registerPtyHandlers(
|
||||
capChars
|
||||
})
|
||||
}
|
||||
// Why: with the hidden-delivery gate rolled out, the model snapshot can
|
||||
// recover the dropped middle — emit the out-of-band restore marker once
|
||||
// per overflow episode alongside the droppedOutput sentinel so a fresh
|
||||
// or reloaded view latches restore too.
|
||||
if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) {
|
||||
pendingOverflowMarkedPtys.add(id)
|
||||
sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id))
|
||||
}
|
||||
pendingDroppedChars += pending.data.length
|
||||
// Why empty data, not a trimmed tail: a mid-stream gap would silently
|
||||
// corrupt the pane. The droppedOutput sentinel routes the pane through
|
||||
// hidden-output restore, which repaints from the authoritative main-owned
|
||||
@@ -1653,16 +1753,29 @@ export function registerPtyHandlers(
|
||||
flushTimer = null
|
||||
if (mainWindow.isDestroyed()) {
|
||||
pendingData.clear()
|
||||
pendingOverflowMarkedPtys.clear()
|
||||
rendererInFlightCharsByPty.clear()
|
||||
rendererInFlightTotalChars = 0
|
||||
recordPtyRendererDeliveryPressure()
|
||||
return
|
||||
}
|
||||
const settings = getSettings?.()
|
||||
let writes = 0
|
||||
for (const [id, pending] of getPendingPtyFlushEntries()) {
|
||||
if (writes >= PTY_BATCH_FLUSH_MAX_WRITES) {
|
||||
break
|
||||
}
|
||||
// Why: hidden-gated bytes are dropped, never re-queued — the model
|
||||
// already ingested them; reveal restores from the snapshot+seq machinery.
|
||||
if (shouldDropHiddenRendererPtyData(id, settings)) {
|
||||
pendingData.delete(id)
|
||||
pendingOverflowMarkedPtys.delete(id)
|
||||
const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) {
|
||||
continue
|
||||
}
|
||||
@@ -1687,6 +1800,8 @@ export function registerPtyHandlers(
|
||||
nextPending.containsBackgroundOutput = true
|
||||
}
|
||||
pendingData.set(id, nextPending)
|
||||
} else {
|
||||
pendingOverflowMarkedPtys.delete(id)
|
||||
}
|
||||
sendPtyDataToRenderer(
|
||||
id,
|
||||
@@ -1759,6 +1874,7 @@ export function registerPtyHandlers(
|
||||
)
|
||||
pendingData.delete(payload.id)
|
||||
}
|
||||
pendingOverflowMarkedPtys.delete(payload.id)
|
||||
lastInputAtByPty.delete(payload.id)
|
||||
interactiveOutputCharsByPty.delete(payload.id)
|
||||
rendererInFlightTotalChars = Math.max(
|
||||
@@ -1821,11 +1937,25 @@ export function registerPtyHandlers(
|
||||
flushTimer = null
|
||||
}
|
||||
pendingData.clear()
|
||||
pendingOverflowMarkedPtys.clear()
|
||||
rendererInFlightCharsByPty.clear()
|
||||
rendererInFlightTotalChars = 0
|
||||
recordPtyRendererDeliveryPressure()
|
||||
return
|
||||
}
|
||||
const settings = getSettings?.()
|
||||
// Why: hidden-delivery gate — runtime ingestion above already consumed
|
||||
// the chunk; gated renderer delivery is DROPPED (never queued) and the
|
||||
// reveal path restores from the model snapshot via the seq guard. The
|
||||
// drop sits before the interactive bypass so gated PTYs take neither
|
||||
// the immediate nor the batched renderer path.
|
||||
if (shouldDropHiddenRendererPtyData(payload.id, settings)) {
|
||||
const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (rendererData.length === 0) {
|
||||
return
|
||||
}
|
||||
@@ -1859,6 +1989,7 @@ export function registerPtyHandlers(
|
||||
return
|
||||
}
|
||||
pendingData.delete(payload.id)
|
||||
pendingOverflowMarkedPtys.delete(payload.id)
|
||||
clearFlushTimerIfIdle()
|
||||
// Why: agent TUIs redraw small prompt regions after every keystroke.
|
||||
// Waiting for the throughput batch timer adds visible input latency.
|
||||
@@ -1981,6 +2112,19 @@ export function registerPtyHandlers(
|
||||
})
|
||||
}
|
||||
|
||||
// Why: a reload (did-finish-load) or renderer crash replaces the process
|
||||
// that owned every delivery-interest hold and hidden mark; surviving
|
||||
// daemon/SSH PTYs would otherwise stay force-fed (leaked interest defeats
|
||||
// the gate) or stay gated against a renderer that never marked them. Drop
|
||||
// memory is preserved — each pane's first sync re-marks/unmarks and the
|
||||
// unmark path re-emits the restore marker for unrestored drops.
|
||||
clearRendererGateResetHandlers()
|
||||
rendererGateResetLoadHandler = () => resetRendererScopedHiddenPtyDeliveryState()
|
||||
rendererGateResetGoneHandler = () => resetRendererScopedHiddenPtyDeliveryState()
|
||||
rendererGateResetWebContents = mainWindow.webContents
|
||||
mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler)
|
||||
mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler)
|
||||
|
||||
// Kill orphaned PTY processes from previous page loads when the renderer reloads.
|
||||
// Why: only applies to LocalPtyProvider where PTYs live in the Electron main
|
||||
// process and can become orphaned on page reload. Daemon-backed sessions
|
||||
@@ -2240,6 +2384,18 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
ptyOwnership.set(result.id, args.connectionId ?? null)
|
||||
// Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY
|
||||
// determination from the spawn record before any byte reaches the
|
||||
// runtime emulator, so its DA1 override exists from byte zero.
|
||||
if (
|
||||
isNativeWindowsLocalPtySpawn({
|
||||
connectionId: args.connectionId,
|
||||
cwd: args.cwd,
|
||||
shellOverride: daemonShellOverride
|
||||
})
|
||||
) {
|
||||
markNativeWindowsConptyPty(result.id)
|
||||
}
|
||||
const relayResultId = getRelayPtyId(args.connectionId, result.id)
|
||||
const persistSshLease = (): void => {
|
||||
if (!store || !args.connectionId) {
|
||||
@@ -2296,6 +2452,10 @@ export function registerPtyHandlers(
|
||||
if (args.worktreeId) {
|
||||
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
|
||||
}
|
||||
// Why: arms main's per-PTY Command Code output detector from the launch
|
||||
// command (renderer startupCommand parity); banner detection covers
|
||||
// PTYs spawned without one.
|
||||
runtime?.noteTerminalSpawnCommand?.(result.id, args.command ?? null)
|
||||
if (isClaudeLaunch) {
|
||||
markClaudePtySpawned(result.id)
|
||||
}
|
||||
@@ -2536,6 +2696,7 @@ export function registerPtyHandlers(
|
||||
cwd?: string | null
|
||||
lastTitle?: string
|
||||
seq?: number
|
||||
pendingDeliveryStartSeq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
alternateScreen?: boolean
|
||||
} | null> => {
|
||||
@@ -2544,13 +2705,43 @@ export function registerPtyHandlers(
|
||||
}
|
||||
const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows)
|
||||
try {
|
||||
return await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { scrollbackRows })
|
||||
const snapshot = await runtime.serializeHiddenOutputRecoveryBuffer(args.id, {
|
||||
scrollbackRows
|
||||
})
|
||||
if (!snapshot || typeof snapshot.seq !== 'number') {
|
||||
return snapshot
|
||||
}
|
||||
// Why: sampled after serialize — every byte at or below snapshot.seq
|
||||
// that can still reach the renderer sits in this pending queue. The
|
||||
// renderer's post-restore dedupe bounds its duplicate window with it;
|
||||
// without the bound a stale baseline silently swallows genuinely-new
|
||||
// chunks whose seq domain sits below the snapshot counter.
|
||||
const pending = pendingData.get(args.id)
|
||||
if (pending && typeof pending.startSeq !== 'number') {
|
||||
// Why: a seq-less backlog cannot be bounded — stay conservative.
|
||||
return snapshot
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
pendingDeliveryStartSeq: Math.min(pending?.startSeq ?? snapshot.seq, snapshot.seq)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Why: with main holding side-effect authority the renderer no longer
|
||||
// derives titles from replayed bytes on (re)attach. This title-only replay
|
||||
// snapshot restores title state — never historical bells/completions (the
|
||||
// no-attention-replay rule, terminal-side-effect-authority.md).
|
||||
ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => {
|
||||
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
|
||||
return null
|
||||
}
|
||||
return runtime.getTerminalSideEffectSnapshot(args.id)
|
||||
})
|
||||
|
||||
ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => {
|
||||
return getPtyRendererDeliveryDebugSnapshot()
|
||||
})
|
||||
@@ -2582,6 +2773,11 @@ export function registerPtyHandlers(
|
||||
foreground?: unknown
|
||||
background?: unknown
|
||||
}
|
||||
// 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.
|
||||
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
|
||||
@@ -2940,6 +3136,20 @@ export function registerPtyHandlers(
|
||||
return await existingPaneSpawn.promise
|
||||
}
|
||||
const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null
|
||||
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 {
|
||||
try {
|
||||
@@ -2958,6 +3168,11 @@ export function registerPtyHandlers(
|
||||
result = await provider.spawn(spawnOptions)
|
||||
spawnTiming.mark('provider_spawn')
|
||||
} 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 (preSpawnStartupTerminalColorReplyPtyId) {
|
||||
@@ -3022,6 +3237,30 @@ export function registerPtyHandlers(
|
||||
reattach: result.isReattach ?? false
|
||||
})
|
||||
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.
|
||||
if (
|
||||
isNativeWindowsLocalPtySpawn({
|
||||
connectionId: args.connectionId,
|
||||
cwd: args.cwd,
|
||||
shellOverride: effectiveShellOverride
|
||||
})
|
||||
) {
|
||||
markNativeWindowsConptyPty(result.id)
|
||||
}
|
||||
if (startupTerminalColorQueryReplyColors) {
|
||||
if (result.isReattach) {
|
||||
if (preSpawnStartupTerminalColorReplyPtyId) {
|
||||
@@ -3130,7 +3369,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' &&
|
||||
@@ -3149,6 +3399,13 @@ export function registerPtyHandlers(
|
||||
) {
|
||||
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
|
||||
}
|
||||
// Why: arms main's per-PTY Command Code output detector from the launch
|
||||
// command (renderer startupCommand parity); banner detection covers
|
||||
// PTYs spawned without one.
|
||||
runtime?.noteTerminalSpawnCommand?.(
|
||||
result.id,
|
||||
typeof args.command === 'string' ? args.command : null
|
||||
)
|
||||
if (isClaudeLaunch) {
|
||||
markClaudePtySpawned(result.id)
|
||||
}
|
||||
@@ -3532,6 +3789,66 @@ export function registerPtyHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:setHiddenRendererPty')
|
||||
ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => {
|
||||
if (typeof args.id !== 'string' || !args.id) {
|
||||
return
|
||||
}
|
||||
if (args.hidden === true) {
|
||||
markHiddenRendererPty(args.id)
|
||||
// Why: bytes already queued for a newly hidden PTY are model-owned
|
||||
// state; drop them now instead of holding them under ACK starvation.
|
||||
// Reveal restores from the snapshot.
|
||||
const pending = pendingData.get(args.id)
|
||||
if (pending && shouldDropHiddenRendererPtyData(args.id, getSettings?.())) {
|
||||
pendingData.delete(args.id)
|
||||
pendingOverflowMarkedPtys.delete(args.id)
|
||||
const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(
|
||||
args.id,
|
||||
'hidden-drop',
|
||||
runtime?.getPtyOutputSequence(args.id)
|
||||
)
|
||||
}
|
||||
recordPtyRendererDeliveryPressure()
|
||||
}
|
||||
return
|
||||
}
|
||||
const { droppedWhileHidden } = unmarkHiddenRendererPty(args.id)
|
||||
// Why: a renderer reload or remount can replace the view that latched
|
||||
// restore-needed from the first-drop marker. Re-emit on unhide so the
|
||||
// (possibly fresh) visible view still pulls the model snapshot covering
|
||||
// the dropped bytes. If the original view is still alive this can trigger
|
||||
// a redundant second restore — accepted: a snapshot replay is cheap and
|
||||
// idempotent, while a missed restore leaves a corrupt pane.
|
||||
if (droppedWhileHidden) {
|
||||
sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:terminalViewAttributes')
|
||||
ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => {
|
||||
// Why validate-or-drop: the responder must never store a malformed
|
||||
// palette — a wrong color reply breaks TUI theme detection worse than
|
||||
// the documented silent-until-first-push behavior.
|
||||
const attributes = validateTerminalViewAttributes(args)
|
||||
if (attributes) {
|
||||
setTerminalViewAttributes(attributes)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:setPtyDeliveryInterest')
|
||||
ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => {
|
||||
if (typeof args.id !== 'string' || !args.id) {
|
||||
return
|
||||
}
|
||||
// Why: explicit delivery-interest signal from renderer sidecars / eager
|
||||
// pre-mount buffers — any interest suppresses the hidden-delivery gate so
|
||||
// raw-byte consumers keep receiving while the view is hidden or parked.
|
||||
setRendererPtyDeliveryInterest(args.id, args.interested === true)
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:signal')
|
||||
ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => {
|
||||
tryGetProviderForPty(args.id)
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
applyElectronProxySettingsMock,
|
||||
browserWindowGetAllWindowsMock,
|
||||
handleMock,
|
||||
onMock,
|
||||
previewGhosttyImportMock,
|
||||
previewWarpThemeImportMock,
|
||||
prepareLocalWorktreeRootsForReposMock,
|
||||
@@ -14,6 +15,7 @@ const {
|
||||
applyElectronProxySettingsMock: vi.fn(),
|
||||
browserWindowGetAllWindowsMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
onMock: vi.fn(),
|
||||
previewGhosttyImportMock: vi.fn(),
|
||||
previewWarpThemeImportMock: vi.fn(),
|
||||
prepareLocalWorktreeRootsForReposMock: vi.fn(),
|
||||
@@ -22,7 +24,7 @@ const {
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock },
|
||||
ipcMain: { handle: handleMock },
|
||||
ipcMain: { handle: handleMock, on: onMock },
|
||||
nativeTheme: { themeSource: 'system' }
|
||||
}))
|
||||
|
||||
@@ -70,6 +72,7 @@ const store = {
|
||||
describe('registerSettingsHandlers', () => {
|
||||
beforeEach(() => {
|
||||
handleMock.mockClear()
|
||||
onMock.mockClear()
|
||||
applyAppIconMock.mockClear()
|
||||
applyElectronProxySettingsMock.mockClear()
|
||||
applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' })
|
||||
@@ -89,6 +92,22 @@ describe('registerSettingsHandlers', () => {
|
||||
expect(channels).toContain('settings:previewGhosttyImport')
|
||||
})
|
||||
|
||||
it('answers the synchronous settings read with the persisted settings', () => {
|
||||
// Why: panes can bind PTYs before async hydration; the side-effect
|
||||
// authority kill switch needs the persisted value synchronously.
|
||||
store.getSettings.mockReturnValue({ terminalMainSideEffectAuthority: false })
|
||||
registerSettingsHandlers(store as never)
|
||||
|
||||
const listener = onMock.mock.calls.find(
|
||||
(call) => call[0] === 'settings:get-sync'
|
||||
)?.[1] as (event: { returnValue: unknown }) => void
|
||||
expect(listener).toBeTypeOf('function')
|
||||
|
||||
const event = { returnValue: undefined as unknown }
|
||||
listener(event)
|
||||
expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false })
|
||||
})
|
||||
|
||||
it('registers settings:previewWarpThemeImport handler', () => {
|
||||
registerSettingsHandlers(store as never)
|
||||
const channels = handleMock.mock.calls.map((call) => call[0])
|
||||
|
||||
@@ -66,6 +66,15 @@ export function registerSettingsHandlers(
|
||||
return store.getSettings()
|
||||
})
|
||||
|
||||
// Why: terminal panes can bind PTYs before async settings hydration
|
||||
// completes. The side-effect authority kill switch is consulted once at
|
||||
// transport creation, so the renderer needs the persisted value
|
||||
// synchronously or pre-hydration bindings would always pick main authority
|
||||
// (terminal-side-effect-authority.md, migration switch).
|
||||
ipcMain.on('settings:get-sync', (event) => {
|
||||
event.returnValue = store.getSettings()
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
|
||||
const sanitizedArgs = sanitizeRendererSettingsUpdate(args)
|
||||
// Why: Floating Workspace grants are trusted only when written by the
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type RuntimeTerminalAgentStatusEvent
|
||||
} from './orca-runtime'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
||||
import {
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES,
|
||||
TERMINAL_INPUT_MAX_BYTES,
|
||||
@@ -5394,6 +5395,654 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves tui-idle when a completion title is coalesced with the next working title', async () => {
|
||||
// Why: node-pty + the main batch window can coalesce "task done" and the
|
||||
// next task's working title into one chunk. A last-title reader never
|
||||
// sees the intermediate idle and the waiter hangs (issue #1083 class).
|
||||
const runtime = createRuntime()
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const wait = runtime.waitForTerminal(terminal.handle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: 1_000
|
||||
})
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07\x1b]0;Codex working\x07', 101)
|
||||
|
||||
await expect(wait).resolves.toMatchObject({
|
||||
handle: terminal.handle,
|
||||
condition: 'tui-idle',
|
||||
status: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores the bare cursor-agent native title so synthesized spinner state survives', async () => {
|
||||
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100)
|
||||
// cursor-agent re-emits its bare native title on internal redraws while
|
||||
// still working; it must not stomp the synthesized working title.
|
||||
runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', 101)
|
||||
|
||||
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
|
||||
title: '⠋ Cursor Agent'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a stale working title after 3s of title-less output', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData(ptyId, 'output without a title\r\n', 101)
|
||||
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
|
||||
title: 'Codex working'
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
|
||||
title: 'Codex'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels the stale-title timer when the PTY exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData(ptyId, 'output without a title\r\n', 101)
|
||||
runtime.onPtyExit(ptyId, 0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
|
||||
// The dead session keeps its factual last title — the disposed tracker's
|
||||
// stale-title rewrite must not fire into the retained record.
|
||||
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
|
||||
title: 'Codex working'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps stale-title timers isolated per PTY', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ptyA = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a`
|
||||
const ptyB = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-b`
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: ptyA, cwd: '/tmp/worktree-a', title: 'shell' },
|
||||
{ id: ptyB, cwd: '/tmp/worktree-a', title: 'shell' }
|
||||
]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
runtime.onPtyData(ptyA, '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData(ptyB, '\x1b]0;Aider working\x07', 100)
|
||||
// Only A receives title-less output, so only A's stale timer arms.
|
||||
runtime.onPtyData(ptyA, 'output without a title\r\n', 101)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
const { terminals } = await runtime.listTerminals()
|
||||
expect(terminals.find((t) => t.tabId === `pty:${ptyA}`)).toMatchObject({ title: 'Codex' })
|
||||
expect(terminals.find((t) => t.tabId === `pty:${ptyB}`)).toMatchObject({
|
||||
title: 'Aider working'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// ─── pty:sideEffect channel (terminal-side-effect-authority.md, slice 2) ──
|
||||
describe('terminal side-effect fact channel', () => {
|
||||
function createSideEffectRuntime(): {
|
||||
runtime: OrcaRuntimeService
|
||||
batches: TerminalSideEffectBatch[]
|
||||
} {
|
||||
const batches: TerminalSideEffectBatch[] = []
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
onTerminalSideEffects: (batch) => batches.push(batch)
|
||||
})
|
||||
return { runtime, batches }
|
||||
}
|
||||
|
||||
it('emits one batched event per chunk with facts in byte order and attribution', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const chunk = '\x1b]0;Codex working\x07response\x1b]0;Codex done\x07\x07'
|
||||
runtime.onPtyData('pty-1', chunk, 100)
|
||||
|
||||
expect(batches).toHaveLength(1)
|
||||
expect(batches[0]).toMatchObject({
|
||||
ptyId: 'pty-1',
|
||||
seq: chunk.length,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: 'tab-1',
|
||||
paneKey: 'tab-1:1'
|
||||
})
|
||||
expect(batches[0].replay).toBeUndefined()
|
||||
expect(batches[0].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' },
|
||||
{ kind: 'agent-working' },
|
||||
{ kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' },
|
||||
{ kind: 'agent-idle', title: 'Codex done' },
|
||||
{ kind: 'bell' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps per-PTY ordering across chunks and accumulates seq', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
|
||||
expect(batches.map((batch) => batch.facts[0]?.kind)).toEqual(['title', 'title'])
|
||||
expect(batches[0].seq).toBeLessThan(batches[1].seq)
|
||||
})
|
||||
|
||||
it('emits nothing for chunks without derived facts', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
// Plain output, a BEL-terminated non-title OSC split across chunks, and
|
||||
// an Orca status payload: none of these is a title/bell/agent fact.
|
||||
runtime.onPtyData('pty-1', 'plain output\r\n', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]7;file://host', 101)
|
||||
runtime.onPtyData('pty-1', '/tmp\x07', 102)
|
||||
runtime.onPtyData('pty-1', '\x1b]9999;{"state":"working","agentType":"codex"}\x07', 103)
|
||||
|
||||
expect(batches).toEqual([])
|
||||
})
|
||||
|
||||
it('emits the stale-working-title rewrite as between-chunk fact batches', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', 'output without a title\r\n', 101)
|
||||
batches.length = 0
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
// Timer facts fire outside a chunk, so each emits immediately —
|
||||
// still strictly ordered per PTY. They carry staleWorkingTitleClear:
|
||||
// the renderer must clear state without scheduling a task-complete
|
||||
// notification main's unthrottled timer did not earn.
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Codex',
|
||||
rawTitle: 'Codex',
|
||||
staleWorkingTitleClear: true
|
||||
},
|
||||
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ingests synthetic title frames without touching the byte pipeline', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
|
||||
|
||||
expect(batches).toHaveLength(1)
|
||||
expect(batches[0].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' },
|
||||
// The synthesized spinner classifies as working — agent facts derive
|
||||
// from synthetic frames the same as from real bytes.
|
||||
{ kind: 'agent-working' }
|
||||
])
|
||||
// Synthetic frames are fabricated by main: they must not advance the
|
||||
// metered output sequence the renderer ACK budget is based on.
|
||||
expect(runtime.getPtyOutputSequence('pty-1')).toBe(0)
|
||||
})
|
||||
|
||||
it('carries the synthetic permission BEL as a bell fact', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor needs your input\x07\x07')
|
||||
|
||||
expect(batches[0].facts.at(0)).toMatchObject({ kind: 'title' })
|
||||
expect(batches[0].facts.at(-1)).toEqual({ kind: 'bell' })
|
||||
})
|
||||
|
||||
it('emits command-finished facts with best-effort exit codes across chunk splits', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', 'output\x1b]133;D;13', 100)
|
||||
expect(batches).toEqual([])
|
||||
runtime.onPtyData('pty-1', '0\x07prompt $ ', 101)
|
||||
runtime.onPtyData('pty-1', '\x1b]133;D\x07', 102)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{ kind: 'command-finished', exitCode: 130 },
|
||||
{ kind: 'command-finished', exitCode: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('emits pr-link facts once per URL with batch attribution', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', 'PR https://github.com/acme/orca/pull/4', 100)
|
||||
runtime.onPtyData('pty-1', '2\r\nand https://github.com/acme/orca/pull/43 done\r\n', 101)
|
||||
// Repeated URL: deduped per PTY, like the renderer byte detector.
|
||||
runtime.onPtyData('pty-1', 'again https://github.com/acme/orca/pull/42\r\n', 102)
|
||||
|
||||
expect(batches).toHaveLength(1)
|
||||
expect(batches[0]).toMatchObject({
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: 'tab-1'
|
||||
})
|
||||
expect(batches[0].facts).toEqual([
|
||||
{
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
number: 42
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/43',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
number: 43
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('emits 2031-subscribe facts across chunk splits', () => {
|
||||
// Why: hidden-delivery-gated views never receive the bytes — this fact
|
||||
// is their only signal to send the DECSET 2031 color-scheme reply.
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b[?20', 100)
|
||||
expect(batches).toEqual([])
|
||||
runtime.onPtyData('pty-1', '31h', 101)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }])
|
||||
})
|
||||
|
||||
it('prefers the tracked title over the renderer snapshot lastTitle', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'visible content',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
// The renderer xterm never saw the synthetic frame (it no longer
|
||||
// rides pty:data), so its serializer reports a stale title.
|
||||
lastTitle: 'stale shell title'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
hasRendererSerializer: () => true,
|
||||
getSize: () => ({ cols: 80, rows: 24 })
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
|
||||
|
||||
const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 10 })
|
||||
expect(snapshot?.source).toBe('renderer')
|
||||
expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent')
|
||||
})
|
||||
|
||||
it('prefers the tracked title over the headless emulator lastTitle', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07real output\r\n', 100)
|
||||
// The hook-driven idle frame lands only in main's tracker — the
|
||||
// emulator never sees fabricated bytes (invariant 5).
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
|
||||
|
||||
const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 })
|
||||
expect(snapshot?.source).toBe('headless')
|
||||
expect(snapshot?.lastTitle).toBe('Codex ready')
|
||||
})
|
||||
|
||||
it('returns a title-only replay snapshot and never historical attention', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07\x07', 100)
|
||||
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({
|
||||
ptyId: 'pty-1',
|
||||
replay: true,
|
||||
facts: [{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }]
|
||||
})
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the cursor-agent literal from record-fallback snapshots', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', 'plain output\n', 100)
|
||||
// Simulate a record title restored by a path that bypassed the tracker
|
||||
// (the tracker itself refuses to store the bare native title).
|
||||
const records = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { lastOscTitle: string | null }>
|
||||
}
|
||||
).ptysById
|
||||
records.get('pty-1')!.lastOscTitle = 'Cursor Agent'
|
||||
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('emits the chunk agentStatus events before its side-effect batch', () => {
|
||||
// Cross-channel contract order per chunk: status → titles → bell.
|
||||
const order: string[] = []
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
onTerminalAgentStatus: () => order.push('agentStatus:set'),
|
||||
onTerminalSideEffects: () => order.push('pty:sideEffect')
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
'\x1b]9999;{"state":"working","agentType":"codex"}\x07\x1b]0;Codex working\x07\x07',
|
||||
100
|
||||
)
|
||||
|
||||
expect(order).toEqual(['agentStatus:set', 'pty:sideEffect'])
|
||||
})
|
||||
|
||||
it('still emits a throwing chunk’s facts under its own seq, not the next chunk’s', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
vi.spyOn(
|
||||
runtime as unknown as { applyTrackedPtyTitle: (ptyId: string, title: string) => boolean },
|
||||
'applyTrackedPtyTitle'
|
||||
).mockImplementationOnce(() => {
|
||||
throw new Error('tracker boom')
|
||||
})
|
||||
|
||||
const first = '\x1b]0;Codex working\x07'
|
||||
expect(() => runtime.onPtyData('pty-1', first, 100)).toThrow('tracker boom')
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
|
||||
expect(batches).toHaveLength(2)
|
||||
expect(batches[0].seq).toBe(first.length)
|
||||
expect(batches[0].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }
|
||||
])
|
||||
// The next chunk's batch carries only its own facts (the throw aborted
|
||||
// the first chunk's agent-tracker pass, so no working state was kept).
|
||||
expect(batches[1].seq).toBeGreaterThan(batches[0].seq)
|
||||
expect(batches[1].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' }
|
||||
])
|
||||
})
|
||||
|
||||
it('parses synthetic frames statelessly so ticks cannot corrupt the bell detector', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;split ti', 100)
|
||||
// An 80ms spinner tick lands between the two halves of the real OSC.
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
|
||||
// Continuation: this BEL terminates the real OSC — it is NOT a bell.
|
||||
runtime.onPtyData('pty-1', 'tle\x07', 101)
|
||||
// A later standalone BEL is a real bell and must not be swallowed.
|
||||
runtime.onPtyData('pty-1', 'ready\x07', 102)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' },
|
||||
{ kind: 'agent-working' },
|
||||
{ kind: 'title', normalizedTitle: 'split title', rawTitle: 'split title' },
|
||||
{ kind: 'bell' }
|
||||
])
|
||||
})
|
||||
|
||||
it('touches mobile snapshots once for decorative spinner ticks, again on idle', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
const touchSpy = vi.spyOn(
|
||||
runtime as unknown as { touchMobileSessionSnapshotsForPty: (ptyId: string) => void },
|
||||
'touchMobileSessionSnapshotsForPty'
|
||||
)
|
||||
|
||||
for (const frame of ['⠋', '⠙', '⠹', '⠸', '⠼']) {
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', `\x1b]0;${frame} Cursor Agent\x07`)
|
||||
}
|
||||
// Five ticks with the same de-spinnered title: one snapshot fan-out.
|
||||
expect(touchSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor ready\x07')
|
||||
expect(touchSpy).toHaveBeenCalledTimes(2)
|
||||
// Raw record titles still track every frame for worktree ps/mobile tabs.
|
||||
expect(
|
||||
(
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { lastOscTitle: string | null }>
|
||||
}
|
||||
).ptysById.get('pty-1')?.lastOscTitle
|
||||
).toBe('Cursor ready')
|
||||
})
|
||||
|
||||
it('seeds the lazily created tracker from the daemon-snapshot title', async () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'restored scrollback\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
lastTitle: 'Codex working'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
hasRendererSerializer: () => true,
|
||||
getSize: () => ({ cols: 80, rows: 24 })
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
// First live chunk creates the tracker cold and kicks off hydration;
|
||||
// the snapshot seed must land in the already-created tracker.
|
||||
runtime.onPtyData('pty-1', 'plain output without a title\n', 100)
|
||||
await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 })
|
||||
batches.length = 0
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
|
||||
// Without the seed the tracker never saw 'working', so this idle title
|
||||
// could not produce a completion fact.
|
||||
expect(batches.flatMap((batch) => batch.facts)).toContainEqual({
|
||||
kind: 'agent-idle',
|
||||
title: 'Codex done'
|
||||
})
|
||||
})
|
||||
|
||||
it('arms the stale-title timer for a seeded working title', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'restored scrollback\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
lastTitle: 'Codex working'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
hasRendererSerializer: () => true,
|
||||
getSize: () => ({ cols: 80, rows: 24 })
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', 'plain output\n', 100)
|
||||
// Settle the async daemon-snapshot hydration that seeds the tracker.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
runtime.onPtyData('pty-1', 'still no title\n', 101)
|
||||
batches.length = 0
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Codex',
|
||||
rawTitle: 'Codex',
|
||||
staleWorkingTitleClear: true
|
||||
},
|
||||
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits command-code-working facts only after the banner arms the scrape', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
// Generic status words without the Command Code banner must not arm.
|
||||
runtime.onPtyData('pty-1', '❯ Fix the spinner\r\nThinking...', 100)
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 101)
|
||||
runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m', 102)
|
||||
|
||||
expect(batches.at(-1)).toMatchObject({
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: 'tab-1'
|
||||
})
|
||||
expect(batches.at(-1)?.facts).toEqual([
|
||||
{ kind: 'command-code-working', prompt: 'Fix the spinner' }
|
||||
])
|
||||
})
|
||||
|
||||
it('emits a command-code-done fact when the idle composer returns', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 100)
|
||||
runtime.onPtyData('pty-1', '❯ say hi\r\n✻ Thinking...', 101)
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
'\r\n✻ Thought for 1 second\r\n:: Hi!\r\n❯ Ask your question...',
|
||||
102
|
||||
)
|
||||
|
||||
expect(batches.at(-1)?.facts).toEqual([{ kind: 'command-code-done', prompt: 'say hi' }])
|
||||
})
|
||||
|
||||
it('arms the Command Code scrape from the noted spawn command', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
// Mirrors the renderer detector's startupCommand fast-arm: no banner
|
||||
// needed when main saw the launch command at spawn time.
|
||||
runtime.noteTerminalSpawnCommand('pty-1', 'command-code --trust')
|
||||
runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n✻ Thinking...', 100)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toContainEqual({
|
||||
kind: 'command-code-working',
|
||||
prompt: 'Fix the spinner'
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the tracked title over a stale renderer lastTitle in the hydration seed', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'renderer scrollback\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
// The renderer xterm never saw the synthetic hook frame (it no longer
|
||||
// rides pty:data), so its serializer reports the pre-agent title.
|
||||
lastTitle: 'stale shell title'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
hasRendererSerializer: () => true,
|
||||
getSize: () => ({ cols: 80, rows: 24 })
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude working\x07')
|
||||
// First live chunk kicks off renderer hydration; awaiting the snapshot
|
||||
// below settles the seed write chain.
|
||||
runtime.onPtyData('pty-1', 'plain output\n', 100)
|
||||
await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 })
|
||||
|
||||
const leaves = (
|
||||
runtime as unknown as { leaves: Map<string, { lastOscTitle: string | null }> }
|
||||
).leaves
|
||||
// The seed must not stomp the leaf record (worktree ps status source)
|
||||
// back to the renderer's stale title.
|
||||
expect([...leaves.values()][0]?.lastOscTitle).toBe('⠋ Claude working')
|
||||
})
|
||||
})
|
||||
|
||||
it('returns OSC titles from headless main terminal snapshots', async () => {
|
||||
const runtime = createRuntime()
|
||||
syncSinglePty(runtime, 'pty-1')
|
||||
|
||||
@@ -2,14 +2,26 @@
|
||||
/* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */
|
||||
/* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */
|
||||
import {
|
||||
extractLastOscTitle,
|
||||
detectAgentStatusFromTitle,
|
||||
isClaudeManagementTitle,
|
||||
isShellProcess
|
||||
isCursorNativeAgentTitle,
|
||||
isShellProcess,
|
||||
normalizeTerminalTitle
|
||||
} from '../../shared/agent-detection'
|
||||
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
import {
|
||||
createTerminalTitleTracker,
|
||||
stripBrailleSpinnerGlyphs,
|
||||
type TerminalTitleTracker
|
||||
} from '../../shared/terminal-output-side-effects'
|
||||
import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status'
|
||||
import type {
|
||||
TerminalSideEffectBatch,
|
||||
TerminalSideEffectFact
|
||||
} from '../../shared/terminal-side-effect-facts'
|
||||
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusIpcPayload,
|
||||
@@ -657,6 +669,15 @@ import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
|
||||
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
|
||||
import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import {
|
||||
isNativeWindowsConptyPty,
|
||||
registerConptyDa1OverrideInstaller,
|
||||
shouldModelAnswerHiddenPtyQueries
|
||||
} from './terminal-model-query-authority'
|
||||
import {
|
||||
getTerminalViewAttributes,
|
||||
registerTerminalViewAttributesApplier
|
||||
} from './terminal-view-attribute-store'
|
||||
import { killAllProcessesForWorktree } from './worktree-teardown'
|
||||
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
|
||||
import type { IFilesystemProvider, IPtyProvider } from '../providers/types'
|
||||
@@ -792,6 +813,11 @@ type RuntimeStore = {
|
||||
mobileEmulatorDefaultDeviceUdid?: string | null
|
||||
voice?: VoiceSettings
|
||||
claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode']
|
||||
// Why: Phase-5 query responder kill switches — read per chunk in
|
||||
// onPtyData to capture reply ownership at ingestion.
|
||||
terminalMainSideEffectAuthority?: GlobalSettings['terminalMainSideEffectAuthority']
|
||||
terminalHiddenDeliveryGate?: GlobalSettings['terminalHiddenDeliveryGate']
|
||||
terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority']
|
||||
}
|
||||
// Why: narrow to `unknown` return so test mocks can return void without
|
||||
// a cast. The runtime never reads the return value — the persisted value
|
||||
@@ -980,6 +1006,28 @@ export type RuntimeTerminalAgentStatusEvent = {
|
||||
payload: ParsedAgentStatusPayload
|
||||
}
|
||||
|
||||
type RuntimePtyTitleTrackerEntry = {
|
||||
tracker: TerminalTitleTracker
|
||||
// Why: onPtyData batches the mobile session-tab touch to once per chunk;
|
||||
// the stale-working-title timer fires between chunks and must touch
|
||||
// immediately. These flags route the tracker callback to the right mode.
|
||||
applyingChunk: boolean
|
||||
// Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the
|
||||
// synthetic path gates mobile snapshot fan-out on a non-decorative title
|
||||
// change (spinner glyph + status comparison key kept below).
|
||||
applyingSyntheticFrame: boolean
|
||||
lastMobileTitleGateKey: string | null
|
||||
chunkTouchedSessionTabs: boolean
|
||||
// Why: facts observed while applying a chunk are batched into one
|
||||
// pty:sideEffect emission per chunk, preserving byte order (titles in
|
||||
// sequence, then bell). Timer-fired facts emit immediately between chunks.
|
||||
pendingFacts: TerminalSideEffectFact[]
|
||||
// Why: Command Code lacks hooks, so its working/done state is scraped from
|
||||
// TUI output. Null when no side-effect consumer exists (headless serve) —
|
||||
// the scrape produces facts only.
|
||||
commandCodeDetector: { observe: (data: string) => boolean } | null
|
||||
}
|
||||
|
||||
// Why: the full OSC 9999 payload flows through emitTerminalAgentStatusEvents and
|
||||
// is then forwarded to the renderer and dropped. Mobile is served by the main
|
||||
// process and has no renderer store, so we retain the latest payload per pane
|
||||
@@ -1006,6 +1054,10 @@ type RuntimeHeadlessTerminal = {
|
||||
type HeadlessSeedMetadata = {
|
||||
cwd?: string | null
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
/** 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 = {
|
||||
@@ -1988,8 +2040,18 @@ export class OrcaRuntimeService {
|
||||
string,
|
||||
ReturnType<typeof createAgentStatusOscProcessor>
|
||||
>()
|
||||
// Why: per-PTY shared title trackers (all-titles ordering + stale-working
|
||||
// timer) replace last-title-per-chunk scanning so main observes the same
|
||||
// intra-chunk working→idle transitions the renderer does (issue #1083).
|
||||
// Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit.
|
||||
private ptyTitleTrackersByPtyId = new Map<string, RuntimePtyTitleTrackerEntry>()
|
||||
// Why: the Command Code output detector arms early from the launch command
|
||||
// when known (banner detection covers user-typed launches), mirroring the
|
||||
// renderer detector's startupCommand seed.
|
||||
private terminalSpawnCommandsByPtyId = new Map<string, string>()
|
||||
// Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over
|
||||
// SSH/relay buffering. Keep a small raw scan tail so status titles are not lost.
|
||||
// SSH/relay buffering. Keep a small raw scan tail and feed reconstructed
|
||||
// chunks into the title tracker instead of falling back to last-title scans.
|
||||
private oscTitleScanTailByPtyId = new Map<string, string>()
|
||||
// Why: latest agent-status payload per pane, retained so worktree.ps can serve
|
||||
// mobile the same inline agent rows the desktop sidebar renders. Cleared on pty
|
||||
@@ -2051,6 +2113,14 @@ export class OrcaRuntimeService {
|
||||
>
|
||||
>()
|
||||
|
||||
// Why: Phase-5 query-responder suppression — a terminal-RPC subscribe
|
||||
// stream feeds a remote xterm view (mobile/web/remote desktop) that answers
|
||||
// queries with view authority, so main must yield while one is attached
|
||||
// (terminal-query-authority.md). Ref-counted per PTY because multiple
|
||||
// streams can attach concurrently; mobileSubscribers is consulted too so
|
||||
// grace-window mobile records keep suppressing.
|
||||
private remoteTerminalViewSubscriberCounts = new Map<string, number>()
|
||||
|
||||
// Why: per-PTY driver state. The "driver" is whoever currently owns the
|
||||
// input/resize floor. While `kind === 'mobile'` the desktop renderer drops
|
||||
// xterm.onData/onResize and shows the lock banner; `terminal.send` /
|
||||
@@ -2198,6 +2268,7 @@ export class OrcaRuntimeService {
|
||||
private readonly getLocalProviderFn: (() => IPtyProvider) | null
|
||||
private readonly onPtyStopped: ((ptyId: string) => void) | null
|
||||
private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null
|
||||
private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null
|
||||
private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null
|
||||
private readonly buildAgentHookPtyEnv: (() => Record<string, string>) | null
|
||||
private accountServices: RuntimeAccountServices | null = null
|
||||
@@ -2222,6 +2293,7 @@ export class OrcaRuntimeService {
|
||||
getLocalProvider?: () => IPtyProvider
|
||||
onPtyStopped?: (ptyId: string) => void
|
||||
onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void
|
||||
onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void
|
||||
// Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC
|
||||
// terminal output. worktree.ps reads this at query time so mobile shows the
|
||||
// same inline agent rows the desktop sidebar does — same source, 1:1.
|
||||
@@ -2245,6 +2317,20 @@ export class OrcaRuntimeService {
|
||||
this.onPtyStopped = deps?.onPtyStopped ?? null
|
||||
this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null
|
||||
this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null
|
||||
this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null
|
||||
// Why: the ConPTY spawn mark can land after daemon stream data already
|
||||
// created this PTY's emulator; the mark retrofits the DA1 override here
|
||||
// (terminal-query-authority.md §ConPTY DA1).
|
||||
registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId))
|
||||
// Why: a renderer attribute push must reach already-live emulators too —
|
||||
// cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color
|
||||
// override reset a theme apply implies (terminal-query-authority.md
|
||||
// §View-attribute bridge).
|
||||
registerTerminalViewAttributesApplier((attributes) => {
|
||||
for (const state of this.headlessTerminals.values()) {
|
||||
state.emulator.applyPushedViewAttributes(attributes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getLocalProvider(): IPtyProvider | null {
|
||||
@@ -5049,6 +5135,16 @@ export class OrcaRuntimeService {
|
||||
this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId })
|
||||
}
|
||||
|
||||
/** Record the spawn launch command so the per-PTY Command Code detector can
|
||||
* arm from it (renderer startupCommand parity). Best-effort: a chunk that
|
||||
* beats this call falls back to the detector's banner arming. */
|
||||
noteTerminalSpawnCommand(ptyId: string, command: string | null | undefined): void {
|
||||
const trimmed = typeof command === 'string' ? command.trim() : ''
|
||||
if (trimmed.length > 0) {
|
||||
this.terminalSpawnCommandsByPtyId.set(ptyId, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming data from a PTY process, running agent detection,
|
||||
* updating terminal tail buffers, and triggering foreground agent refreshes.
|
||||
@@ -5069,6 +5165,12 @@ export class OrcaRuntimeService {
|
||||
// panel can surface them in place of the kernel bind address.
|
||||
advertisedUrlWatcher.ingest(ptyId, data, at)
|
||||
serveSimStateWatcher.ingestPtyOutput(ptyId, data)
|
||||
// Why: reply ownership is captured per chunk, here at ingestion — the
|
||||
// same module state and tick as the hidden-gate drop sites — and rides
|
||||
// the writeChain link. A mark/setting/subscriber flip before the queued
|
||||
// emulator write runs must not change who answers (terminal-query-
|
||||
// authority.md invariant 1).
|
||||
const forwardQueryReplies = this.shouldAnswerQueriesForLiveChunk(ptyId)
|
||||
// Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer
|
||||
// MUST run before trackHeadlessTerminalData so the eager-state pattern
|
||||
// (set headlessTerminals + writeChain head = seedPromise) is in place
|
||||
@@ -5077,17 +5179,9 @@ export class OrcaRuntimeService {
|
||||
// that the later seed-resolve would overwrite, dropping the live byte.
|
||||
// See docs/mobile-prefer-renderer-scrollback.md.
|
||||
this.maybeHydrateHeadlessFromRenderer(ptyId)
|
||||
this.trackHeadlessTerminalData(ptyId, data, outputSequence)
|
||||
|
||||
// Why: extract OSC title from raw PTY data before tail-buffer processing
|
||||
// strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.)
|
||||
// announce status via OSC 0/1/2 title sequences — this is the same
|
||||
// detection path the renderer uses for notifications and sidebar badges.
|
||||
const oscTitle = this.extractLastOscTitleForPty(ptyId, data)
|
||||
const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null
|
||||
this.trackHeadlessTerminalData(ptyId, data, outputSequence, forwardQueryReplies)
|
||||
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
|
||||
let shouldTouchPtyBackedSessionTabs = false
|
||||
const ptyTailBefore = pty
|
||||
? {
|
||||
lines: pty.tailBuffer,
|
||||
@@ -5119,26 +5213,6 @@ export class OrcaRuntimeService {
|
||||
pty.tailLinesTotal += nextTail.newCompleteLines
|
||||
pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine)
|
||||
this.scheduleWaitBlockedCheck(ptyId, normalized.text, at)
|
||||
if (oscTitle !== null) {
|
||||
const prevStatus = pty.lastAgentStatus
|
||||
const prevTitle = pty.lastOscTitle
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
pty.lastOscTitle = oscTitle
|
||||
pty.lastOscTitleAt = observedAt
|
||||
pty.lastAgentStatus = agentStatus
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, oscTitle, observedAt)
|
||||
shouldTouchPtyBackedSessionTabs =
|
||||
prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus
|
||||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
this.resolvePtyTuiIdleWaiters(pty, ptyId)
|
||||
}
|
||||
// Why: gate on an actual status transition — braille spinner frames
|
||||
// mutate the title every tick, so probing per-title-change would stream
|
||||
// a foreground query per frame during active work.
|
||||
if (prevStatus !== pty.lastAgentStatus) {
|
||||
this.refreshPtyForegroundAgent(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
@@ -5206,39 +5280,50 @@ export class OrcaRuntimeService {
|
||||
leaf.tailLinesTotal += nextTail.newCompleteLines
|
||||
leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine)
|
||||
}
|
||||
|
||||
if (oscTitle !== null) {
|
||||
// Why: keep the latest OSC title on the leaf so worktree.ps can
|
||||
// recompute status from the live title each call. Without this,
|
||||
// daemon-hosted terminals (no renderer pushing pane titles) had no
|
||||
// way to clear a stale 'working' status after the agent exited and
|
||||
// the shell took over the title — the stuck-spinner bug in #1437.
|
||||
leaf.lastOscTitle = oscTitle
|
||||
leaf.lastOscTitleAt = this.nextTitleObservationSequence()
|
||||
const prevStatus = leaf.lastAgentStatus
|
||||
// Why: when a new OSC title doesn't classify as an agent state (e.g.
|
||||
// bare shell title after the agent exits), clear lastAgentStatus so
|
||||
// it is no longer sticky. Tui-idle waiters that needed the previous
|
||||
// 'idle' transition were already resolved at the moment of the
|
||||
// transition below; only fresh waiters registered after the agent
|
||||
// exits would observe the cleared value, and they correctly fall
|
||||
// back to title-based detection / polling.
|
||||
leaf.lastAgentStatus = agentStatus
|
||||
// Why: resolve tui-idle on any transition TO idle (not just working→idle).
|
||||
// Claude Code may skip "working" entirely on fast tasks, going null→idle,
|
||||
// and the coordinator's tui-idle waiter would hang forever waiting for a
|
||||
// working→idle transition that never comes. Permission→idle is excluded:
|
||||
// it means the agent was blocked on user approval and the user said no,
|
||||
// which isn't a task-completion signal.
|
||||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
this.resolveTuiIdleWaiters(leaf)
|
||||
this.deliverPendingMessages(leaf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
|
||||
if (shouldTouchPtyBackedSessionTabs) {
|
||||
// Why: feed the chunk's OSC titles through the shared per-PTY tracker in
|
||||
// byte order — the same ordering the renderer transport uses — so
|
||||
// coalesced working→idle transitions reach tui-idle waiters and
|
||||
// pending-message delivery instead of being masked by the chunk's last
|
||||
// title (issue #1083). Uses the OSC 9999-stripped cleanData like the
|
||||
// renderer, so pure status chunks don't perturb the stale-title probe.
|
||||
const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId)
|
||||
const previousTitleScanTail = this.oscTitleScanTailByPtyId.get(ptyId)
|
||||
const titleInput = previousTitleScanTail
|
||||
? `${previousTitleScanTail}${agentStatusChunk.cleanData}`
|
||||
: agentStatusChunk.cleanData
|
||||
const nextTitleScanTail = extractOscTitleScanTail(titleInput)
|
||||
if (nextTitleScanTail.length > 0) {
|
||||
this.oscTitleScanTailByPtyId.set(ptyId, nextTitleScanTail)
|
||||
} else {
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
}
|
||||
titleTrackerEntry.applyingChunk = true
|
||||
titleTrackerEntry.chunkTouchedSessionTabs = false
|
||||
try {
|
||||
titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, {
|
||||
titleScanData: titleInput
|
||||
})
|
||||
// Why: the Command Code scrape rides the same per-chunk batch (its facts
|
||||
// trail the tracker's). cleanData keeps OSC 9999 payloads out of the
|
||||
// detector's bounded recent-text window; the detector strips remaining
|
||||
// control sequences itself, exactly like the renderer byte path.
|
||||
titleTrackerEntry.commandCodeDetector?.observe(agentStatusChunk.cleanData)
|
||||
} finally {
|
||||
titleTrackerEntry.applyingChunk = false
|
||||
try {
|
||||
// Why: per-chunk cross-channel contract order is status → titles →
|
||||
// bell — the chunk's agentStatus:set events must reach the renderer
|
||||
// before its pty:sideEffect batch.
|
||||
this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
|
||||
} finally {
|
||||
// Why: flushed in the finally so a throwing tracker callback cannot
|
||||
// strand this chunk's facts to be emitted under the next chunk's seq.
|
||||
this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry)
|
||||
}
|
||||
}
|
||||
if (titleTrackerEntry.chunkTouchedSessionTabs) {
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
}
|
||||
|
||||
@@ -5326,19 +5411,357 @@ export class OrcaRuntimeService {
|
||||
return processor(data)
|
||||
}
|
||||
|
||||
private extractLastOscTitleForPty(ptyId: string, data: string): string | null {
|
||||
const previousTail = this.oscTitleScanTailByPtyId.get(ptyId)
|
||||
if (!previousTail && !data.includes('\x1b')) {
|
||||
/** Emit the facts batched while applying one chunk/frame as a single
|
||||
* pty:sideEffect batch, preserving byte order. */
|
||||
private flushPendingTerminalSideEffectFacts(
|
||||
ptyId: string,
|
||||
entry: RuntimePtyTitleTrackerEntry
|
||||
): void {
|
||||
if (entry.pendingFacts.length === 0) {
|
||||
return
|
||||
}
|
||||
const facts = entry.pendingFacts
|
||||
entry.pendingFacts = []
|
||||
this.emitTerminalSideEffectBatch(ptyId, facts)
|
||||
}
|
||||
|
||||
/** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through
|
||||
* the per-PTY tracker — NOT onPtyData, so emulator state, tails,
|
||||
* transcripts, and stats never see synthetic bytes. Parsed via the
|
||||
* tracker's stateless synthetic path: the shared chunk bell detector must
|
||||
* never observe fabricated bytes, or a tick interleaved with a split real
|
||||
* OSC corrupts its escape state (phantom/swallowed bells). While the
|
||||
* side-effect kill switch is off the legacy pty:data copy still drives
|
||||
* renderer parsers; this ingest keeps main's facts and records
|
||||
* authoritative. */
|
||||
ingestSyntheticTitleFrame(ptyId: string, data: string): void {
|
||||
const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId)
|
||||
entry.applyingChunk = true
|
||||
entry.applyingSyntheticFrame = true
|
||||
entry.chunkTouchedSessionTabs = false
|
||||
try {
|
||||
entry.tracker.applySyntheticTitleFrame(data)
|
||||
} finally {
|
||||
entry.applyingChunk = false
|
||||
entry.applyingSyntheticFrame = false
|
||||
this.flushPendingTerminalSideEffectFacts(ptyId, entry)
|
||||
}
|
||||
if (entry.chunkTouchedSessionTabs) {
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Record one derived side-effect fact: batched per chunk while applying
|
||||
* bytes, emitted immediately for between-chunk facts (stale-title timer). */
|
||||
private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void {
|
||||
if (!this.onTerminalSideEffects) {
|
||||
return
|
||||
}
|
||||
const entry = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
if (entry?.applyingChunk) {
|
||||
entry.pendingFacts.push(fact)
|
||||
return
|
||||
}
|
||||
this.emitTerminalSideEffectBatch(ptyId, [fact])
|
||||
}
|
||||
|
||||
private emitTerminalSideEffectBatch(
|
||||
ptyId: string,
|
||||
facts: TerminalSideEffectFact[],
|
||||
options: { replay?: boolean } = {}
|
||||
): void {
|
||||
if (!this.onTerminalSideEffects || facts.length === 0) {
|
||||
return
|
||||
}
|
||||
const batch: TerminalSideEffectBatch = {
|
||||
ptyId,
|
||||
seq: this.ptyOutputSequenceById.get(ptyId) ?? 0,
|
||||
facts,
|
||||
...(options.replay ? { replay: true } : {}),
|
||||
...this.resolveTerminalSideEffectAttribution(ptyId)
|
||||
}
|
||||
try {
|
||||
this.onTerminalSideEffects(batch)
|
||||
} catch (err) {
|
||||
console.error('[runtime] terminal side-effect listener threw', { ptyId, err })
|
||||
}
|
||||
}
|
||||
|
||||
/** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the
|
||||
* first mounted leaf, fall back to the spawn-time PTY record binding. */
|
||||
private resolveTerminalSideEffectAttribution(ptyId: string): {
|
||||
worktreeId?: string
|
||||
tabId?: string
|
||||
paneKey?: string
|
||||
connectionId?: string | null
|
||||
} {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const connectionId = pty?.connectionId ?? null
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
return {
|
||||
worktreeId: leaf.worktreeId,
|
||||
tabId: leaf.tabId,
|
||||
paneKey: this.makeRuntimePaneKey(leaf),
|
||||
connectionId
|
||||
}
|
||||
}
|
||||
if (pty?.paneKey) {
|
||||
return {
|
||||
worktreeId: pty.worktreeId,
|
||||
...(pty.tabId ? { tabId: pty.tabId } : {}),
|
||||
paneKey: pty.paneKey,
|
||||
connectionId
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/** Title-only replay batch for renderer (re)attach — the no-attention-replay
|
||||
* rule: snapshots restore title state, never historical bells/completions. */
|
||||
getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null {
|
||||
const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker
|
||||
const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle
|
||||
// Why: the cursor-agent literal drop applies to every title surface; a
|
||||
// record-fallback snapshot must not replay the bare native title the
|
||||
// tracker would have refused to emit live.
|
||||
const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null
|
||||
const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null
|
||||
if (normalizedTitle === null && !rawTitle) {
|
||||
return null
|
||||
}
|
||||
const input = `${previousTail ?? ''}${data}`
|
||||
const scanTail = extractOscTitleScanTail(input)
|
||||
if (scanTail.length > 0) {
|
||||
this.oscTitleScanTailByPtyId.set(ptyId, scanTail)
|
||||
} else {
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
return {
|
||||
ptyId,
|
||||
seq: this.ptyOutputSequenceById.get(ptyId) ?? 0,
|
||||
replay: true,
|
||||
facts: [
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!),
|
||||
rawTitle: rawTitle ?? normalizedTitle!
|
||||
}
|
||||
],
|
||||
...this.resolveTerminalSideEffectAttribution(ptyId)
|
||||
}
|
||||
return extractLastOscTitle(input)
|
||||
}
|
||||
|
||||
/** Raw last title from main's tracked PTY/leaf records — the title surface
|
||||
* the tracker (live bytes + synthetic frames) keeps current. */
|
||||
private getTrackedRawTitleForPty(ptyId: string): string | null {
|
||||
const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle
|
||||
if (recordTitle) {
|
||||
return recordTitle
|
||||
}
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
if (leaf.lastOscTitle) {
|
||||
return leaf.lastOscTitle
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Why: synthetic agent title frames no longer ride pty:data, so neither
|
||||
* renderer xterm nor the headless emulator observes them. Mobile-parity
|
||||
* snapshot titles must prefer main's tracker over snapshot lastTitle, or
|
||||
* hook-driven spinner/idle titles vanish from mobile tabs. */
|
||||
private preferTrackedLastTitle<T extends { lastTitle?: string }>(ptyId: string, snapshot: T): T {
|
||||
const tracked = this.getTrackedRawTitleForPty(ptyId)
|
||||
if (!tracked) {
|
||||
return snapshot
|
||||
}
|
||||
return { ...snapshot, lastTitle: tracked }
|
||||
}
|
||||
|
||||
/** Decorative comparison key: spinner frame glyphs stripped, derived agent
|
||||
* status kept so a working→idle flip with an otherwise-equal label still
|
||||
* counts as a change. */
|
||||
private makeMobileTitleGateKey(rawTitle: string, normalizedTitle: string): string {
|
||||
return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs(
|
||||
normalizedTitle
|
||||
)}`
|
||||
}
|
||||
|
||||
private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry {
|
||||
const existing = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
// Why: trackers are created lazily on the first observed chunk. After an
|
||||
// app relaunch the PTY/leaf records can already hold a persisted title; a
|
||||
// cold tracker would miss the parked working→idle completion and never
|
||||
// arm the stale-title timer for a persisted 'working' title.
|
||||
let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null
|
||||
if (initialTitle === null) {
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
if (leaf.lastOscTitle) {
|
||||
initialTitle = leaf.lastOscTitle
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const tracker = createTerminalTitleTracker(
|
||||
{
|
||||
onTitle: (normalizedTitle, rawTitle, meta) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, {
|
||||
kind: 'title',
|
||||
normalizedTitle,
|
||||
rawTitle,
|
||||
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
|
||||
})
|
||||
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle)
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
const live = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
const gateKey = this.makeMobileTitleGateKey(rawTitle, normalizedTitle)
|
||||
const decorativeOnly = live?.lastMobileTitleGateKey === gateKey
|
||||
if (live) {
|
||||
live.lastMobileTitleGateKey = gateKey
|
||||
}
|
||||
if (live?.applyingChunk) {
|
||||
// Why: synthetic spinner ticks change only the braille glyph
|
||||
// ~12.5x/sec; fanning out full mobile session snapshots per frame
|
||||
// is pure churn. Raw lastOscTitle updates above stay cheap.
|
||||
if (!(live.applyingSyntheticFrame && decorativeOnly)) {
|
||||
live.chunkTouchedSessionTabs = true
|
||||
}
|
||||
} else {
|
||||
// Stale-working-title timer path — fires between chunks, so the
|
||||
// per-chunk batching in onPtyData cannot pick it up.
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
}
|
||||
},
|
||||
// Why: agent transitions and bells become pty:sideEffect facts —
|
||||
// main is the single byte parser for local/SSH PTYs; the renderer
|
||||
// store handler decides what the facts mean (notification policy).
|
||||
onAgentBecameWorking: () => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-working' })
|
||||
},
|
||||
onAgentBecameIdle: (title, meta) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, {
|
||||
kind: 'agent-idle',
|
||||
title,
|
||||
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
|
||||
})
|
||||
},
|
||||
onAgentExited: () => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' })
|
||||
},
|
||||
// Why: bell/command-finished/pr-link/2031 facts exist only for the
|
||||
// pty:sideEffect channel. Headless serve has no consumer, so skip the
|
||||
// per-chunk bell walk and 133/URL/2031 scans entirely.
|
||||
...(this.onTerminalSideEffects
|
||||
? {
|
||||
onBell: () => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
|
||||
},
|
||||
onCommandFinished: (exitCode: number | null) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode })
|
||||
},
|
||||
onPrLink: (link: TerminalGitHubPRLink) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link })
|
||||
},
|
||||
// Why: hidden-delivery-gated views never see the bytes, so main
|
||||
// surfaces DECSET 2031 subscribes as facts; the theme reply is
|
||||
// still sent by the renderer (query authority stays with the view).
|
||||
onMode2031Subscribe: () => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
initialTitle !== null ? { initialTitle } : {}
|
||||
)
|
||||
const entry: RuntimePtyTitleTrackerEntry = {
|
||||
tracker,
|
||||
applyingChunk: false,
|
||||
applyingSyntheticFrame: false,
|
||||
lastMobileTitleGateKey: null,
|
||||
chunkTouchedSessionTabs: false,
|
||||
pendingFacts: [],
|
||||
// Why: command-code facts exist only for the pty:sideEffect channel —
|
||||
// headless serve skips the per-chunk scrape entirely. The detector
|
||||
// self-arms on the Command Code banner; the spawn command (when main
|
||||
// saw one) mirrors the renderer detector's startupCommand fast-arm.
|
||||
commandCodeDetector: this.onTerminalSideEffects
|
||||
? createCommandCodeOutputStatusDetector({
|
||||
startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null,
|
||||
onWorking: (prompt) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt })
|
||||
},
|
||||
onDone: (prompt) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt })
|
||||
}
|
||||
})
|
||||
: null
|
||||
}
|
||||
this.ptyTitleTrackersByPtyId.set(ptyId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** Apply one observed OSC title (raw form) to the PTY and leaf records.
|
||||
* Returns true when the PTY record's title or status changed. */
|
||||
private applyTrackedPtyTitle(ptyId: string, rawTitle: string): boolean {
|
||||
const agentStatus = detectAgentStatusFromTitle(rawTitle)
|
||||
let ptyRecordChanged = false
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty) {
|
||||
const prevStatus = pty.lastAgentStatus
|
||||
const prevTitle = pty.lastOscTitle
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
// Why: records keep the RAW title — worktree `ps` and mobile tab titles
|
||||
// expect it; normalized titles ride along on the tracker for later
|
||||
// emitted facts (terminal-side-effect-authority.md).
|
||||
pty.lastOscTitle = rawTitle
|
||||
pty.lastOscTitleAt = observedAt
|
||||
pty.lastAgentStatus = agentStatus
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, rawTitle, observedAt)
|
||||
ptyRecordChanged = prevTitle !== rawTitle || prevStatus !== agentStatus
|
||||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
this.resolvePtyTuiIdleWaiters(pty, ptyId)
|
||||
}
|
||||
// Why: gate on an actual status transition — braille spinner frames
|
||||
// mutate the title every tick, so probing per-title-change would stream
|
||||
// a foreground query per frame during active work.
|
||||
if (prevStatus !== agentStatus) {
|
||||
this.refreshPtyForegroundAgent(ptyId)
|
||||
}
|
||||
}
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
// Why: keep the latest OSC title on the leaf so worktree.ps can
|
||||
// recompute status from the live title each call. Without this,
|
||||
// daemon-hosted terminals (no renderer pushing pane titles) had no
|
||||
// way to clear a stale 'working' status after the agent exited and
|
||||
// the shell took over the title — the stuck-spinner bug in #1437.
|
||||
leaf.lastOscTitle = rawTitle
|
||||
leaf.lastOscTitleAt = this.nextTitleObservationSequence()
|
||||
const prevStatus = leaf.lastAgentStatus
|
||||
// Why: when a new OSC title doesn't classify as an agent state (e.g.
|
||||
// bare shell title after the agent exits), clear lastAgentStatus so
|
||||
// it is no longer sticky. Tui-idle waiters that needed the previous
|
||||
// 'idle' transition were already resolved at the moment of the
|
||||
// transition below; only fresh waiters registered after the agent
|
||||
// exits would observe the cleared value, and they correctly fall
|
||||
// back to title-based detection / polling.
|
||||
leaf.lastAgentStatus = agentStatus
|
||||
// Why: resolve tui-idle on any transition TO idle (not just working→idle).
|
||||
// Claude Code may skip "working" entirely on fast tasks, going null→idle,
|
||||
// and the coordinator's tui-idle waiter would hang forever waiting for a
|
||||
// working→idle transition that never comes. Permission→idle is excluded:
|
||||
// it means the agent was blocked on user approval and the user said no,
|
||||
// which isn't a task-completion signal.
|
||||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
this.resolveTuiIdleWaiters(leaf)
|
||||
this.deliverPendingMessages(leaf)
|
||||
}
|
||||
}
|
||||
return ptyRecordChanged
|
||||
}
|
||||
|
||||
/** Cancel the per-PTY title tracker (stale-title timer included) on PTY
|
||||
* teardown so it cannot fire into pruned records. */
|
||||
private disposePtyTitleTracker(ptyId: string): void {
|
||||
this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose()
|
||||
this.ptyTitleTrackersByPtyId.delete(ptyId)
|
||||
}
|
||||
|
||||
private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void {
|
||||
@@ -5448,6 +5871,37 @@ export class OrcaRuntimeService {
|
||||
return addListenerToMap(this.dataListeners, ptyId, listener)
|
||||
}
|
||||
|
||||
/** Registered by terminal-RPC subscribe/multiplex streams: while a remote
|
||||
* view subscriber is attached its xterm answers queries with view
|
||||
* authority and the model responder must stay silent. Returns an
|
||||
* idempotent release. */
|
||||
registerRemoteTerminalViewSubscriber(ptyId: string): () => void {
|
||||
this.remoteTerminalViewSubscriberCounts.set(
|
||||
ptyId,
|
||||
(this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1
|
||||
)
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
const next = (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1
|
||||
if (next <= 0) {
|
||||
this.remoteTerminalViewSubscriberCounts.delete(ptyId)
|
||||
} else {
|
||||
this.remoteTerminalViewSubscriberCounts.set(ptyId, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasRemoteTerminalViewSubscriber(ptyId: string): boolean {
|
||||
if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) {
|
||||
return true
|
||||
}
|
||||
return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0
|
||||
}
|
||||
|
||||
subscribeToFitOverrideChanges(
|
||||
ptyId: string,
|
||||
listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void
|
||||
@@ -5587,15 +6041,20 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||||
const state: RuntimeHeadlessTerminal = {
|
||||
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
|
||||
outputSequence: 0,
|
||||
writeChain: Promise.resolve()
|
||||
}
|
||||
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
|
||||
this.headlessTerminals.set(ptyId, state)
|
||||
state.writeChain = state.writeChain
|
||||
.then(async () => {
|
||||
// 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)
|
||||
}
|
||||
@@ -5636,11 +6095,9 @@ export class OrcaRuntimeService {
|
||||
|
||||
this.headlessHydrationState.set(ptyId, 'pending')
|
||||
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||||
const state: RuntimeHeadlessTerminal = {
|
||||
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
|
||||
outputSequence: 0,
|
||||
writeChain: Promise.resolve()
|
||||
}
|
||||
// Why: hydration writes below never set forwardQueryReplies (main-side
|
||||
// replay guard) — renderer-buffer snapshots can embed stale queries.
|
||||
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
|
||||
this.headlessTerminals.set(ptyId, state)
|
||||
|
||||
// Why: append the seed work to writeChain so live writes queued by
|
||||
@@ -5668,9 +6125,14 @@ export class OrcaRuntimeService {
|
||||
if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) {
|
||||
state.emulator.resize(ptyDims.cols, ptyDims.rows)
|
||||
}
|
||||
if (rendered.lastTitle) {
|
||||
state.emulator.setLastTitle(rendered.lastTitle)
|
||||
this.applySeededAgentStatus(ptyId, rendered.lastTitle)
|
||||
// Why: the renderer xterm no longer sees synthetic hook title frames
|
||||
// (they feed main's tracker only), so its serializer lastTitle can be
|
||||
// stale here. Prefer main's tracked title; the renderer's is only the
|
||||
// seed when main has observed none (fresh relaunch, cold tracker).
|
||||
const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle
|
||||
if (seedTitle) {
|
||||
state.emulator.setLastTitle(seedTitle)
|
||||
this.applySeededAgentStatus(ptyId, seedTitle)
|
||||
}
|
||||
} catch {
|
||||
// Hydration is best-effort. Live writes continue via the same
|
||||
@@ -5691,6 +6153,11 @@ export class OrcaRuntimeService {
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
// Why: a relaunched main starts its per-PTY title tracker cold — without
|
||||
// this seed it misses the parked working→idle completion and never arms
|
||||
// the stale-title timer for a persisted 'working' title. Seeding no-ops
|
||||
// once a live title was observed, so live state always wins.
|
||||
this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title)
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty) {
|
||||
@@ -5711,11 +6178,28 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private trackHeadlessTerminalData(ptyId: string, data: string, outputSequence: number): void {
|
||||
/** Per-chunk reply-ownership capture (Phase 5). Evaluated synchronously at
|
||||
* ingestion only — never re-read at reply time. */
|
||||
private shouldAnswerQueriesForLiveChunk(ptyId: string): boolean {
|
||||
return shouldModelAnswerHiddenPtyQueries({
|
||||
ptyId,
|
||||
settings: this.store?.getSettings(),
|
||||
hasRemoteViewSubscriber: this.hasRemoteTerminalViewSubscriber(ptyId)
|
||||
})
|
||||
}
|
||||
|
||||
private trackHeadlessTerminalData(
|
||||
ptyId: string,
|
||||
data: string,
|
||||
outputSequence: number,
|
||||
forwardQueryReplies = false
|
||||
): void {
|
||||
const state = this.getOrCreateHeadlessTerminal(ptyId)
|
||||
state.writeChain = state.writeChain
|
||||
.then(async () => {
|
||||
await state.emulator.write(data)
|
||||
// Why: the ingestion-time ownership decision is closed over this
|
||||
// chain link; async scheduling cannot retroactively change it.
|
||||
await state.emulator.write(data, { forwardQueryReplies })
|
||||
state.outputSequence = outputSequence
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -5724,17 +6208,65 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
}
|
||||
|
||||
/** Shared factory for the per-PTY runtime emulators (seed, hydration, and
|
||||
* lazy live-byte creation): wires the Phase-5 query-reply sink and the
|
||||
* ConPTY DA1 override. The daemon emulator never goes through here. */
|
||||
private createPtyHeadlessTerminalState(
|
||||
ptyId: string,
|
||||
dims: { cols: number; rows: number }
|
||||
): RuntimeHeadlessTerminal {
|
||||
let state: RuntimeHeadlessTerminal | null = null
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
// Why: replies take the provider input path (same entry as pty:write —
|
||||
// daemon shell-ready gating and the SSH relay write apply unchanged),
|
||||
// NOT writePtyInput, so renderer interactive-output metering never
|
||||
// counts responder traffic as user-input echo.
|
||||
onQueryReply: (reply) => {
|
||||
// Why the identity check: queued writeChain links can parse after
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (isNativeWindowsConptyPty(ptyId)) {
|
||||
emulator.installConptyPrimaryDeviceAttributesOverride()
|
||||
}
|
||||
// Why the lazy getter: replies must use the freshest renderer push at
|
||||
// parse time, and stay silent (never default) before the first push.
|
||||
emulator.installViewAttributeResponder(() => getTerminalViewAttributes())
|
||||
const viewAttributes = getTerminalViewAttributes()
|
||||
if (viewAttributes) {
|
||||
emulator.applyPushedViewAttributes(viewAttributes)
|
||||
}
|
||||
state = { emulator, outputSequence: 0, writeChain: Promise.resolve() }
|
||||
return state
|
||||
}
|
||||
|
||||
/** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via
|
||||
* markNativeWindowsConptyPty when the spawn mark lands after daemon stream
|
||||
* data already created this PTY's emulator. Idempotent emulator-side. */
|
||||
private ensureNativeWindowsConptyDa1Override(ptyId: string): void {
|
||||
if (isNativeWindowsConptyPty(ptyId)) {
|
||||
this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride()
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal {
|
||||
const existing = this.headlessTerminals.get(ptyId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||||
const state: RuntimeHeadlessTerminal = {
|
||||
emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }),
|
||||
outputSequence: 0,
|
||||
writeChain: Promise.resolve()
|
||||
}
|
||||
const state = this.createPtyHeadlessTerminalState(ptyId, size)
|
||||
this.headlessTerminals.set(ptyId, state)
|
||||
return state
|
||||
}
|
||||
@@ -5810,7 +6342,9 @@ export class OrcaRuntimeService {
|
||||
// If renderer serialization races reload/unmount, callers can still use
|
||||
// their existing null fallback paths.
|
||||
}
|
||||
return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null
|
||||
return rendererSnapshot
|
||||
? this.preferTrackedLastTitle(ptyId, { ...rendererSnapshot, source: 'renderer' as const })
|
||||
: null
|
||||
}
|
||||
|
||||
private async withVisibleSnapshotFallback(
|
||||
@@ -5898,20 +6432,20 @@ export class OrcaRuntimeService {
|
||||
const snapshot = state.emulator.getSnapshot({ scrollbackRows })
|
||||
const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi
|
||||
return data.length > 0 || opts.includeEmpty === true
|
||||
? {
|
||||
? this.preferTrackedLastTitle(ptyId, {
|
||||
data,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
cwd: snapshot.cwd,
|
||||
lastTitle: snapshot.lastTitle,
|
||||
seq: state.outputSequence,
|
||||
source: 'headless',
|
||||
source: 'headless' as const,
|
||||
oscLinks: snapshot.oscLinks,
|
||||
// Why: lets the renderer skip the destructive scrollback clear when
|
||||
// restoring an alt-screen snapshot — clearing wipes xterm's own
|
||||
// history that the TUI relies on for scroll-up after a tab return.
|
||||
alternateScreen: isAlternateScreen
|
||||
}
|
||||
})
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -5922,6 +6456,10 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
this.headlessTerminals.delete(ptyId)
|
||||
// Why: queued chain links still parse below before the emulator disposes;
|
||||
// sever the reply sink now so they cannot write to a respawned PTY that
|
||||
// reused this id (belt to the sink's state-identity check).
|
||||
state.emulator.disableQueryReplyForwarding()
|
||||
state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose())
|
||||
}
|
||||
|
||||
@@ -6726,6 +7264,7 @@ export class OrcaRuntimeService {
|
||||
serveSimStateWatcher.unbindPty(ptyId)
|
||||
// Clean up new mobile state for this PTY
|
||||
this.mobileSubscribers.delete(ptyId)
|
||||
this.remoteTerminalViewSubscriberCounts.delete(ptyId)
|
||||
this.mobileDisplayModes.delete(ptyId)
|
||||
this.resizeListeners.delete(ptyId)
|
||||
this.lastRendererSizes.delete(ptyId)
|
||||
@@ -6733,6 +7272,8 @@ export class OrcaRuntimeService {
|
||||
this.clearWaitBlockedCheckState(ptyId)
|
||||
this.ptyOutputSequenceById.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.terminalSpawnCommandsByPtyId.delete(ptyId)
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
this.clearAgentRowSnapshotsForPty(ptyId)
|
||||
// Why: a Claude agent-team leader whose PTY exits naturally (agent finished,
|
||||
@@ -17743,6 +18284,8 @@ export class OrcaRuntimeService {
|
||||
this.clearWaitBlockedCheckState(ptyId)
|
||||
this.ptyOutputSequenceById.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.terminalSpawnCommandsByPtyId.delete(ptyId)
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
this.clearAgentRowSnapshotsForPty(ptyId)
|
||||
const handle = this.handleByPtyId.get(ptyId)
|
||||
|
||||
@@ -40,6 +40,9 @@ const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024
|
||||
const TERMINAL_OUTPUT_FLUSH_MS = 5
|
||||
// Why: output batches become binary stream payloads; byte size is the transport cost.
|
||||
const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024
|
||||
// Why: remote clients can apply output pressure without pausing runtime PTY ingestion.
|
||||
const TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES = 512 * 1024
|
||||
const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024
|
||||
// Why: pending output is held for later binary frames, so cap the encoded
|
||||
// payload bytes rather than UTF-16 code units.
|
||||
const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
|
||||
@@ -82,7 +85,13 @@ type TerminalMultiplexStream = {
|
||||
ptyId: string
|
||||
client: TerminalViewportClient | undefined
|
||||
isMobile: boolean
|
||||
ackOutput: boolean
|
||||
ackInFlightBytes: number
|
||||
buffering: boolean
|
||||
ackPendingOutput: TerminalOutputFrameChunk[]
|
||||
ackPendingOutputBytes: number
|
||||
ackPendingOutputOverflowed: boolean
|
||||
ackRecoverySnapshotInFlight: boolean
|
||||
pendingOutput: TerminalOutputChunk[]
|
||||
pendingOutputBytes: number
|
||||
pendingOutputOverflowed: boolean
|
||||
@@ -344,6 +353,26 @@ function getOutputAfterSnapshotSeq(
|
||||
return chunk.data.slice(snapshotSeq - chunkStartSeq)
|
||||
}
|
||||
|
||||
function appendAckPendingOutput(
|
||||
stream: TerminalMultiplexStream,
|
||||
chunk: TerminalOutputFrameChunk
|
||||
): void {
|
||||
stream.ackPendingOutput.push(chunk)
|
||||
stream.ackPendingOutputBytes += chunk.bytes.byteLength
|
||||
let omittedChunkCount = 0
|
||||
while (
|
||||
stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES &&
|
||||
omittedChunkCount < stream.ackPendingOutput.length
|
||||
) {
|
||||
stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength
|
||||
omittedChunkCount += 1
|
||||
}
|
||||
if (omittedChunkCount > 0) {
|
||||
stream.ackPendingOutput.splice(0, omittedChunkCount)
|
||||
stream.ackPendingOutputOverflowed = true
|
||||
}
|
||||
}
|
||||
|
||||
function trimPendingOutputToBudget(
|
||||
pendingOutput: TerminalOutputChunk[],
|
||||
pendingOutputBytes: number
|
||||
@@ -370,6 +399,43 @@ function measureTerminalStreamByteLength(
|
||||
return measureClipboardTextByteLength(data, options)
|
||||
}
|
||||
|
||||
function trimPendingOutputCoveredBySnapshot(
|
||||
pendingOutput: TerminalOutputChunk[],
|
||||
snapshotSeq: number | undefined
|
||||
): { chunks: TerminalOutputChunk[]; bytes: number } {
|
||||
if (typeof snapshotSeq !== 'number') {
|
||||
return {
|
||||
chunks: pendingOutput,
|
||||
bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0)
|
||||
}
|
||||
}
|
||||
const chunks: TerminalOutputChunk[] = []
|
||||
let bytes = 0
|
||||
for (const chunk of pendingOutput) {
|
||||
const chunkSeq = chunk.meta?.seq
|
||||
const rawLength = chunk.meta?.rawLength ?? chunk.data.length
|
||||
if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) {
|
||||
chunks.push(chunk)
|
||||
bytes += chunk.bytes
|
||||
continue
|
||||
}
|
||||
const startSeq = chunkSeq - rawLength
|
||||
if (snapshotSeq >= chunkSeq) {
|
||||
continue
|
||||
}
|
||||
if (snapshotSeq <= startSeq) {
|
||||
chunks.push(chunk)
|
||||
bytes += chunk.bytes
|
||||
continue
|
||||
}
|
||||
const data = chunk.data.slice(snapshotSeq - startSeq)
|
||||
const slicedBytes = terminalStreamByteLength(data)
|
||||
chunks.push({ data, bytes: slicedBytes, meta: undefined })
|
||||
bytes += slicedBytes
|
||||
}
|
||||
return { chunks, bytes }
|
||||
}
|
||||
|
||||
function terminalStreamByteLength(data: string): number {
|
||||
return measureTerminalStreamByteLength(data).byteLength
|
||||
}
|
||||
@@ -728,7 +794,16 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({
|
||||
type: z.enum(['mobile', 'desktop']).default('desktop')
|
||||
})
|
||||
.optional(),
|
||||
viewport: TerminalViewport.optional()
|
||||
viewport: TerminalViewport.optional(),
|
||||
capabilities: z
|
||||
.object({
|
||||
ackOutput: z.literal(1).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const TerminalMultiplexAckFrame = z.object({
|
||||
bytes: z.number().int().nonnegative()
|
||||
})
|
||||
|
||||
const TerminalMultiplexSnapshotRequestFrame = z.object({
|
||||
@@ -1188,6 +1263,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
let closed = false
|
||||
let cursor = 0
|
||||
const streams = new Map<number, TerminalMultiplexStream>()
|
||||
let ackTotalInFlightBytes = 0
|
||||
let resolveMultiplex = (): void => {}
|
||||
const multiplexClosed = new Promise<void>((resolve) => {
|
||||
resolveMultiplex = resolve
|
||||
@@ -1231,6 +1307,136 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
})
|
||||
)
|
||||
}
|
||||
const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => {
|
||||
if (!stream.ackOutput) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
stream.ackInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES &&
|
||||
ackTotalInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES
|
||||
)
|
||||
}
|
||||
const sendAckGatedOutput = (
|
||||
stream: TerminalMultiplexStream,
|
||||
chunk: TerminalOutputFrameChunk
|
||||
): void => {
|
||||
sendFrame(stream.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
|
||||
if (stream.ackOutput) {
|
||||
stream.ackInFlightBytes += chunk.bytes.byteLength
|
||||
ackTotalInFlightBytes += chunk.bytes.byteLength
|
||||
}
|
||||
}
|
||||
const queueOrSendOutput = (
|
||||
stream: TerminalMultiplexStream,
|
||||
chunk: TerminalOutputFrameChunk
|
||||
): void => {
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
stream.ackPendingOutputOverflowed ||
|
||||
stream.ackPendingOutput.length > 0 ||
|
||||
!canSendAckGatedOutput(stream, chunk.bytes.byteLength)
|
||||
) {
|
||||
appendAckPendingOutput(stream, chunk)
|
||||
return
|
||||
}
|
||||
sendAckGatedOutput(stream, chunk)
|
||||
}
|
||||
const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise<void> => {
|
||||
if (
|
||||
closed ||
|
||||
streams.get(stream.streamId) !== stream ||
|
||||
stream.ackRecoverySnapshotInFlight
|
||||
) {
|
||||
return
|
||||
}
|
||||
stream.ackRecoverySnapshotInFlight = true
|
||||
try {
|
||||
const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0)
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
const size = runtime.getTerminalSize(stream.ptyId)
|
||||
const displayMode = runtime.getMobileDisplayMode(stream.ptyId)
|
||||
// Why: dropped ACK-pending output means live frames are no longer a
|
||||
// complete replay. Send a fresh model snapshot before resuming output.
|
||||
// Why: truncated marks an unusable snapshot, and clients discard
|
||||
// those. The recovery snapshot must be applied to cover dropped
|
||||
// output, so it is only truncated when serialization failed.
|
||||
sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), {
|
||||
kind: 'scrollback',
|
||||
cols: serialized?.cols ?? size?.cols ?? 80,
|
||||
rows: serialized?.rows ?? size?.rows ?? 24,
|
||||
displayMode,
|
||||
reason: 'ack-pending-overflow',
|
||||
seq: serialized?.seq,
|
||||
source: serialized?.source,
|
||||
truncated: !serialized,
|
||||
truncatedByByteBudget: serialized?.truncatedByByteBudget,
|
||||
data: serialized?.data ?? ''
|
||||
})
|
||||
if (serialized && typeof serialized.seq === 'number') {
|
||||
// Why: retained chunks queued before the snapshot serialized are
|
||||
// already contained in it; replaying them would duplicate output.
|
||||
const snapshotSeq = serialized.seq
|
||||
const retained = stream.ackPendingOutput.filter(
|
||||
(chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq)
|
||||
)
|
||||
stream.ackPendingOutput = retained
|
||||
stream.ackPendingOutputBytes = retained.reduce(
|
||||
(total, chunk) => total + chunk.bytes.byteLength,
|
||||
0
|
||||
)
|
||||
}
|
||||
stream.ackPendingOutputOverflowed = false
|
||||
} catch (error) {
|
||||
sendStreamError(
|
||||
stream.streamId,
|
||||
error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.'
|
||||
)
|
||||
} finally {
|
||||
if (streams.get(stream.streamId) === stream) {
|
||||
stream.ackRecoverySnapshotInFlight = false
|
||||
flushAckPendingOutput(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
const flushAckPendingOutput = (stream: TerminalMultiplexStream): void => {
|
||||
if (stream.ackPendingOutputOverflowed) {
|
||||
void sendAckRecoverySnapshot(stream)
|
||||
return
|
||||
}
|
||||
let flushed = 0
|
||||
while (
|
||||
flushed < stream.ackPendingOutput.length &&
|
||||
canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength)
|
||||
) {
|
||||
sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)
|
||||
flushed += 1
|
||||
}
|
||||
if (flushed > 0) {
|
||||
stream.ackPendingOutput.splice(0, flushed)
|
||||
stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce(
|
||||
(total, pending) => total + pending.bytes.byteLength,
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
const flushAllAckPendingOutput = (): void => {
|
||||
for (const stream of streams.values()) {
|
||||
flushAckPendingOutput(stream)
|
||||
}
|
||||
}
|
||||
const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => {
|
||||
if (!stream.ackOutput || bytes <= 0) {
|
||||
return
|
||||
}
|
||||
const acknowledged = Math.min(stream.ackInFlightBytes, bytes)
|
||||
stream.ackInFlightBytes -= acknowledged
|
||||
ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged)
|
||||
flushAllAckPendingOutput()
|
||||
}
|
||||
const detachStream = (streamId: number, emitEnd: boolean): void => {
|
||||
const stream = streams.get(streamId)
|
||||
if (!stream) {
|
||||
@@ -1238,12 +1444,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
stream.outputBatcher.flush()
|
||||
stream.outputBatcher.dispose()
|
||||
ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes)
|
||||
stream.ackInFlightBytes = 0
|
||||
stream.ackPendingOutput = []
|
||||
stream.ackPendingOutputBytes = 0
|
||||
stream.ackPendingOutputOverflowed = false
|
||||
stream.ackRecoverySnapshotInFlight = false
|
||||
stream.unsubscribeData()
|
||||
stream.unsubscribeResize()
|
||||
stream.unsubscribeFit()
|
||||
stream.unsubscribeDriver()
|
||||
stream.unregisterBinaryHandler()
|
||||
streams.delete(streamId)
|
||||
flushAllAckPendingOutput()
|
||||
if (stream.isMobile && stream.client?.id) {
|
||||
runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id)
|
||||
}
|
||||
@@ -1273,6 +1486,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
detachStream(stream.streamId, false)
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Ack) {
|
||||
const parsed = TerminalMultiplexAckFrame.safeParse(
|
||||
decodeTerminalStreamJson<unknown>(frame.payload) ?? {}
|
||||
)
|
||||
if (parsed.success) {
|
||||
acknowledgeOutput(stream, parsed.data.bytes)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Input) {
|
||||
const text = decodeTerminalStreamText(frame.payload)
|
||||
if (!text) {
|
||||
@@ -1428,6 +1650,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
emit({ type: 'end', streamId: request.streamId })
|
||||
return
|
||||
}
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
// Why: a competing subscribe for the same streamId can fully register
|
||||
// while this one awaited the PTY id above. Overwriting it in
|
||||
// `streams` would orphan its data/view-subscriber registrations — a
|
||||
// leaked view subscriber permanently silences the model query
|
||||
// responder (terminal-query-authority.md). Detach it so every
|
||||
// registration stays release-balanced.
|
||||
detachStream(request.streamId, false)
|
||||
|
||||
const ptyId = leaf.ptyId
|
||||
const stream: TerminalMultiplexStream = {
|
||||
@@ -1436,7 +1668,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
ptyId,
|
||||
client: request.client,
|
||||
isMobile,
|
||||
ackOutput: request.capabilities?.ackOutput === 1,
|
||||
ackInFlightBytes: 0,
|
||||
buffering: true,
|
||||
ackPendingOutput: [],
|
||||
ackPendingOutputBytes: 0,
|
||||
ackPendingOutputOverflowed: false,
|
||||
ackRecoverySnapshotInFlight: false,
|
||||
pendingOutput: [],
|
||||
pendingOutputBytes: 0,
|
||||
pendingOutputOverflowed: false,
|
||||
@@ -1444,7 +1682,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
resizeGeneration: 0,
|
||||
outputBatcher: createTerminalOutputBatcher((data, meta) => {
|
||||
for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) {
|
||||
sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
|
||||
queueOrSendOutput(stream, chunk)
|
||||
}
|
||||
}),
|
||||
unsubscribeData: () => {},
|
||||
@@ -1459,7 +1697,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
)
|
||||
|
||||
try {
|
||||
stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
if (closed || streams.get(request.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
@@ -1469,6 +1707,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
stream.outputBatcher.push(data, meta)
|
||||
})
|
||||
// Why: a multiplexed stream feeds a remote xterm view that answers
|
||||
// terminal queries with view authority; the main model responder
|
||||
// yields while it is attached (terminal-query-authority.md).
|
||||
// Wrapped into unsubscribeData so every detach path releases it.
|
||||
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
|
||||
stream.unsubscribeData = () => {
|
||||
releaseViewSubscriber()
|
||||
unsubscribeStreamData()
|
||||
}
|
||||
|
||||
if (isMobile && request.client?.id) {
|
||||
await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport)
|
||||
@@ -1626,6 +1873,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Why the ownership check: a newer subscribe may own this streamId
|
||||
// now (it detached and released this stream on arrival). Detaching
|
||||
// or erroring the slot here would tear down the successor's live
|
||||
// registrations instead of this stream's.
|
||||
if (streams.get(request.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
detachStream(request.streamId, false)
|
||||
sendStreamError(request.streamId, error instanceof Error ? error.message : String(error))
|
||||
emit({ type: 'end', streamId: request.streamId })
|
||||
@@ -1726,9 +1980,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
const outputBatcher = createTerminalOutputBatcher((chunk) => {
|
||||
emit({ type: 'data', chunk })
|
||||
})
|
||||
const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
|
||||
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => {
|
||||
outputBatcher.push(data)
|
||||
})
|
||||
// Why: this legacy JSON stream can feed a live xterm view too
|
||||
// (older web/desktop subscribers), so it conservatively registers
|
||||
// as a remote view subscriber. For read-only watchers the cost is
|
||||
// a withheld model reply — the pre-Phase-5 status quo — which is
|
||||
// strictly safer than a double reply under a view consumer.
|
||||
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
|
||||
const unsubscribeData = (): void => {
|
||||
releaseViewSubscriber()
|
||||
unsubscribeStreamData()
|
||||
}
|
||||
const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
|
||||
outputBatcher.flush()
|
||||
emit({
|
||||
@@ -1766,8 +2030,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
// resize re-stream so it only fires on an actual width change.
|
||||
let lastResizeCols: number | undefined
|
||||
let resizeGeneration = 0
|
||||
const pendingOutput: TerminalOutputChunk[] = []
|
||||
let pendingOutput: TerminalOutputChunk[] = []
|
||||
let pendingOutputBytes = 0
|
||||
let pendingOutputOverflowed = false
|
||||
let unsubscribeData = (): void => {}
|
||||
let unsubscribeResize = (): void => {}
|
||||
let unsubscribeFit = (): void => {}
|
||||
@@ -1870,7 +2135,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
return
|
||||
}
|
||||
|
||||
unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
@@ -1886,10 +2151,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
pendingOutputBytes += measurement.byteLength
|
||||
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
|
||||
pendingOutputBytes = trimmed.bytes
|
||||
pendingOutputOverflowed ||= trimmed.overflowed
|
||||
return
|
||||
}
|
||||
outputBatcher?.push(data, meta)
|
||||
})
|
||||
// Why: binary subscribe streams feed remote xterm views (mobile and
|
||||
// binary-capable desktop clients) that answer queries with view
|
||||
// authority; the main model responder yields while attached.
|
||||
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
|
||||
unsubscribeData = () => {
|
||||
releaseViewSubscriber()
|
||||
unsubscribeStreamData()
|
||||
}
|
||||
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
|
||||
@@ -1938,6 +2212,55 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
// Why: baseline for resize re-stream gating; the client already
|
||||
// rewrapped to these cols via the initial snapshot replay.
|
||||
lastResizeCols = serialized?.cols ?? size?.cols
|
||||
let recoveryAttempts = 0
|
||||
// Why: if the bounded pre-subscribe tail overflowed, only a fresh
|
||||
// model snapshot can cover the dropped middle without replay gaps.
|
||||
while (pendingOutputOverflowed && recoveryAttempts < 2) {
|
||||
pendingOutputOverflowed = false
|
||||
recoveryAttempts += 1
|
||||
const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
if (!recovery) {
|
||||
break
|
||||
}
|
||||
// Why: without an output seq (renderer-source fallback) covered
|
||||
// chunks cannot be trimmed exactly, and the renderer view may lag
|
||||
// the queued chunks under backpressure. Keep the bounded replay
|
||||
// instead of applying an unverifiable snapshot.
|
||||
if (typeof recovery.seq !== 'number') {
|
||||
break
|
||||
}
|
||||
// Why: shipped mobile clients drop a second scrollback snapshot for
|
||||
// an initialized handle but apply a resized snapshot inline by
|
||||
// re-initializing xterm with fresh scrollback. Omit seq on the wire
|
||||
// so the client's layout-seq staleness filter is not polluted with
|
||||
// output-byte sequences.
|
||||
const recoveryStats = sendSnapshotFrames(sendFrame, {
|
||||
kind: 'resized',
|
||||
cols: recovery.cols,
|
||||
rows: recovery.rows,
|
||||
displayMode,
|
||||
reason: 'pending-output-overflow',
|
||||
source: recovery.source,
|
||||
truncated: false,
|
||||
truncatedByByteBudget: recovery.truncatedByByteBudget,
|
||||
data: recovery.data
|
||||
})
|
||||
console.log('[mobile-terminal-stream] recovery snapshot', {
|
||||
terminal: params.terminal,
|
||||
streamId,
|
||||
reason: 'pending-output-overflow',
|
||||
bytes: recoveryStats.bytes,
|
||||
chunks: recoveryStats.chunks,
|
||||
scrollbackRows: recovery.scrollbackRows,
|
||||
truncatedByByteBudget: recovery.truncatedByByteBudget === true
|
||||
})
|
||||
const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq)
|
||||
pendingOutput = trimmed.chunks
|
||||
pendingOutputBytes = trimmed.bytes
|
||||
}
|
||||
buffering = false
|
||||
for (const item of pendingOutput.splice(0)) {
|
||||
const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
// Why: subscribe streams register as remote view subscribers for Phase-5
|
||||
// query-authority suppression (terminal-query-authority.md).
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
// Why: every multiplex stream registers as a remote view subscriber for
|
||||
// Phase-5 query-authority suppression (terminal-query-authority.md).
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
@@ -468,6 +471,697 @@ describe('terminal multiplex RPC', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('holds ACK-capable multiplex output over budget until the client acknowledges bytes', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'snapshot',
|
||||
cols: 120,
|
||||
rows: 40
|
||||
}),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn(
|
||||
(
|
||||
_: string,
|
||||
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}
|
||||
),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-ack-gated',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 16,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const output = 'x'.repeat(700 * 1024)
|
||||
dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length })
|
||||
|
||||
const initialOutputFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
const initialBytes = initialOutputFrames.reduce(
|
||||
(total, frame) => total + (frame?.payload.byteLength ?? 0),
|
||||
0
|
||||
)
|
||||
expect(initialBytes).toBeLessThanOrEqual(512 * 1024)
|
||||
expect(initialOutputFrames.length).toBeGreaterThan(0)
|
||||
const initialOutput = initialOutputFrames
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(initialOutput.length).toBeLessThan(output.length)
|
||||
|
||||
handlers.get(16)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 16,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamText('still interactive\r')
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
|
||||
text: 'still interactive\r',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
handlers.get(16)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 16,
|
||||
seq: 3,
|
||||
payload: encodeTerminalStreamJson({ bytes: initialBytes })
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
const flushedOutputFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
expect(flushedOutputFrames.length).toBeGreaterThan(initialOutputFrames.length)
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-ack-gated')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('releases shared ACK budget to other stalled multiplex streams', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListeners = new Map<
|
||||
string,
|
||||
(data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn((terminal: string) => ({
|
||||
ptyId: terminal.replace('terminal-', 'pty-')
|
||||
})),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(async (ptyId: string) => ({
|
||||
data: `snapshot-${ptyId}`,
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn(
|
||||
(
|
||||
ptyId: string,
|
||||
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
) => {
|
||||
dataListeners.set(ptyId, listener)
|
||||
return vi.fn()
|
||||
}
|
||||
),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-ack-shared-budget',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
|
||||
const streamIds = [21, 22, 23, 24, 25, 26]
|
||||
for (const streamId of streamIds) {
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: streamId,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId,
|
||||
terminal: `terminal-${streamId - 20}`,
|
||||
client: { id: `desktop-${streamId}`, type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
messages
|
||||
.map((msg) => JSON.parse(msg).result)
|
||||
.filter((result) => result?.type === 'subscribed')
|
||||
).toHaveLength(streamIds.length)
|
||||
)
|
||||
await vi.waitFor(() => expect(dataListeners.size).toBe(streamIds.length))
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const fillerOutput = 'f'.repeat(480 * 1024)
|
||||
for (let index = 1; index <= 4; index += 1) {
|
||||
dataListeners.get(`pty-${index}`)?.(fillerOutput, {
|
||||
seq: fillerOutput.length,
|
||||
rawLength: fillerOutput.length
|
||||
})
|
||||
}
|
||||
const stalledOutput = 's'.repeat(700 * 1024)
|
||||
dataListeners.get('pty-5')?.(stalledOutput, {
|
||||
seq: stalledOutput.length,
|
||||
rawLength: stalledOutput.length
|
||||
})
|
||||
dataListeners.get('pty-6')?.(stalledOutput, {
|
||||
seq: stalledOutput.length,
|
||||
rawLength: stalledOutput.length
|
||||
})
|
||||
|
||||
const initialOutputFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
const initialBytesByStream = new Map<number, number>()
|
||||
for (const frame of initialOutputFrames) {
|
||||
if (!frame) {
|
||||
continue
|
||||
}
|
||||
initialBytesByStream.set(
|
||||
frame.streamId,
|
||||
(initialBytesByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength
|
||||
)
|
||||
}
|
||||
const initialBytes = initialOutputFrames.reduce(
|
||||
(total, frame) => total + (frame?.payload.byteLength ?? 0),
|
||||
0
|
||||
)
|
||||
expect(initialBytes).toBeLessThanOrEqual(2 * 1024 * 1024)
|
||||
expect(initialBytesByStream.get(21)).toBe(480 * 1024)
|
||||
expect(initialBytesByStream.get(22)).toBe(480 * 1024)
|
||||
expect(initialBytesByStream.get(23)).toBe(480 * 1024)
|
||||
expect(initialBytesByStream.get(24)).toBe(480 * 1024)
|
||||
expect(initialBytesByStream.get(25)).toBeGreaterThan(0)
|
||||
expect(initialBytesByStream.get(26) ?? 0).toBe(0)
|
||||
|
||||
handlers.get(26)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 26,
|
||||
seq: 200,
|
||||
payload: encodeTerminalStreamText('remote-still-interactive\r')
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-6', {
|
||||
text: 'remote-still-interactive\r',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
const frameCountBeforeAck = binaryFrames.length
|
||||
handlers.get(21)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 21,
|
||||
seq: 201,
|
||||
payload: encodeTerminalStreamJson({ bytes: initialBytesByStream.get(21) ?? 0 })
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames
|
||||
.slice(frameCountBeforeAck)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.some((frame) => {
|
||||
if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) {
|
||||
return false
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
|
||||
return payload?.reason === 'ack-pending-overflow'
|
||||
})
|
||||
).toBe(true)
|
||||
)
|
||||
const framesAfterAck = binaryFrames
|
||||
.slice(frameCountBeforeAck)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
const snapshotStartIndex = framesAfterAck.findIndex((frame) => {
|
||||
if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) {
|
||||
return false
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
|
||||
return payload?.reason === 'ack-pending-overflow'
|
||||
})
|
||||
const outputFramesAfterAck = framesAfterAck.filter(
|
||||
(frame) => frame?.opcode === TerminalStreamOpcode.Output
|
||||
)
|
||||
const bytesAfterAckByStream = new Map<number, number>()
|
||||
for (const frame of outputFramesAfterAck) {
|
||||
if (!frame) {
|
||||
continue
|
||||
}
|
||||
bytesAfterAckByStream.set(
|
||||
frame.streamId,
|
||||
(bytesAfterAckByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength
|
||||
)
|
||||
}
|
||||
expect(snapshotStartIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
framesAfterAck
|
||||
.filter((frame) => frame?.streamId === 25 && frame.opcode === TerminalStreamOpcode.Output)
|
||||
.every((frame) => framesAfterAck.indexOf(frame) > snapshotStartIndex)
|
||||
).toBe(true)
|
||||
expect(bytesAfterAckByStream.get(25) ?? 0).toBeGreaterThan(0)
|
||||
expect(bytesAfterAckByStream.get(21) ?? 0).toBe(0)
|
||||
expect(
|
||||
outputFramesAfterAck.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
).toBeLessThanOrEqual(initialBytesByStream.get(21) ?? 0)
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-ack-shared-budget')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('caps stalled ACK output and snapshots before resuming retained tail frames', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 })
|
||||
.mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: 99 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn(
|
||||
(
|
||||
_: string,
|
||||
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}
|
||||
),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-ack-overflow',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 17,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const output = 'x'.repeat(3 * 1024 * 1024)
|
||||
dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length })
|
||||
|
||||
const initialOutputFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
const initialBytes = initialOutputFrames.reduce(
|
||||
(total, frame) => total + (frame?.payload.byteLength ?? 0),
|
||||
0
|
||||
)
|
||||
expect(initialBytes).toBeLessThanOrEqual(512 * 1024)
|
||||
|
||||
handlers.get(17)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 17,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamText('still interactive\r')
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
|
||||
text: 'still interactive\r',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
binaryFrames.splice(0)
|
||||
handlers.get(17)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 17,
|
||||
seq: 3,
|
||||
payload: encodeTerminalStreamJson({ bytes: initialBytes })
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.some((frame) => {
|
||||
if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) {
|
||||
return false
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
|
||||
return payload?.reason === 'ack-pending-overflow'
|
||||
})
|
||||
).toBe(true)
|
||||
)
|
||||
const drainFrames = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
const recoveryStartIndex = drainFrames.findIndex((frame) => {
|
||||
if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) {
|
||||
return false
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
|
||||
return payload?.reason === 'ack-pending-overflow'
|
||||
})
|
||||
const firstOutputAfterAckIndex = drainFrames.findIndex(
|
||||
(frame) => frame?.opcode === TerminalStreamOpcode.Output
|
||||
)
|
||||
expect(recoveryStartIndex).toBeGreaterThanOrEqual(0)
|
||||
// Why: clients discard truncated snapshots; a usable recovery snapshot
|
||||
// must not be marked truncated or the dropped output gap is permanent.
|
||||
expect(
|
||||
decodeTerminalStreamJson<{ truncated?: boolean }>(drainFrames[recoveryStartIndex]!.payload)
|
||||
?.truncated
|
||||
).toBe(false)
|
||||
expect(firstOutputAfterAckIndex).toBeGreaterThan(recoveryStartIndex)
|
||||
expect(
|
||||
drainFrames
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
).toBe('recovered snapshot')
|
||||
|
||||
const outputBytesAfterRecovery = drainFrames
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
expect(outputBytesAfterRecovery).toBeLessThanOrEqual(256 * 1024)
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-ack-overflow')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('trims recovery-covered ACK pending output instead of replaying it', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const floodedChars = 3 * 1024 * 1024
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 })
|
||||
// Why: the recovery snapshot seq covers the entire flood, so every
|
||||
// retained pending chunk is already contained in the snapshot.
|
||||
.mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: floodedChars }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn(
|
||||
(
|
||||
_: string,
|
||||
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}
|
||||
),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-ack-trim',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 31,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const output = 'x'.repeat(floodedChars)
|
||||
dataListenerRef.current?.(output, { seq: floodedChars, rawLength: floodedChars })
|
||||
const initialBytes = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
expect(initialBytes).toBeLessThanOrEqual(512 * 1024)
|
||||
|
||||
binaryFrames.splice(0)
|
||||
handlers.get(31)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 31,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamJson({ bytes: initialBytes })
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd)
|
||||
).toBe(true)
|
||||
)
|
||||
|
||||
const framesAfterRecovery = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
expect(
|
||||
framesAfterRecovery
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
).toBe('recovered snapshot')
|
||||
// Why: every retained chunk is covered by the recovery snapshot seq;
|
||||
// replaying any of them would duplicate snapshot content.
|
||||
expect(
|
||||
framesAfterRecovery.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
).toEqual([])
|
||||
|
||||
binaryFrames.splice(0)
|
||||
const fresh = 'fresh-after-recovery\r\n'
|
||||
dataListenerRef.current?.(fresh, {
|
||||
seq: floodedChars + fresh.length,
|
||||
rawLength: fresh.length
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
const freshOutput = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(freshOutput).toBe(fresh)
|
||||
})
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-ack-trim')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
@@ -1372,6 +2066,226 @@ describe('terminal multiplex RPC', () => {
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('keeps view-subscriber releases balanced when a same-streamId subscribe overwrites a blocked one', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
// Why: a leaked registration permanently suppresses the model query
|
||||
// responder (terminal-query-authority.md) — the count must return to 0.
|
||||
let viewSubscriberCount = 0
|
||||
let leafResolved = false
|
||||
let resolveFirstWait: (ptyId: string) => void = () => {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn(() => (leafResolved ? { ptyId: 'pty-1' } : { ptyId: null })),
|
||||
waitForLeafPtyId: vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveFirstWait = resolve
|
||||
})
|
||||
),
|
||||
registerRemoteTerminalViewSubscriber: vi.fn(() => {
|
||||
viewSubscriberCount += 1
|
||||
let released = false
|
||||
return () => {
|
||||
if (!released) {
|
||||
released = true
|
||||
viewSubscriberCount -= 1
|
||||
}
|
||||
}
|
||||
}),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
handleMobileSubscribe: vi.fn().mockResolvedValue(undefined),
|
||||
handleMobileUnsubscribe: vi.fn(),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-overwrite',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
const sendSubscribe = (): void => {
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 7,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'phone-1', type: 'mobile' }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe A blocks in waitForLeafPtyId; subscribe B (same streamId)
|
||||
// then resolves the leaf directly and fully registers.
|
||||
sendSubscribe()
|
||||
await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled())
|
||||
leafResolved = true
|
||||
sendSubscribe()
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength(
|
||||
1
|
||||
)
|
||||
)
|
||||
|
||||
// A resumes and takes the slot; B's registration must be released, not
|
||||
// orphaned by the overwrite.
|
||||
resolveFirstWait('pty-1')
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength(
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
handlers.get(7)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Unsubscribe,
|
||||
streamId: 7,
|
||||
seq: 2,
|
||||
payload: new Uint8Array()
|
||||
})
|
||||
)!
|
||||
)
|
||||
expect(viewSubscriberCount).toBe(0)
|
||||
|
||||
cleanups.get('terminal-multiplex:conn-overwrite')?.()
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('keeps an evicted subscribe error from detaching the successor stream', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
let viewSubscriberCount = 0
|
||||
const mobileSubscribeWaiters: {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
}[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
registerRemoteTerminalViewSubscriber: vi.fn(() => {
|
||||
viewSubscriberCount += 1
|
||||
let released = false
|
||||
return () => {
|
||||
if (!released) {
|
||||
released = true
|
||||
viewSubscriberCount -= 1
|
||||
}
|
||||
}
|
||||
}),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
handleMobileSubscribe: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve, reject) => {
|
||||
mobileSubscribeWaiters.push({ resolve: () => resolve(true), reject })
|
||||
})
|
||||
),
|
||||
handleMobileUnsubscribe: vi.fn(),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-evicted-error',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
const sendSubscribe = (): void => {
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 9,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'phone-1', type: 'mobile' }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
// A registers, then blocks in handleMobileSubscribe. B (same streamId)
|
||||
// evicts A on arrival and completes its own registration.
|
||||
sendSubscribe()
|
||||
await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(1))
|
||||
sendSubscribe()
|
||||
await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(2))
|
||||
mobileSubscribeWaiters[1]!.resolve()
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength(
|
||||
1
|
||||
)
|
||||
)
|
||||
expect(viewSubscriberCount).toBe(1)
|
||||
|
||||
// A's pending await now rejects. The evicted stream must not detach the
|
||||
// successor that owns the slot.
|
||||
mobileSubscribeWaiters[0]!.reject(new Error('mobile_subscribe_failed'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(viewSubscriberCount).toBe(1)
|
||||
|
||||
cleanups.get('terminal-multiplex:conn-evicted-error')?.()
|
||||
await dispatchPromise
|
||||
expect(viewSubscriberCount).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds live output queued while a multiplex snapshot is loading', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
// Why: subscribe streams register as remote view subscribers for Phase-5
|
||||
// query-authority suppression (terminal-query-authority.md).
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
// Why: subscribe streams register as remote view subscribers for Phase-5
|
||||
// query-authority suppression (terminal-query-authority.md).
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
@@ -331,21 +334,35 @@ describe('terminal subscribe buffering', () => {
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('bounds legacy binary output queued while the initial snapshot is serializing', async () => {
|
||||
it('recovers binary output overflow queued while the initial snapshot is serializing', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: { current?: (data: string) => void } = {}
|
||||
let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {}
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const snapshotResolvers: ((value: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
}) => void)[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
new Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
}>((resolve) => {
|
||||
snapshotResolvers.push(resolve)
|
||||
})
|
||||
),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
@@ -386,26 +403,63 @@ describe('terminal subscribe buffering', () => {
|
||||
|
||||
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
|
||||
const shiftSpy = vi.spyOn(Array.prototype, 'shift')
|
||||
let seq = 0
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`)
|
||||
const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}`
|
||||
seq += data.length
|
||||
dataListenerRef.current?.(data, { seq, rawLength: data.length })
|
||||
}
|
||||
const shiftCallCount = shiftSpy.mock.calls.length
|
||||
shiftSpy.mockRestore()
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled())
|
||||
resolveSnapshot({ data: '', cols: 120, rows: 40 })
|
||||
snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' })
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2))
|
||||
snapshotResolvers[1]?.({
|
||||
data: 'recovered after overflow\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq,
|
||||
source: 'headless'
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
const output = binaryFrames
|
||||
const decodedFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.filter((frame): frame is NonNullable<typeof frame> => frame !== null)
|
||||
const snapshotStarts = decodedFrames.filter(
|
||||
(frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart
|
||||
)
|
||||
const decodedStarts = snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))
|
||||
// Why: shipped mobile clients only apply a mid-session snapshot when it
|
||||
// arrives as a resized frame; a second scrollback frame is dropped.
|
||||
// Why seq 0: the merged stream prefers the snapshot's own output seq
|
||||
// (post-restore live-chunk reconciliation) over the layout seq.
|
||||
expect(decodedStarts).toEqual([
|
||||
expect.objectContaining({ kind: 'scrollback', seq: 0 }),
|
||||
expect.objectContaining({
|
||||
kind: 'resized',
|
||||
reason: 'pending-output-overflow',
|
||||
source: 'headless'
|
||||
})
|
||||
])
|
||||
// Why: output-byte sequences must not pollute the client layout-seq
|
||||
// staleness filter.
|
||||
expect(decodedStarts[1]).not.toHaveProperty('seq')
|
||||
const snapshotText = decodedFrames
|
||||
.filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => decodeTerminalStreamText(frame.payload))
|
||||
.join('')
|
||||
const output = decodedFrames
|
||||
.filter((frame) => frame.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => decodeTerminalStreamText(frame.payload))
|
||||
.join('')
|
||||
expect(output.length).toBeLessThanOrEqual(256 * 1024)
|
||||
expect(output).not.toContain('000')
|
||||
expect(output).toContain('399')
|
||||
expect(output).not.toContain('399')
|
||||
expect(snapshotText).toContain('recovered after overflow')
|
||||
expect(shiftCallCount).toBe(0)
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
@@ -531,4 +585,120 @@ describe('terminal subscribe buffering', () => {
|
||||
runtime.cleanupSubscription('terminal-1:phone-1')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('keeps bounded replay when overflow recovery has no output seq', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const snapshotResolvers: ((value: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
}) => void)[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
new Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
}>((resolve) => {
|
||||
snapshotResolvers.push(resolve)
|
||||
})
|
||||
),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateMobileViewport: vi.fn().mockResolvedValue(false)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-buffered-no-seq',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes)
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
|
||||
let seq = 0
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}`
|
||||
seq += data.length
|
||||
dataListenerRef.current?.(data, { seq, rawLength: data.length })
|
||||
}
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled())
|
||||
snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' })
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2))
|
||||
// Why: renderer-source snapshots carry no output seq, so covered chunks
|
||||
// cannot be trimmed and the recovery snapshot must not be applied.
|
||||
snapshotResolvers[1]?.({
|
||||
data: 'renderer fallback snapshot\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
source: 'renderer'
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
const decodedFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame): frame is NonNullable<typeof frame> => frame !== null)
|
||||
const snapshotStarts = decodedFrames.filter(
|
||||
(frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart
|
||||
)
|
||||
// Why seq 0: the merged stream prefers the snapshot's own output seq
|
||||
// (post-restore live-chunk reconciliation) over the layout seq.
|
||||
expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([
|
||||
expect.objectContaining({ kind: 'scrollback', seq: 0 })
|
||||
])
|
||||
const output = decodedFrames
|
||||
.filter((frame) => frame.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => decodeTerminalStreamText(frame.payload))
|
||||
.join('')
|
||||
expect(output.length).toBeLessThanOrEqual(256 * 1024)
|
||||
expect(output).not.toContain('000')
|
||||
expect(output).toContain('399')
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
await dispatchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,15 @@ import * as runtimeMetadataModule from './runtime-metadata'
|
||||
import { readRuntimeMetadata } from './runtime-metadata'
|
||||
import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamText,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../shared/terminal-stream-protocol'
|
||||
import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto'
|
||||
import { DeviceRegistry } from './device-registry'
|
||||
|
||||
@@ -2760,6 +2769,206 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const writes: { terminal: string; text: string }[] = []
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'multiplex-background-pty' })
|
||||
.mockResolvedValueOnce({ id: 'multiplex-active-pty' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: (ptyId, data) => {
|
||||
writes.push({ terminal: ptyId, text: data })
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
const phoneOffer = server.createPairingOffer({
|
||||
address: '127.0.0.1',
|
||||
name: 'phone',
|
||||
scope: 'mobile'
|
||||
})
|
||||
expect(phoneOffer.available).toBe(true)
|
||||
if (!phoneOffer.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
const pairing = parsePairingCode(phoneOffer.pairingUrl)
|
||||
expect(pairing).toBeTruthy()
|
||||
if (!pairing) {
|
||||
throw new Error('Pairing URL did not parse')
|
||||
}
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const laptopEndpoint = metadata!.transports[0]!.endpoint
|
||||
const laptopAuthToken = metadata!.authToken
|
||||
const worktree = 'id:repo-1::/tmp/worktree-a'
|
||||
const backgroundLeafId = '11111111-1111-4111-8111-111111111111'
|
||||
const activeLeafId = '22222222-2222-4222-8222-222222222222'
|
||||
const backgroundCreateResponse = await sendRequest(laptopEndpoint, {
|
||||
id: 'laptop_create_background',
|
||||
authToken: laptopAuthToken,
|
||||
method: 'terminal.create',
|
||||
params: {
|
||||
worktree,
|
||||
command: 'background',
|
||||
tabId: 'multiplex-background-tab',
|
||||
leafId: backgroundLeafId
|
||||
}
|
||||
})
|
||||
const activeCreateResponse = await sendRequest(laptopEndpoint, {
|
||||
id: 'laptop_create_active',
|
||||
authToken: laptopAuthToken,
|
||||
method: 'terminal.create',
|
||||
params: {
|
||||
worktree,
|
||||
command: 'active',
|
||||
tabId: 'multiplex-active-tab',
|
||||
leafId: activeLeafId,
|
||||
activate: true
|
||||
}
|
||||
})
|
||||
const backgroundTerminal = (backgroundCreateResponse.result as { terminal: { handle: string } })
|
||||
.terminal
|
||||
const activeTerminal = (activeCreateResponse.result as { terminal: { handle: string } })
|
||||
.terminal
|
||||
|
||||
const responses: Record<string, unknown>[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const onError = vi.fn()
|
||||
const subscription = await subscribeRemoteRuntimeRequest(
|
||||
pairing,
|
||||
'terminal.multiplex',
|
||||
{},
|
||||
15_000,
|
||||
{
|
||||
onResponse: (response) => responses.push(response as Record<string, unknown>),
|
||||
onBinary: (bytes) => binaryFrames.push(bytes),
|
||||
onError
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
responses.some(
|
||||
(response) => (response.result as { type?: string } | undefined)?.type === 'ready'
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
subscription.sendBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
seq: 1,
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 21,
|
||||
terminal: backgroundTerminal.handle,
|
||||
client: { id: 'desktop-background', type: 'desktop' },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)
|
||||
subscription.sendBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
seq: 2,
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 22,
|
||||
terminal: activeTerminal.handle,
|
||||
client: { id: 'desktop-active', type: 'desktop' },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
const subscribedStreamIds = responses
|
||||
.map((response) => response.result as { type?: string; streamId?: number } | undefined)
|
||||
.filter((result) => result?.type === 'subscribed')
|
||||
.map((result) => result?.streamId)
|
||||
expect(subscribedStreamIds).toEqual(expect.arrayContaining([21, 22]))
|
||||
})
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const backgroundOutput = 'B'.repeat(700 * 1024)
|
||||
runtime.onPtyData('multiplex-background-pty', backgroundOutput, 1)
|
||||
await vi.waitFor(() => {
|
||||
const backgroundFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
|
||||
const backgroundBytes = backgroundFrames.reduce(
|
||||
(total, frame) => total + (frame?.payload.byteLength ?? 0),
|
||||
0
|
||||
)
|
||||
expect(backgroundBytes).toBeGreaterThan(0)
|
||||
expect(backgroundBytes).toBeLessThan(backgroundOutput.length)
|
||||
})
|
||||
|
||||
const frameCountBeforeActive = binaryFrames.length
|
||||
runtime.onPtyData('multiplex-active-pty', 'ACTIVE_MULTIPLEX_READY\r\n', 2)
|
||||
await vi.waitFor(() => {
|
||||
const activeOutput = binaryFrames
|
||||
.slice(frameCountBeforeActive)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 22)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(activeOutput).toContain('ACTIVE_MULTIPLEX_READY')
|
||||
})
|
||||
|
||||
subscription.sendBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
seq: 3,
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 22,
|
||||
payload: encodeTerminalStreamText('still interactive\r')
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(writes).toContainEqual({
|
||||
terminal: 'multiplex-active-pty',
|
||||
text: 'still interactive\r'
|
||||
})
|
||||
)
|
||||
|
||||
const backgroundBytesBeforeAck = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
|
||||
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
subscription.sendBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
seq: 4,
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 21,
|
||||
payload: encodeTerminalStreamJson({ bytes: backgroundBytesBeforeAck })
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
const backgroundBytesAfterAck = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
|
||||
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
expect(backgroundBytesAfterAck).toBeGreaterThan(backgroundBytesBeforeAck)
|
||||
})
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
subscription.close()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('serves worktree.ps from the runtime summary builder', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_resetTerminalModelQueryAuthorityForTest,
|
||||
clearNativeWindowsConptyPty,
|
||||
isNativeWindowsConptyPty,
|
||||
isNativeWindowsLocalPtySpawn,
|
||||
isTerminalModelQueryAuthorityEnabled,
|
||||
markNativeWindowsConptyPty,
|
||||
shouldModelAnswerHiddenPtyQueries
|
||||
} from './terminal-model-query-authority'
|
||||
import {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest,
|
||||
markHiddenRendererPty,
|
||||
setRendererPtyDeliveryInterest
|
||||
} from '../ipc/pty-hidden-delivery-gate'
|
||||
|
||||
const ALL_ON = {
|
||||
terminalMainSideEffectAuthority: true,
|
||||
terminalHiddenDeliveryGate: true,
|
||||
terminalModelQueryAuthority: true
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
_resetTerminalModelQueryAuthorityForTest()
|
||||
_resetHiddenRendererPtyDeliveryGateForTest()
|
||||
})
|
||||
|
||||
describe('isTerminalModelQueryAuthorityEnabled', () => {
|
||||
it('defaults on, including for absent settings', () => {
|
||||
expect(isTerminalModelQueryAuthorityEnabled(ALL_ON)).toBe(true)
|
||||
expect(isTerminalModelQueryAuthorityEnabled({})).toBe(true)
|
||||
expect(isTerminalModelQueryAuthorityEnabled(null)).toBe(true)
|
||||
expect(isTerminalModelQueryAuthorityEnabled(undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('is an independent off switch for the responder alone', () => {
|
||||
expect(
|
||||
isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalModelQueryAuthority: false })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires both Phase-4 gate switches — no marks exist without them', () => {
|
||||
expect(
|
||||
isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalHiddenDeliveryGate: false })
|
||||
).toBe(false)
|
||||
expect(
|
||||
isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalMainSideEffectAuthority: false })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldModelAnswerHiddenPtyQueries', () => {
|
||||
const answer = (ptyId: string, overrides: Record<string, boolean> = {}): boolean =>
|
||||
shouldModelAnswerHiddenPtyQueries({
|
||||
ptyId,
|
||||
settings: { ...ALL_ON, ...overrides },
|
||||
hasRemoteViewSubscriber: false
|
||||
})
|
||||
|
||||
it('answers only for hidden-marked PTYs (the delivery decision is the reply decision)', () => {
|
||||
expect(answer('pty-1')).toBe(false)
|
||||
markHiddenRendererPty('pty-1')
|
||||
expect(answer('pty-1')).toBe(true)
|
||||
expect(answer('pty-other')).toBe(false)
|
||||
})
|
||||
|
||||
it('yields to registered renderer delivery interest (chunk is delivered to a sidecar)', () => {
|
||||
markHiddenRendererPty('pty-1')
|
||||
setRendererPtyDeliveryInterest('pty-1', true)
|
||||
expect(answer('pty-1')).toBe(false)
|
||||
setRendererPtyDeliveryInterest('pty-1', false)
|
||||
expect(answer('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('yields while a remote view subscriber is attached', () => {
|
||||
markHiddenRendererPty('pty-1')
|
||||
expect(
|
||||
shouldModelAnswerHiddenPtyQueries({
|
||||
ptyId: 'pty-1',
|
||||
settings: ALL_ON,
|
||||
hasRemoteViewSubscriber: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stays silent under any kill switch', () => {
|
||||
markHiddenRendererPty('pty-1')
|
||||
expect(answer('pty-1', { terminalModelQueryAuthority: false })).toBe(false)
|
||||
expect(answer('pty-1', { terminalHiddenDeliveryGate: false })).toBe(false)
|
||||
expect(answer('pty-1', { terminalMainSideEffectAuthority: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNativeWindowsLocalPtySpawn (main-side mirror of isLocalNativeWindowsPty)', () => {
|
||||
const base = {
|
||||
connectionId: null,
|
||||
cwd: 'C:\\repo',
|
||||
shellOverride: undefined,
|
||||
platform: 'win32' as NodeJS.Platform
|
||||
}
|
||||
|
||||
it('matches local native Windows spawns', () => {
|
||||
expect(isNativeWindowsLocalPtySpawn(base)).toBe(true)
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: undefined })).toBe(true)
|
||||
expect(
|
||||
isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Tools\\powershell.exe' })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-Windows hosts', () => {
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'darwin' })).toBe(false)
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'linux' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects SSH-backed spawns', () => {
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: 'ssh-1' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects WSL cwds and WSL shell overrides', () => {
|
||||
expect(
|
||||
isNativeWindowsLocalPtySpawn({ ...base, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me' })
|
||||
).toBe(false)
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl.exe' })).toBe(false)
|
||||
expect(
|
||||
isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Windows\\System32\\wsl.exe' })
|
||||
).toBe(false)
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native-Windows ConPTY spawn record', () => {
|
||||
it('marks, reads, and clears per PTY', () => {
|
||||
expect(isNativeWindowsConptyPty('pty-1')).toBe(false)
|
||||
markNativeWindowsConptyPty('pty-1')
|
||||
expect(isNativeWindowsConptyPty('pty-1')).toBe(true)
|
||||
expect(isNativeWindowsConptyPty('pty-2')).toBe(false)
|
||||
clearNativeWindowsConptyPty('pty-1')
|
||||
expect(isNativeWindowsConptyPty('pty-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Phase 5 of the terminal model/view architecture: main-side terminal query
|
||||
* authority (docs/reference/terminal-query-authority.md).
|
||||
*
|
||||
* The delivery decision is the reply decision: main answers a query iff the
|
||||
* hidden-delivery gate dropped the chunk that carried it. This module owns
|
||||
* the responder kill-switch predicate and the main-side mirror of the
|
||||
* renderer's native-Windows-ConPTY determination, recorded per PTY at spawn
|
||||
* so the runtime emulator can register the DA1 override before byte zero.
|
||||
*/
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
isHiddenPtyDeliveryGateEnabled,
|
||||
shouldDropHiddenRendererPtyData
|
||||
} from '../ipc/pty-hidden-delivery-gate'
|
||||
|
||||
export type TerminalModelQueryAuthoritySettings = Pick<
|
||||
GlobalSettings,
|
||||
'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' | 'terminalModelQueryAuthority'
|
||||
>
|
||||
|
||||
/** Responder kill switch: requires BOTH Phase-4 gate switches (no marks/drops
|
||||
* exist without them) plus the Phase-5-specific independent off switch. */
|
||||
export function isTerminalModelQueryAuthorityEnabled(
|
||||
settings: TerminalModelQueryAuthoritySettings | null | undefined
|
||||
): boolean {
|
||||
return isHiddenPtyDeliveryGateEnabled(settings) && settings?.terminalModelQueryAuthority !== false
|
||||
}
|
||||
|
||||
/** Per-chunk reply-ownership predicate, evaluated once at ingestion in
|
||||
* OrcaRuntimeService.onPtyData — the same module state and tick as the
|
||||
* hidden-gate drop sites, so "chunk dropped" and "main answers" cannot
|
||||
* diverge for live chunks. Remote view subscribers (mobile/web/remote
|
||||
* desktop xterms on the multiplexed stream) keep view authority, so main
|
||||
* yields while one is attached. */
|
||||
export function shouldModelAnswerHiddenPtyQueries(opts: {
|
||||
ptyId: string
|
||||
settings: TerminalModelQueryAuthoritySettings | null | undefined
|
||||
hasRemoteViewSubscriber: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
isTerminalModelQueryAuthorityEnabled(opts.settings) &&
|
||||
!opts.hasRemoteViewSubscriber &&
|
||||
shouldDropHiddenRendererPtyData(opts.ptyId, opts.settings)
|
||||
)
|
||||
}
|
||||
|
||||
/** Main-side mirror of the renderer's isLocalNativeWindowsPty
|
||||
* (windows-pty-compatibility.ts), computed from spawn-time facts: local or
|
||||
* daemon provider (no SSH connection), win32 host, and not a WSL shell. */
|
||||
export function isNativeWindowsLocalPtySpawn(opts: {
|
||||
connectionId: string | null | undefined
|
||||
cwd: string | null | undefined
|
||||
shellOverride: string | null | undefined
|
||||
platform?: NodeJS.Platform
|
||||
}): boolean {
|
||||
if ((opts.platform ?? process.platform) !== 'win32') {
|
||||
return false
|
||||
}
|
||||
if (opts.connectionId) {
|
||||
return false
|
||||
}
|
||||
if (isWslUncPath(opts.cwd ?? '')) {
|
||||
return false
|
||||
}
|
||||
if (/(?:^|[/\\])wsl(?:\.exe)?$/i.test(opts.shellOverride ?? '')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts records
|
||||
// the determination at spawn, the runtime consults it at emulator creation.
|
||||
// Daemon-adopted PTYs from a previous app run carry no mark — acceptable:
|
||||
// ConPTY's blocking DA1 only fires at spawn, which happened in a prior life.
|
||||
const nativeWindowsConptyPtys = new Set<string>()
|
||||
|
||||
// Why installers: the mark lands after the awaited spawn response, but daemon
|
||||
// stream data (warm-reattach flush) can lazy-create the runtime emulator
|
||||
// first. The runtime registers an installer so marking retrofits the DA1
|
||||
// override onto an existing emulator; installation is idempotent emulator-side.
|
||||
type ConptyDa1OverrideInstaller = (ptyId: string) => void
|
||||
const conptyDa1OverrideInstallers = new Set<ConptyDa1OverrideInstaller>()
|
||||
|
||||
export function registerConptyDa1OverrideInstaller(installer: ConptyDa1OverrideInstaller): void {
|
||||
conptyDa1OverrideInstallers.add(installer)
|
||||
}
|
||||
|
||||
export function markNativeWindowsConptyPty(id: string): void {
|
||||
nativeWindowsConptyPtys.add(id)
|
||||
for (const installer of conptyDa1OverrideInstallers) {
|
||||
installer(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativeWindowsConptyPty(id: string): boolean {
|
||||
return nativeWindowsConptyPtys.has(id)
|
||||
}
|
||||
|
||||
/** Wired into clearProviderPtyState so every PTY teardown path releases the
|
||||
* spawn record. */
|
||||
export function clearNativeWindowsConptyPty(id: string): void {
|
||||
nativeWindowsConptyPtys.delete(id)
|
||||
}
|
||||
|
||||
/** Test seam: reset module state between tests. */
|
||||
export function _resetTerminalModelQueryAuthorityForTest(): void {
|
||||
nativeWindowsConptyPtys.clear()
|
||||
conptyDa1OverrideInstallers.clear()
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* Phase 5 model query responder (docs/reference/terminal-query-authority.md):
|
||||
* reply parity through the runtime emulator, the per-chunk ownership matrix,
|
||||
* the main-side replay guard, and the ingestion-time capture race.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest,
|
||||
markHiddenRendererPty,
|
||||
setRendererPtyDeliveryInterest,
|
||||
unmarkHiddenRendererPty
|
||||
} from '../ipc/pty-hidden-delivery-gate'
|
||||
import {
|
||||
_resetTerminalModelQueryAuthorityForTest,
|
||||
markNativeWindowsConptyPty
|
||||
} from './terminal-model-query-authority'
|
||||
import {
|
||||
_resetTerminalViewAttributesForTest,
|
||||
setTerminalViewAttributes
|
||||
} from './terminal-view-attribute-store'
|
||||
import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes'
|
||||
|
||||
const settingsState = {
|
||||
terminalMainSideEffectAuthority: true as boolean,
|
||||
terminalHiddenDeliveryGate: true as boolean,
|
||||
terminalModelQueryAuthority: true as boolean
|
||||
}
|
||||
|
||||
const store = {
|
||||
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: settingsState.terminalMainSideEffectAuthority,
|
||||
terminalHiddenDeliveryGate: settingsState.terminalHiddenDeliveryGate,
|
||||
terminalModelQueryAuthority: settingsState.terminalModelQueryAuthority
|
||||
})
|
||||
}
|
||||
|
||||
type RendererBufferStub = { data: string; cols: number; rows: number }
|
||||
|
||||
function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const replies: { ptyId: string; data: string }[] = []
|
||||
runtime.setPtyController({
|
||||
write: (ptyId, data) => {
|
||||
replies.push({ ptyId, data })
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
getSize: () => ({ cols: 80, rows: 24 }),
|
||||
resize: () => true,
|
||||
...(opts.rendererBuffer
|
||||
? {
|
||||
hasRendererSerializer: () => true,
|
||||
serializeBuffer: async () => opts.rendererBuffer ?? null
|
||||
}
|
||||
: {})
|
||||
})
|
||||
return { runtime, replies }
|
||||
}
|
||||
|
||||
/** Awaits the per-PTY emulator writeChain so queued chunk links (and the
|
||||
* replies they forward) have settled. */
|
||||
async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise<void> {
|
||||
await runtime.serializeMainTerminalBuffer(ptyId)
|
||||
}
|
||||
|
||||
/** Renderer-pushed attribute snapshot with distinct, pinned slot values so
|
||||
* reply fixtures cannot pass by coincidence. */
|
||||
function viewAttributes(overrides: Partial<TerminalViewAttributes> = {}): TerminalViewAttributes {
|
||||
const ansi = Array.from(
|
||||
{ length: 256 },
|
||||
(_, i) => [i, (i * 2) % 256, (i * 3) % 256] as TerminalViewRgb
|
||||
)
|
||||
ansi[1] = [0xcc, 0x00, 0x00]
|
||||
return {
|
||||
foreground: [0xd0, 0xd0, 0xd0],
|
||||
background: [0x1e, 0x1e, 0x2e],
|
||||
cursor: [0xff, 0x99, 0x00],
|
||||
ansi,
|
||||
colorSchemeMode: 'dark',
|
||||
cursorStyle: 'bar',
|
||||
cursorBlink: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest()
|
||||
_resetTerminalModelQueryAuthorityForTest()
|
||||
_resetTerminalViewAttributesForTest()
|
||||
settingsState.terminalMainSideEffectAuthority = true
|
||||
settingsState.terminalHiddenDeliveryGate = true
|
||||
settingsState.terminalModelQueryAuthority = true
|
||||
})
|
||||
|
||||
describe('reply parity for hidden-dropped chunks', () => {
|
||||
// Expected replies pinned from the design doc and verified against the
|
||||
// bundled @xterm/headless build — the same core the renderer runs, so
|
||||
// parity is structural for static and model-state classes.
|
||||
it.each([
|
||||
['DA1 CSI c', '\x1b[c', ['\x1b[?1;2c']],
|
||||
['DA1 CSI 0 c variant', '\x1b[0c', ['\x1b[?1;2c']],
|
||||
['DA2', '\x1b[>c', ['\x1b[>0;276;0c']],
|
||||
['DSR 5n operating status', '\x1b[5n', ['\x1b[0n']],
|
||||
['CPR 6n at origin', '\x1b[6n', ['\x1b[1;1R']],
|
||||
['CPR 6n reports the model cursor position', 'hello\r\nworld\x1b[6n', ['\x1b[2;6R']],
|
||||
['DECXCPR ?6n', '\x1b[?6n', ['\x1b[?1;1R']],
|
||||
['DECRPM ?1 DECCKM default', '\x1b[?1$p', ['\x1b[?1;2$y']],
|
||||
['DECRPM ?6 DECOM default', '\x1b[?6$p', ['\x1b[?6;2$y']],
|
||||
['DECRPM ?7 DECAWM default', '\x1b[?7$p', ['\x1b[?7;1$y']],
|
||||
['DECRPM ?25 DECTCEM default', '\x1b[?25$p', ['\x1b[?25;1$y']],
|
||||
['DECRPM ?1004 focus events default', '\x1b[?1004$p', ['\x1b[?1004;2$y']],
|
||||
['DECRPM ?1006 SGR mouse default', '\x1b[?1006$p', ['\x1b[?1006;2$y']],
|
||||
['DECRPM ?1016 SGR pixels default', '\x1b[?1016$p', ['\x1b[?1016;2$y']],
|
||||
['DECRPM ?1049 alt screen default', '\x1b[?1049$p', ['\x1b[?1049;2$y']],
|
||||
['DECRPM ?2004 bracketed paste default', '\x1b[?2004$p', ['\x1b[?2004;2$y']],
|
||||
['DECRPM ?2026 synchronized output default', '\x1b[?2026$p', ['\x1b[?2026;2$y']],
|
||||
['DECRPM reports a set mode as enabled', '\x1b[?2004h\x1b[?2004$p', ['\x1b[?2004;1$y']],
|
||||
['DECRPM unknown mode reports 0', '\x1b[?12345$p', ['\x1b[?12345;0$y']],
|
||||
['DECRQM ANSI insert mode', '\x1b[4$p', ['\x1b[4;2$y']],
|
||||
['DECRQSS DECSTBM default margins', '\x1bP$qr\x1b\\', ['\x1bP1$r1;24r\x1b\\']],
|
||||
['DECRQSS DECSTBM after margin set', '\x1b[5;20r\x1bP$qr\x1b\\', ['\x1bP1$r5;20r\x1b\\']],
|
||||
['DECRQSS DECSCUSR default cursor', '\x1bP$q q\x1b\\', ['\x1bP1$r2 q\x1b\\']],
|
||||
['DECRQSS DECSCA', '\x1bP$q"q\x1b\\', ['\x1bP1$r0"q\x1b\\']],
|
||||
['DECRQSS SGR', '\x1bP$qm\x1b\\', ['\x1bP1$r0m\x1b\\']],
|
||||
['XTVERSION', '\x1b[>0q', ['\x1bP>|xterm.js(6.0.0)\x1b\\']],
|
||||
['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']],
|
||||
['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']]
|
||||
])('%s', async (_label, chunk, expectedReplies) => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-q')
|
||||
|
||||
runtime.onPtyData('pty-q', chunk, Date.now())
|
||||
await settle(runtime, 'pty-q')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(expectedReplies)
|
||||
expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['XTWINOPS', '\x1b[14t'],
|
||||
['XTGETTCAP', '\x1bP+q544e\x1b\\'],
|
||||
['DSR ?15n printer status', '\x1b[?15n'],
|
||||
['DSR ?25n UDK status', '\x1b[?25n'],
|
||||
['DSR ?26n keyboard status', '\x1b[?26n'],
|
||||
['DSR ?53n locator status', '\x1b[?53n'],
|
||||
// View-attribute class: silent until the slice-2 renderer attribute push
|
||||
// — a fabricated default would resurrect the default-black OSC-11 bug.
|
||||
['OSC 10 foreground query', '\x1b]10;?\x07'],
|
||||
['OSC 11 background query', '\x1b]11;?\x07'],
|
||||
['OSC 12 cursor-color query', '\x1b]12;?\x1b\\'],
|
||||
['OSC 4 palette query', '\x1b]4;1;?\x07'],
|
||||
['DSR ?996n color-scheme query', '\x1b[?996n']
|
||||
])('stays silent for %s', async (_label, chunk) => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-q')
|
||||
|
||||
runtime.onPtyData('pty-q', chunk, Date.now())
|
||||
await settle(runtime, 'pty-q')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reply ownership matrix', () => {
|
||||
const DA1 = '\x1b[c'
|
||||
|
||||
it('never answers delivered (unmarked) chunks — the visible xterm owns them', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
|
||||
runtime.onPtyData('pty-v', DA1, Date.now())
|
||||
await settle(runtime, 'pty-v')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never answers while renderer delivery interest holds the chunk delivered', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-i')
|
||||
setRendererPtyDeliveryInterest('pty-i', true)
|
||||
|
||||
runtime.onPtyData('pty-i', DA1, Date.now())
|
||||
await settle(runtime, 'pty-i')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)],
|
||||
['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)],
|
||||
[
|
||||
'terminalMainSideEffectAuthority',
|
||||
() => (settingsState.terminalMainSideEffectAuthority = false)
|
||||
]
|
||||
])('never answers with kill switch %s off', async (_label, flip) => {
|
||||
flip()
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-k')
|
||||
|
||||
runtime.onPtyData('pty-k', DA1, Date.now())
|
||||
await settle(runtime, 'pty-k')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('yields while a remote view subscriber is attached and resumes on release', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-r')
|
||||
const release = runtime.registerRemoteTerminalViewSubscriber('pty-r')
|
||||
|
||||
runtime.onPtyData('pty-r', DA1, Date.now())
|
||||
await settle(runtime, 'pty-r')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
release()
|
||||
// Releases are idempotent: a double release must not unbalance the count.
|
||||
release()
|
||||
runtime.onPtyData('pty-r', DA1, Date.now())
|
||||
await settle(runtime, 'pty-r')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
|
||||
})
|
||||
|
||||
it('counts overlapping remote view subscribers', () => {
|
||||
const { runtime } = createResponderRuntime()
|
||||
const releaseA = runtime.registerRemoteTerminalViewSubscriber('pty-m')
|
||||
const releaseB = runtime.registerRemoteTerminalViewSubscriber('pty-m')
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true)
|
||||
releaseA()
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true)
|
||||
releaseB()
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats mobile subscriber records as remote view subscribers', async () => {
|
||||
const { runtime } = createResponderRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-mob', 'client-1', { cols: 40, rows: 20 })
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber('pty-mob')).toBe(true)
|
||||
})
|
||||
|
||||
it('answers a dropped-chunk query exactly once', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-once')
|
||||
|
||||
runtime.onPtyData('pty-once', DA1, Date.now())
|
||||
await settle(runtime, 'pty-once')
|
||||
|
||||
expect(replies).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('main-side replay guard', () => {
|
||||
const DA1 = '\x1b[c'
|
||||
|
||||
it('never answers queries embedded in a seeded snapshot, then answers live bytes', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-seed')
|
||||
|
||||
runtime.seedHeadlessTerminal('pty-seed', `restored prompt${DA1}`)
|
||||
await settle(runtime, 'pty-seed')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-seed', DA1, Date.now())
|
||||
await settle(runtime, 'pty-seed')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
|
||||
})
|
||||
|
||||
it('never answers queries replayed by renderer-buffer hydration', async () => {
|
||||
const { runtime, replies } = createResponderRuntime({
|
||||
rendererBuffer: { data: `restored screen${DA1}`, cols: 80, rows: 24 }
|
||||
})
|
||||
markHiddenRendererPty('pty-hyd')
|
||||
|
||||
// First live byte triggers maybeHydrateHeadlessFromRenderer; the hydration
|
||||
// seed parses the embedded DA1 but must not forward its reply.
|
||||
runtime.onPtyData('pty-hyd', 'live output', Date.now())
|
||||
await settle(runtime, 'pty-hyd')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
it('still answers when the hidden mark flips off between ingestion and the async write', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-race')
|
||||
|
||||
runtime.onPtyData('pty-race', DA1, Date.now())
|
||||
// Flip before the queued writeChain link runs: the captured decision wins.
|
||||
unmarkHiddenRendererPty('pty-race')
|
||||
await settle(runtime, 'pty-race')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
|
||||
})
|
||||
|
||||
it('stays silent when the hidden mark lands after ingestion', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
|
||||
runtime.onPtyData('pty-race2', DA1, Date.now())
|
||||
markHiddenRendererPty('pty-race2')
|
||||
await settle(runtime, 'pty-race2')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale writeChain links after dispose', () => {
|
||||
const DA1 = '\x1b[c'
|
||||
|
||||
it('never forwards a queued reply once the PTY state is disposed', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-stale')
|
||||
|
||||
// Queue a forward-flagged chain link, then dispose before it runs.
|
||||
runtime.onPtyData('pty-stale', DA1, Date.now())
|
||||
runtime.onPtyExit('pty-stale', 0)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never injects a stale reply into a successor PTY reusing the session id', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-reuse')
|
||||
|
||||
// Daemon respawns reuse session ids: dispose with the flagged link still
|
||||
// queued, then re-create the same id before the link runs.
|
||||
runtime.onPtyData('pty-reuse', DA1, Date.now())
|
||||
runtime.onPtyExit('pty-reuse', 0)
|
||||
runtime.onPtyData('pty-reuse', 'fresh shell banner', Date.now())
|
||||
await settle(runtime, 'pty-reuse')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConPTY DA1 override', () => {
|
||||
it('retrofits the override when the spawn mark lands after data created the emulator', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-win-late')
|
||||
|
||||
// Daemon warm-reattach flush: stream data creates the emulator before
|
||||
// the awaited spawn response marks the PTY native-Windows.
|
||||
runtime.onPtyData('pty-win-late', 'warm reattach flush', Date.now())
|
||||
markNativeWindowsConptyPty('pty-win-late')
|
||||
|
||||
runtime.onPtyData('pty-win-late', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win-late')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
|
||||
})
|
||||
|
||||
it('keeps the override single-reply when installed at creation and marked again', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win-idem')
|
||||
markHiddenRendererPty('pty-win-idem')
|
||||
|
||||
runtime.onPtyData('pty-win-idem', 'boot output', Date.now())
|
||||
// A duplicate mark (e.g. respawn against a live emulator) must not stack
|
||||
// a second handler that double-replies.
|
||||
markNativeWindowsConptyPty('pty-win-idem')
|
||||
|
||||
runtime.onPtyData('pty-win-idem', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win-idem')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
|
||||
})
|
||||
|
||||
it('answers CSI ?61;4c for marked native-Windows PTYs, suppressing the core ?1;2c', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win')
|
||||
markHiddenRendererPty('pty-win')
|
||||
|
||||
runtime.onPtyData('pty-win', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
|
||||
})
|
||||
|
||||
it('lets non-primary device-attribute queries fall through to the core', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win2')
|
||||
markHiddenRendererPty('pty-win2')
|
||||
|
||||
runtime.onPtyData('pty-win2', '\x1b[>c', Date.now())
|
||||
await settle(runtime, 'pty-win2')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[>0;276;0c'])
|
||||
})
|
||||
|
||||
it('keeps the override silent for delivered chunks', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win3')
|
||||
|
||||
runtime.onPtyData('pty-win3', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win3')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeadlessEmulator forwarding window', () => {
|
||||
it('forwards replies only for writes flagged forwardQueryReplies', async () => {
|
||||
const onQueryReply = vi.fn()
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
|
||||
try {
|
||||
await emulator.write('\x1b[c')
|
||||
expect(onQueryReply).not.toHaveBeenCalled()
|
||||
|
||||
await emulator.write('\x1b[c', { forwardQueryReplies: true })
|
||||
expect(onQueryReply).toHaveBeenCalledTimes(1)
|
||||
expect(onQueryReply).toHaveBeenCalledWith('\x1b[?1;2c')
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes the async-fallback forwarding window to the flagged chunk parse', async () => {
|
||||
const onQueryReply = vi.fn()
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
|
||||
// Force the async write path (xterm deprecates writeSync; the fallback
|
||||
// must stay structurally safe without writeChain serialization).
|
||||
const internals = emulator as unknown as { terminal: { _core: { writeSync?: unknown } } }
|
||||
internals.terminal._core.writeSync = undefined
|
||||
try {
|
||||
// Enqueue an unflagged seed carrying a query, then a flagged live
|
||||
// chunk, WITHOUT awaiting between them: both sit in xterm's write
|
||||
// queue together. The seed parse must not see an open window.
|
||||
const seed = emulator.write('seeded\x1b[c')
|
||||
const live = emulator.write('\x1b[5n', { forwardQueryReplies: true })
|
||||
await Promise.all([seed, live])
|
||||
|
||||
expect(onQueryReply.mock.calls.map((call) => call[0])).toEqual(['\x1b[0n'])
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the ConPTY override inside the forwarding window', async () => {
|
||||
const onQueryReply = vi.fn()
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
|
||||
emulator.installConptyPrimaryDeviceAttributesOverride()
|
||||
try {
|
||||
// Unflagged (replayed/seeded) DA1 must answer no one even with the
|
||||
// override installed.
|
||||
await emulator.write('\x1b[c')
|
||||
expect(onQueryReply).not.toHaveBeenCalled()
|
||||
|
||||
await emulator.write('\x1b[c', { forwardQueryReplies: true })
|
||||
expect(onQueryReply).toHaveBeenCalledTimes(1)
|
||||
expect(onQueryReply).toHaveBeenCalledWith('\x1b[?61;4c')
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('view-attribute bridge replies (after renderer push)', () => {
|
||||
// Reply bytes pinned to the renderer xterm's format: OSC replies use the
|
||||
// queried ident, 16-bit doubled-byte channels, and ST termination
|
||||
// (CoreBrowserTerminal._handleColorEvent + toRgbString); ?996n answers with
|
||||
// the contour 997 report, same bytes as mode2031SequenceFor.
|
||||
it.each([
|
||||
['OSC 10 foreground', '\x1b]10;?\x07', ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\']],
|
||||
['OSC 11 background', '\x1b]11;?\x07', ['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']],
|
||||
['OSC 12 cursor color', '\x1b]12;?\x1b\\', ['\x1b]12;rgb:ffff/9999/0000\x1b\\']],
|
||||
['OSC 4 named palette slot', '\x1b]4;1;?\x07', ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']],
|
||||
['OSC 4 extended palette slot', '\x1b]4;196;?\x07', ['\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']],
|
||||
[
|
||||
'OSC 4 multiple slots in one sequence',
|
||||
'\x1b]4;1;?;196;?\x07',
|
||||
['\x1b]4;1;rgb:cccc/0000/0000\x1b\\', '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']
|
||||
],
|
||||
[
|
||||
'OSC 10 stacked params report foreground then background',
|
||||
'\x1b]10;?;?\x07',
|
||||
['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']
|
||||
],
|
||||
['DSR ?996n dark', '\x1b[?996n', ['\x1b[?997;1n']],
|
||||
['DECRQSS DECSCUSR from pushed cursor options', '\x1bP$q q\x1b\\', ['\x1bP1$r5 q\x1b\\']],
|
||||
['DECRQM ?12 from pushed cursorBlink', '\x1b[?12$p', ['\x1b[?12;1$y']]
|
||||
])('%s', async (_label, chunk, expectedReplies) => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-view')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-view', chunk, Date.now())
|
||||
await settle(runtime, 'pty-view')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(expectedReplies)
|
||||
})
|
||||
|
||||
it('answers ?996n from palette luminance, not the pushed app mode (dark palette, light app mode)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-lum-dark')
|
||||
// Supported divergence: light app mode with terminalUseSeparateLightTheme
|
||||
// off renders a dark terminal theme. A visible xterm answers ?996n from
|
||||
// bg/fg relative luminance (CoreBrowserTerminal._reportColorScheme), so
|
||||
// the hidden reply must say dark here too.
|
||||
setTerminalViewAttributes(viewAttributes({ colorSchemeMode: 'light' }))
|
||||
|
||||
runtime.onPtyData('pty-lum-dark', '\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-lum-dark')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;1n'])
|
||||
})
|
||||
|
||||
it('answers ?996n light for a light palette regardless of the pushed app mode', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-lum-light')
|
||||
setTerminalViewAttributes(
|
||||
viewAttributes({
|
||||
foreground: [0x33, 0x33, 0x33],
|
||||
background: [0xfa, 0xfa, 0xfa],
|
||||
colorSchemeMode: 'dark'
|
||||
})
|
||||
)
|
||||
|
||||
runtime.onPtyData('pty-lum-light', '\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-lum-light')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n'])
|
||||
})
|
||||
|
||||
it('answers ?996n from OSC-SET-mutated colors like a visible xterm', async () => {
|
||||
// _reportColorScheme reads the CURRENT theme-service colors, which include
|
||||
// OSC 10/11 SET mutations — the per-PTY overlays layer the same way.
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-lum-set')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-lum-set', '\x1b]11;#ffffff\x07\x1b]10;#101010\x07\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-lum-set')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n'])
|
||||
})
|
||||
|
||||
it('stays silent before the first push, then answers the same query after it', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-first')
|
||||
|
||||
runtime.onPtyData('pty-first', '\x1b]11;?\x07\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-first')
|
||||
// No fabricated defaults: silence is the documented hidden status quo.
|
||||
expect(replies).toEqual([])
|
||||
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
runtime.onPtyData('pty-first', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-first')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
|
||||
})
|
||||
|
||||
it('retrofits cursor options onto already-live emulators when the push lands late', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-late')
|
||||
|
||||
// Emulator exists before any push: core default DECSCUSR is steady block.
|
||||
runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now())
|
||||
await settle(runtime, 'pty-late')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\'])
|
||||
|
||||
setTerminalViewAttributes(viewAttributes({ cursorStyle: 'underline', cursorBlink: false }))
|
||||
runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now())
|
||||
await settle(runtime, 'pty-late')
|
||||
expect(replies.map((reply) => reply.data).at(-1)).toBe('\x1bP1$r4 q\x1b\\')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-PTY OSC color SET layering', () => {
|
||||
it('layers an OSC 4 SET over the pushed base, isolated per PTY', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-a')
|
||||
markHiddenRendererPty('pty-b')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-a', '\x1b]4;1;rgb:00/ff/00\x07\x1b]4;1;?\x07', Date.now())
|
||||
runtime.onPtyData('pty-b', '\x1b]4;1;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-a')
|
||||
await settle(runtime, 'pty-b')
|
||||
|
||||
expect(replies).toEqual([
|
||||
{ ptyId: 'pty-a', data: '\x1b]4;1;rgb:0000/ffff/0000\x1b\\' },
|
||||
{ ptyId: 'pty-b', data: '\x1b]4;1;rgb:cccc/0000/0000\x1b\\' }
|
||||
])
|
||||
})
|
||||
|
||||
it('restores a single indexed color via OSC 104;<idx>', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-104')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-104', '\x1b]4;1;#00ff00\x07\x1b]104;1\x07\x1b]4;1;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-104')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:cccc/0000/0000\x1b\\'])
|
||||
})
|
||||
|
||||
it('restores the whole indexed table via bare OSC 104', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-104all')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData(
|
||||
'pty-104all',
|
||||
'\x1b]4;1;#00ff00;196;#0000ff\x07\x1b]104\x07\x1b]4;1;?;196;?\x07',
|
||||
Date.now()
|
||||
)
|
||||
await settle(runtime, 'pty-104all')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual([
|
||||
'\x1b]4;1;rgb:cccc/0000/0000\x1b\\',
|
||||
'\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\'
|
||||
])
|
||||
})
|
||||
|
||||
it('layers OSC 10/11/12 SETs and restores them via 110/111/112', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-special')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData(
|
||||
'pty-special',
|
||||
'\x1b]10;#010203\x07\x1b]11;rgb:ff/ff/ff\x07\x1b]12;#0a0b0c\x07' +
|
||||
'\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07' +
|
||||
'\x1b]110\x07\x1b]111\x07\x1b]112\x07' +
|
||||
'\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07',
|
||||
Date.now()
|
||||
)
|
||||
await settle(runtime, 'pty-special')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual([
|
||||
'\x1b]10;rgb:0101/0202/0303\x1b\\',
|
||||
'\x1b]11;rgb:ffff/ffff/ffff\x1b\\',
|
||||
'\x1b]12;rgb:0a0a/0b0b/0c0c\x1b\\',
|
||||
'\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\',
|
||||
'\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\',
|
||||
'\x1b]12;rgb:ffff/9999/0000\x1b\\'
|
||||
])
|
||||
})
|
||||
|
||||
it('tracks SET mutations parsed from a seed without replying, like renderer replay', async () => {
|
||||
// Cold-restore scrollback replayed into a visible renderer xterm re-applies
|
||||
// OSC SETs to its theme service; the model mirrors that state — but the
|
||||
// replay guard still keeps the seed from ANSWERING anything.
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-seedset')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.seedHeadlessTerminal('pty-seedset', 'restored\x1b]4;1;#00ff00\x07\x1b]4;1;?\x07')
|
||||
await settle(runtime, 'pty-seedset')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-seedset', '\x1b]4;1;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-seedset')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:0000/ffff/0000\x1b\\'])
|
||||
})
|
||||
|
||||
it('preserves per-PTY overrides on an identical re-push (fresh renderer process)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-idem')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-idem', '\x1b]11;#ffffff\x07', Date.now())
|
||||
await settle(runtime, 'pty-idem')
|
||||
|
||||
// A second window / renderer reload / macOS re-activation re-pushes
|
||||
// byte-identical attributes (its publisher dedupe is per-process). That is
|
||||
// not a theme apply, so the OSC SET overlay must survive.
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
runtime.onPtyData('pty-idem', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-idem')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:ffff/ffff/ffff\x1b\\'])
|
||||
})
|
||||
|
||||
it('clears per-PTY overrides when a new push lands (theme apply parity)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-clear')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-clear', '\x1b]11;#ffffff\x07', Date.now())
|
||||
await settle(runtime, 'pty-clear')
|
||||
|
||||
// A theme apply overwrites OSC-SET-mutated colors on visible panes too
|
||||
// (ThemeService._setTheme), so the model mirrors that on every CHANGED
|
||||
// push (identical re-pushes are filtered — see the test above).
|
||||
setTerminalViewAttributes(viewAttributes({ background: [0x10, 0x20, 0x30] }))
|
||||
runtime.onPtyData('pty-clear', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-clear')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1010/2020/3030\x1b\\'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('view-attribute replay guard and suppression', () => {
|
||||
it('never answers view-attribute queries embedded in a seeded snapshot', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-vseed')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.seedHeadlessTerminal('pty-vseed', 'prompt\x1b]11;?\x07\x1b[?996n')
|
||||
await settle(runtime, 'pty-vseed')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-vseed', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-vseed')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
|
||||
})
|
||||
|
||||
it('never answers view-attribute queries replayed by renderer-buffer hydration', async () => {
|
||||
const { runtime, replies } = createResponderRuntime({
|
||||
rendererBuffer: { data: 'restored\x1b]11;?\x07\x1b[?996n', cols: 80, rows: 24 }
|
||||
})
|
||||
markHiddenRendererPty('pty-vhyd')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-vhyd', 'live output', Date.now())
|
||||
await settle(runtime, 'pty-vhyd')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never answers a delivered (unmarked) view-attribute query — the visible xterm owns it', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-vvis', '\x1b]11;?\x07\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-vvis')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never answers while renderer delivery interest holds the chunk delivered', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-vint')
|
||||
setRendererPtyDeliveryInterest('pty-vint', true)
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-vint', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-vint')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('yields view-attribute replies while a remote view subscriber is attached', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-vrem')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
const release = runtime.registerRemoteTerminalViewSubscriber('pty-vrem')
|
||||
|
||||
runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-vrem')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
release()
|
||||
runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-vrem')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)],
|
||||
['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)],
|
||||
[
|
||||
'terminalMainSideEffectAuthority',
|
||||
() => (settingsState.terminalMainSideEffectAuthority = false)
|
||||
]
|
||||
])('never answers view-attribute queries with kill switch %s off', async (_label, flip) => {
|
||||
flip()
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-vkill')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-vkill', '\x1b]11;?\x07\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-vkill')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute
|
||||
* bridge): main-side cache of the renderer's `pty:terminalViewAttributes`
|
||||
* push. One app-global snapshot, not per-PTY — per-pane font zoom never
|
||||
* affects these attributes and the color/cursor settings are global.
|
||||
*
|
||||
* Null until the first push, and the responder answers NO view-attribute
|
||||
* query while null (silent-until-first-push): a fabricated default would
|
||||
* resurrect the default-black OSC-11 bug. Staleness is bounded by one IPC
|
||||
* hop; subscribed TUIs are corrected by the renderer-owned 2031/997 flip.
|
||||
*/
|
||||
import {
|
||||
terminalViewAttributesEqual,
|
||||
type TerminalViewAttributes
|
||||
} from '../../shared/terminal-view-attributes'
|
||||
|
||||
// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives
|
||||
// the push, the runtime emulators consult it at reply time via the getter.
|
||||
let currentAttributes: TerminalViewAttributes | null = null
|
||||
|
||||
// Why appliers (pattern of registerConptyDa1OverrideInstaller): each push
|
||||
// must also reach already-live emulators — cursor options under the replay
|
||||
// guard, plus the per-PTY override reset a theme apply implies.
|
||||
type TerminalViewAttributesApplier = (attributes: TerminalViewAttributes) => void
|
||||
const pushAppliers = new Set<TerminalViewAttributesApplier>()
|
||||
|
||||
export function registerTerminalViewAttributesApplier(
|
||||
applier: TerminalViewAttributesApplier
|
||||
): void {
|
||||
pushAppliers.add(applier)
|
||||
}
|
||||
|
||||
/** Called from the pty:terminalViewAttributes IPC handler with a validated
|
||||
* payload. Last push wins (replies always use the freshest snapshot). */
|
||||
export function setTerminalViewAttributes(attributes: TerminalViewAttributes): void {
|
||||
// Why idempotent: the renderer publisher's dedupe is per-process, so a
|
||||
// fresh renderer (second window, reload, macOS re-activation) re-pushes
|
||||
// identical attributes. That is not a theme apply — fanning out would wipe
|
||||
// every PTY's OSC SET overlay while visible panes keep theirs.
|
||||
if (currentAttributes && terminalViewAttributesEqual(currentAttributes, attributes)) {
|
||||
return
|
||||
}
|
||||
currentAttributes = attributes
|
||||
for (const applier of pushAppliers) {
|
||||
applier(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTerminalViewAttributes(): TerminalViewAttributes | null {
|
||||
return currentAttributes
|
||||
}
|
||||
|
||||
/** Test seam: reset module state between tests. */
|
||||
export function _resetTerminalViewAttributesForTest(): void {
|
||||
currentAttributes = null
|
||||
pushAppliers.clear()
|
||||
}
|
||||
@@ -91,6 +91,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
}))
|
||||
|
||||
const { deployAndLaunchRelay } = await import('./ssh-relay-deploy')
|
||||
// Why: the hidden-delivery gate module is intentionally real (pure state, no
|
||||
// electron deps) so the SSH parity tests exercise the same gate main uses.
|
||||
const { markHiddenRendererPty, setRendererPtyDeliveryInterest } =
|
||||
await import('../ipc/pty-hidden-delivery-gate')
|
||||
const { _resetHiddenRendererPtyDeliveryGateForTest } =
|
||||
await import('../ipc/pty-hidden-delivery-gate')
|
||||
const { execCommand } = await import('./ssh-relay-deploy-helpers')
|
||||
const { getRemoteHostPlatform } = await import('./ssh-remote-platform')
|
||||
const {
|
||||
@@ -147,6 +153,95 @@ describe('SshRelaySession', () => {
|
||||
installRemoteManagedAgentHooksMock.mockResolvedValue([])
|
||||
mockDeploySuccess()
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
_resetHiddenRendererPtyDeliveryGateForTest()
|
||||
})
|
||||
|
||||
it('drops hidden-gated PTY data after runtime ingestion with one restore marker', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
|
||||
const runtime = {
|
||||
onPtyData: vi.fn(() => 99),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
const session = new SshRelaySession(
|
||||
'target-1',
|
||||
getMainWindow,
|
||||
mockStore,
|
||||
mockPortForward,
|
||||
runtime as never
|
||||
)
|
||||
await session.establish(mockConn)
|
||||
const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as {
|
||||
onData: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: {
|
||||
id: string
|
||||
data: string
|
||||
}) => void
|
||||
|
||||
markHiddenRendererPty('ssh-pty-1')
|
||||
onData({ id: 'ssh-pty-1', data: 'hidden ssh output' })
|
||||
|
||||
// Runtime ingestion still ran; renderer delivery shrank to one marker.
|
||||
expect(runtime.onPtyData).toHaveBeenCalledWith(
|
||||
'ssh-pty-1',
|
||||
'hidden ssh output',
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1)
|
||||
// Why out-of-band: an in-band empty pty:data sentinel is ambiguous with
|
||||
// chunks fully consumed by renderer OSC-9999 stripping.
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
|
||||
id: 'ssh-pty-1',
|
||||
reason: 'hidden-drop',
|
||||
markerSeq: 99
|
||||
})
|
||||
|
||||
onData({ id: 'ssh-pty-1', data: 'more hidden ssh output' })
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Delivery interest (renderer sidecars) suppresses the gate — parity with
|
||||
// the local path in ipc/pty.ts.
|
||||
setRendererPtyDeliveryInterest('ssh-pty-1', true)
|
||||
onData({ id: 'ssh-pty-1', data: 'sidecar ssh bytes' })
|
||||
expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
|
||||
id: 'ssh-pty-1',
|
||||
data: 'sidecar ssh bytes',
|
||||
seq: 99,
|
||||
rawLength: 'sidecar ssh bytes'.length
|
||||
})
|
||||
|
||||
// Non-hidden PTYs are unaffected.
|
||||
onData({ id: 'ssh-pty-2', data: 'visible ssh output' })
|
||||
expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
|
||||
id: 'ssh-pty-2',
|
||||
data: 'visible ssh output',
|
||||
seq: 99,
|
||||
rawLength: 'visible ssh output'.length
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps hidden SSH delivery when the gate kill switch is off', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
|
||||
;(mockStore as unknown as { getSettings: () => unknown }).getSettings = vi.fn(() => ({
|
||||
terminalHiddenDeliveryGate: false
|
||||
}))
|
||||
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
|
||||
await session.establish(mockConn)
|
||||
const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as {
|
||||
onData: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: {
|
||||
id: string
|
||||
data: string
|
||||
}) => void
|
||||
|
||||
markHiddenRendererPty('ssh-pty-1')
|
||||
onData({ id: 'ssh-pty-1', data: 'still delivered' })
|
||||
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
|
||||
id: 'ssh-pty-1',
|
||||
data: 'still delivered'
|
||||
})
|
||||
})
|
||||
|
||||
it('starts in idle state', () => {
|
||||
|
||||
@@ -40,6 +40,11 @@ import {
|
||||
setPtyOwnership,
|
||||
answerStartupTerminalColorQueriesForPty
|
||||
} from '../ipc/pty'
|
||||
import {
|
||||
recordHiddenRendererPtyDataDrop,
|
||||
shouldDropHiddenRendererPtyData
|
||||
} from '../ipc/pty-hidden-delivery-gate'
|
||||
import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider,
|
||||
@@ -944,7 +949,30 @@ export class SshRelaySession {
|
||||
const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now())
|
||||
const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data)
|
||||
const win = this.getMainWindow()
|
||||
if (win && !win.isDestroyed() && rendererData.length > 0) {
|
||||
if (!win || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
// Why: hidden-delivery gate parity with ipc/pty.ts — runtime ingestion
|
||||
// above already consumed the chunk; gated renderer delivery is dropped
|
||||
// and one out-of-band pty:modelRestoreNeeded signal latches
|
||||
// model-restore-needed for reveal. Never an in-band pty:data sentinel:
|
||||
// OSC-9999-only chunks legitimately strip to empty in the renderer.
|
||||
const store = this.store as { getSettings?: Store['getSettings'] }
|
||||
if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) {
|
||||
const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
win.webContents.send('pty:modelRestoreNeeded', {
|
||||
id: payload.id,
|
||||
reason: 'hidden-drop',
|
||||
...(typeof seq === 'number' ? { markerSeq: seq } : {})
|
||||
} satisfies PtyModelRestoreNeededEvent)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: startup color-query answering can strip query-only chunks to
|
||||
// empty; skip empty sends and only attach seq metadata when the chunk
|
||||
// reaches the renderer unmodified (seq tracks raw stream offsets).
|
||||
if (rendererData.length > 0) {
|
||||
win.webContents.send('pty:data', {
|
||||
...payload,
|
||||
data: rendererData,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing'
|
||||
|
||||
describe('shouldCopySyntheticTitleFrameToPtyData', () => {
|
||||
it('keeps the legacy pty:data copy only while the kill switch is off', () => {
|
||||
// Authority off: renderer byte parsers are the sole synthetic-frame
|
||||
// consumer, so the legacy copy must keep flowing.
|
||||
expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: false })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('skips the copy under main authority — tracker ingest is the only consumer', () => {
|
||||
// Why: under authority the copy would only mint phantom renderer ACKs
|
||||
// for fabricated bytes main never metered.
|
||||
expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: true })).toBe(
|
||||
false
|
||||
)
|
||||
// Default-on: an unset switch means main authority.
|
||||
expect(shouldCopySyntheticTitleFrameToPtyData({})).toBe(false)
|
||||
expect(shouldCopySyntheticTitleFrameToPtyData(null)).toBe(false)
|
||||
expect(shouldCopySyntheticTitleFrameToPtyData(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { GlobalSettings } from '../shared/types'
|
||||
|
||||
/**
|
||||
* Why: with the side-effect kill switch off, renderer byte parsers are the
|
||||
* ONLY consumer of main-fabricated OSC title frames, so they must still ride
|
||||
* `pty:data`. With main authority on (the default), the tracker ingest is the
|
||||
* sole consumer and the legacy copy would only mint phantom renderer ACKs for
|
||||
* bytes main never metered. See terminal-side-effect-authority.md (slice 3).
|
||||
*/
|
||||
export function shouldCopySyntheticTitleFrameToPtyData(
|
||||
settings: Pick<GlobalSettings, 'terminalMainSideEffectAuthority'> | null | undefined
|
||||
): boolean {
|
||||
return settings?.terminalMainSideEffectAuthority === false
|
||||
}
|
||||
+51
-12
@@ -176,22 +176,13 @@ import type {
|
||||
WorkspaceSessionPatch,
|
||||
WorkspaceSessionState
|
||||
} from '../shared/types'
|
||||
|
||||
type GitLabRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
type GitHubRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
|
||||
import type { TerminalViewAttributes } from '../shared/terminal-view-attributes'
|
||||
import type {
|
||||
WarpThemeImportPreview,
|
||||
WarpThemeImportSource
|
||||
} from '../shared/terminal-custom-themes'
|
||||
|
||||
import type { SetupScriptImportCandidate } from '../shared/setup-script-imports'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
@@ -262,6 +253,7 @@ import type {
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import type {
|
||||
RuntimeBrowserDriverState,
|
||||
RuntimeMobileSessionTabMove,
|
||||
@@ -409,6 +401,18 @@ import type {
|
||||
} from '../shared/workspace-cleanup'
|
||||
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
|
||||
|
||||
type GitLabRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
type GitHubRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
export type BrowserApi = {
|
||||
registerGuest: (args: {
|
||||
browserPageId: string
|
||||
@@ -1123,6 +1127,10 @@ export type PreloadApi = {
|
||||
shellOverride?: string
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution
|
||||
terminalColorQueryReplies?: { foreground?: string; background?: 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.
|
||||
@@ -1155,6 +1163,15 @@ export type PreloadApi = {
|
||||
ackData: (id: string, charCount: number) => void
|
||||
setActiveRendererPty: (id: string, active: boolean) => void
|
||||
setRendererPtyVisible: (id: string, visible: boolean) => void
|
||||
/** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer
|
||||
* byte delivery after model ingestion; reveal restores from snapshots. */
|
||||
setHiddenRendererPty: (id: string, hidden: boolean) => void
|
||||
/** Ref-counted-on-the-renderer delivery-interest signal that suppresses
|
||||
* the hidden-delivery gate while any raw-byte consumer is registered. */
|
||||
setPtyDeliveryInterest: (id: string, interested: boolean) => void
|
||||
/** View-attribute bridge (Phase 5 slice 2): app-global composed terminal
|
||||
* appearance push backing main's hidden-PTY OSC/DSR color replies. */
|
||||
publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void
|
||||
hasChildProcesses: (id: string) => Promise<boolean>
|
||||
getForegroundProcess: (id: string) => Promise<string | null>
|
||||
getCwd: (id: string) => Promise<string>
|
||||
@@ -1170,6 +1187,10 @@ export type PreloadApi = {
|
||||
rows: number
|
||||
cwd?: string | null
|
||||
seq?: number
|
||||
/** Start of main's pending renderer-delivery queue at snapshot time
|
||||
* (equals `seq` when empty) — bounds the renderer's post-restore
|
||||
* duplicate window. */
|
||||
pendingDeliveryStartSeq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
alternateScreen?: boolean
|
||||
} | null>
|
||||
@@ -1187,6 +1208,11 @@ export type PreloadApi = {
|
||||
peakRendererInFlightChars: number
|
||||
peakMaxRendererInFlightCharsByPty: number
|
||||
ackGatedFlushSkipCount: number
|
||||
hiddenDeliveryGatedPtyCount: number
|
||||
deliveryInterestPtyCount: number
|
||||
hiddenDeliveryDroppedChars: number
|
||||
hiddenDeliveryDroppedChunks: number
|
||||
pendingDroppedChars: number
|
||||
}>
|
||||
resetRendererDeliveryDebug: () => Promise<void>
|
||||
onData: (
|
||||
@@ -1200,6 +1226,15 @@ export type PreloadApi = {
|
||||
}) => void
|
||||
) => () => void
|
||||
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
|
||||
/** Out-of-band main→renderer signal that renderer-bound bytes were
|
||||
* dropped (hidden-delivery gate / pending cap); the pane restores from
|
||||
* the model snapshot. Never delivered in-band on pty:data. */
|
||||
onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void
|
||||
/** Batched derived side-effect facts for PTYs whose bytes transit local
|
||||
* main; see docs/reference/terminal-side-effect-authority.md. */
|
||||
onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void
|
||||
/** Title-only replay snapshot for (re)attach; attention facts never replay. */
|
||||
getSideEffectSnapshot: (id: string) => Promise<TerminalSideEffectBatch | null>
|
||||
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
|
||||
onSerializeBufferRequest: (
|
||||
callback: (data: {
|
||||
@@ -1894,6 +1929,10 @@ export type PreloadApi = {
|
||||
telemetryAcknowledgeBanner: () => Promise<void>
|
||||
settings: {
|
||||
get: () => Promise<GlobalSettings>
|
||||
/** Synchronous persisted-settings read for startup decisions that cannot
|
||||
* wait for async hydration (terminal side-effect authority). Blocking
|
||||
* IPC — call sparingly. */
|
||||
getSync: () => GlobalSettings | null
|
||||
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
|
||||
listFonts: () => Promise<string[]>
|
||||
previewGhosttyImport: () => Promise<GhosttyImportPreview>
|
||||
|
||||
@@ -20,5 +20,8 @@ const exposeStore = preloadEnv?.MODE === 'e2e' || isEnvFlagEnabled(preloadEnv?.V
|
||||
export const preloadE2EConfig = createE2EConfig({
|
||||
headless: process.env.ORCA_E2E_HEADLESS === '1',
|
||||
exposeStore,
|
||||
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null
|
||||
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null,
|
||||
// Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so
|
||||
// only a real positive override reaches the renderer parking policy.
|
||||
terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null
|
||||
})
|
||||
|
||||
@@ -52,6 +52,8 @@ import type {
|
||||
WorktreeDefaultTabsLaunch,
|
||||
WorktreeRemoteBranchConflictEvent
|
||||
} from '../shared/types'
|
||||
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
|
||||
import type { TerminalViewAttributes } from '../shared/terminal-view-attributes'
|
||||
import type {
|
||||
WarpThemeImportPreview,
|
||||
WarpThemeImportSource
|
||||
@@ -128,6 +130,7 @@ import type {
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import type {
|
||||
SpeechErrorEvent,
|
||||
SpeechLifecycleEvent,
|
||||
@@ -764,6 +767,10 @@ const api = {
|
||||
shellOverride?: string
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution
|
||||
terminalColorQueryReplies?: { foreground?: string; background?: 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
|
||||
@@ -824,6 +831,24 @@ const api = {
|
||||
setRendererPtyVisible: (id: string, visible: boolean): void => {
|
||||
ipcRenderer.send('pty:setRendererPtyVisible', { id, visible })
|
||||
},
|
||||
/** Hidden-delivery gate (Phase 4): hidden=true lets main DROP renderer
|
||||
* byte delivery after model ingestion; reveal restores from the model
|
||||
* snapshot. Fire-and-forget like setActiveRendererPty. */
|
||||
setHiddenRendererPty: (id: string, hidden: boolean): void => {
|
||||
ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden })
|
||||
},
|
||||
/** Delivery-interest signal: any renderer party that needs raw bytes
|
||||
* (dispatcher sidecars, eager pre-mount buffers) suppresses the
|
||||
* hidden-delivery gate for that PTY while registered. */
|
||||
setPtyDeliveryInterest: (id: string, interested: boolean): void => {
|
||||
ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested })
|
||||
},
|
||||
/** View-attribute bridge (Phase 5 slice 2): app-global composed terminal
|
||||
* appearance push that lets main's model responder answer OSC 4/10/11/12
|
||||
* and DSR ?996n for hidden-gated PTYs with renderer-true values. */
|
||||
publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => {
|
||||
ipcRenderer.send('pty:terminalViewAttributes', attributes)
|
||||
},
|
||||
|
||||
kill: (id: string, opts?: { keepHistory?: boolean }): Promise<void> =>
|
||||
ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }),
|
||||
@@ -841,6 +866,7 @@ const api = {
|
||||
rows: number
|
||||
cwd?: string | null
|
||||
seq?: number
|
||||
pendingDeliveryStartSeq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
alternateScreen?: boolean
|
||||
} | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
|
||||
@@ -859,6 +885,11 @@ const api = {
|
||||
peakRendererInFlightChars: number
|
||||
peakMaxRendererInFlightCharsByPty: number
|
||||
ackGatedFlushSkipCount: number
|
||||
hiddenDeliveryGatedPtyCount: number
|
||||
deliveryInterestPtyCount: number
|
||||
hiddenDeliveryDroppedChars: number
|
||||
hiddenDeliveryDroppedChunks: number
|
||||
pendingDroppedChars: number
|
||||
}> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'),
|
||||
|
||||
resetRendererDeliveryDebug: (): Promise<void> =>
|
||||
@@ -915,6 +946,32 @@ const api = {
|
||||
return () => ipcRenderer.removeListener('pty:replay', listener)
|
||||
},
|
||||
|
||||
/** Out-of-band signal that main dropped renderer-bound bytes for a PTY
|
||||
* (hidden-delivery gate / pending cap) — the pane must restore from the
|
||||
* model snapshot. Deliberately NOT on pty:data: an in-band marker is
|
||||
* ambiguous with chunks fully stripped by OSC-9999 cleaning. */
|
||||
onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) =>
|
||||
callback(event)
|
||||
ipcRenderer.on('pty:modelRestoreNeeded', listener)
|
||||
return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener)
|
||||
},
|
||||
|
||||
/** Batched derived side-effect facts (title/bell/agent transitions) for
|
||||
* PTYs whose bytes transit local main. Per-PTY in-order; deliberately not
|
||||
* synchronized with pty:data (terminal-side-effect-authority.md). */
|
||||
onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) =>
|
||||
callback(batch)
|
||||
ipcRenderer.on('pty:sideEffect', listener)
|
||||
return () => ipcRenderer.removeListener('pty:sideEffect', listener)
|
||||
},
|
||||
|
||||
/** Title-only replay snapshot applied on (re)attach — attention facts
|
||||
* (bells/completions) never replay. */
|
||||
getSideEffectSnapshot: (id: string): Promise<TerminalSideEffectBatch | null> =>
|
||||
ipcRenderer.invoke('pty:sideEffectSnapshot', { id }),
|
||||
|
||||
onExit: (callback: (data: { id: string; code: number }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) =>
|
||||
callback(data)
|
||||
@@ -1689,6 +1746,10 @@ const api = {
|
||||
settings: {
|
||||
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
|
||||
|
||||
// Why: blocking read for the few startup decisions (terminal side-effect
|
||||
// authority) that cannot wait for async hydration. Call sparingly.
|
||||
getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'),
|
||||
|
||||
set: (args: Record<string, unknown>): Promise<unknown> =>
|
||||
ipcRenderer.invoke('settings:set', args),
|
||||
|
||||
|
||||
@@ -127,6 +127,8 @@ import {
|
||||
} from './startup/startup-diagnostics'
|
||||
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'
|
||||
@@ -855,6 +857,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 timeRendererStartupStep('fetch-settings', () => 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()
|
||||
)
|
||||
// Why: load local + every configured runtime environment (not just the
|
||||
// active one) so a cold start that restored a remote workspace doesn't
|
||||
// hide local repos. The sidebar "All hosts" scope then shows them all.
|
||||
|
||||
@@ -65,6 +65,18 @@ import {
|
||||
} from './terminal/split-group-mount'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs'
|
||||
import {
|
||||
getTerminalWorktreeColdParkRecheckDelayMs,
|
||||
selectColdParkedTerminalWorktrees,
|
||||
type TerminalWorktreeColdParkCandidate
|
||||
} from './terminal-pane/terminal-hidden-view-parking'
|
||||
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
pruneParkedTerminalWatchers,
|
||||
shouldDeferParkedPtyExitTabClose,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-pane/terminal-parked-tab-watchers'
|
||||
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
|
||||
import { setWindowCloseRequestHandler } from './window-close-request-coordinator'
|
||||
import CodexRestartChip from './CodexRestartChip'
|
||||
@@ -124,6 +136,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
|
||||
|
||||
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
function haveSameWorktreeIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false
|
||||
}
|
||||
for (const id of left) {
|
||||
if (!right.has(id)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function findUnifiedTabByVisibleId(
|
||||
state: TerminalStoreSnapshot,
|
||||
worktreeId: string,
|
||||
@@ -206,6 +230,8 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
|
||||
function Terminal(): React.JSX.Element | null {
|
||||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
|
||||
const terminalWorktreeHiddenSinceRef = useRef(new Map<string, number>())
|
||||
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
|
||||
const workspaceSurfaces = useMemo(
|
||||
@@ -222,6 +248,8 @@ function Terminal(): React.JSX.Element | null {
|
||||
const renderedActiveWorktreeId = activeWorktreeId
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
|
||||
const activeTabId = useAppStore((s) => s.activeTabId)
|
||||
const createTab = useAppStore((s) => s.createTab)
|
||||
const closeTab = useAppStore((s) => s.closeTab)
|
||||
@@ -709,7 +737,11 @@ function Terminal(): React.JSX.Element | null {
|
||||
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
|
||||
// spawning when restoring a session with many saved worktree tabs.
|
||||
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
|
||||
const [, setBackgroundMountRevision] = useState(0)
|
||||
const [backgroundMountRevision, setBackgroundMountRevision] = useState(0)
|
||||
const [terminalParkingRevision, setTerminalParkingRevision] = useState(0)
|
||||
const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
useEffect(() => {
|
||||
const timers = measurableBackgroundWorktreeTimersRef.current
|
||||
const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current
|
||||
@@ -747,6 +779,122 @@ function Terminal(): React.JSX.Element | null {
|
||||
closeDialogDebounceTimers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = terminalWorktreeParkingTimersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: worktree-level cold-park policy — hiddenSince bookkeeping, parked-set
|
||||
// selection, and one recheck timer per still-pending deadline so React
|
||||
// re-renders exactly when the hysteresis elapses instead of polling.
|
||||
useEffect(() => {
|
||||
const parkingTimers = terminalWorktreeParkingTimersRef.current
|
||||
for (const timer of parkingTimers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
parkingTimers.clear()
|
||||
|
||||
const nowMs = Date.now()
|
||||
const overrides = getTerminalParkingPolicyOverrides()
|
||||
const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId))
|
||||
const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id))
|
||||
for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) {
|
||||
if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
const retentionCandidates: TerminalWorktreeColdParkCandidate[] = []
|
||||
for (const workspace of workspaceSurfaces) {
|
||||
const worktreeId = workspace.id
|
||||
if (!mountedWorktreeIdsRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
continue
|
||||
}
|
||||
const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId)
|
||||
const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId)
|
||||
if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
} else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs)
|
||||
}
|
||||
|
||||
retentionCandidates.push({
|
||||
worktreeId,
|
||||
terminalTabs: tabsByWorktree[worktreeId] ?? [],
|
||||
isVisible,
|
||||
shouldMeasureHiddenWorktree,
|
||||
hasActivityTerminalPortal,
|
||||
hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
|
||||
worktrees: retentionCandidates,
|
||||
pendingStartupByTabId,
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
// Why: a worktree with any tab the byte watchers cannot cover (no
|
||||
// capture, no layout snapshot, legacy leaf ids) must never park — it
|
||||
// would go silent for bells/titles/completions, the failure that sank
|
||||
// the first parking attempt.
|
||||
for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) {
|
||||
const tabs = tabsByWorktree[worktreeId] ?? []
|
||||
if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) {
|
||||
nextParkedTerminalWorktreeIds.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
setParkedTerminalWorktreeIds((current) =>
|
||||
haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds)
|
||||
? current
|
||||
: nextParkedTerminalWorktreeIds
|
||||
)
|
||||
|
||||
for (const candidate of retentionCandidates) {
|
||||
if (
|
||||
candidate.isVisible ||
|
||||
candidate.shouldMeasureHiddenWorktree ||
|
||||
candidate.hasActivityTerminalPortal ||
|
||||
nextParkedTerminalWorktreeIds.has(candidate.worktreeId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
hiddenSinceMs: candidate.hiddenSinceMs,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
if (delayMs !== null && delayMs > 0) {
|
||||
const worktreeId = candidate.worktreeId
|
||||
const timer = window.setTimeout(() => {
|
||||
parkingTimers.delete(worktreeId)
|
||||
setTerminalParkingRevision((revision) => revision + 1)
|
||||
}, delayMs)
|
||||
parkingTimers.set(worktreeId, timer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
activityTerminalPortals,
|
||||
backgroundMountRevision,
|
||||
pendingStartupByTabId,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalParkingRevision,
|
||||
workspaceSurfaces
|
||||
])
|
||||
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
|
||||
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
|
||||
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
|
||||
@@ -769,6 +917,59 @@ function Terminal(): React.JSX.Element | null {
|
||||
groupsByWorktree,
|
||||
activeGroupIdByWorktree
|
||||
)
|
||||
// Why: parked byte-watcher reconciliation for the legacy (non-split)
|
||||
// terminal host, which renders TerminalPanes directly. In split mode each
|
||||
// TerminalPaneOverlayLayer owns its worktree's watchers, so here we only
|
||||
// dispose worktrees that render no overlay layer (no layout / unmounted)
|
||||
// and prune watchers for deleted worktrees.
|
||||
useEffect(() => {
|
||||
pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id)))
|
||||
for (const workspace of workspaceSurfaces) {
|
||||
if (
|
||||
anyMountedWorktreeHasLayout &&
|
||||
mountedWorktreeIdsRef.current.has(workspace.id) &&
|
||||
getEffectiveLayoutForWorktree(workspace.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const tabs = tabsByWorktree[workspace.id] ?? []
|
||||
const parkedTabIds = new Set<string>()
|
||||
if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) {
|
||||
const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const parked =
|
||||
!isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id)
|
||||
if (parked) {
|
||||
for (const tab of tabs) {
|
||||
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
|
||||
worktreeId: workspace.id,
|
||||
tabId: tab.id
|
||||
})
|
||||
if (!activityTerminalPortal) {
|
||||
parkedTabIds.add(tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
syncParkedTerminalTabWatchers({ worktreeId: workspace.id, tabs, parkedTabIds })
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
activityTerminalPortals,
|
||||
anyMountedWorktreeHasLayout,
|
||||
backgroundMountRevision,
|
||||
getEffectiveLayoutForWorktree,
|
||||
parkedTerminalWorktreeIds,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
workspaceSurfaces
|
||||
])
|
||||
// Why: symmetric with useTerminalTabColdParking's unmount cleanup — when
|
||||
// the terminal host unmounts, no reconciliation effect will run again, so
|
||||
// dispose every remaining parked watcher here (overlay-layer children have
|
||||
// already disposed theirs by the time this parent cleanup runs).
|
||||
useEffect(() => () => pruneParkedTerminalWatchers(new Set()), [])
|
||||
// Auto-create first tab when worktree activates
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
@@ -1068,6 +1269,12 @@ function Terminal(): React.JSX.Element | null {
|
||||
if (consumeSuppressedPtyExit(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: a parked multi-leaf tab has no PaneManager to promote split
|
||||
// siblings, so closing the tab here would kill them; the reveal
|
||||
// remount handles dead PTYs per leaf instead.
|
||||
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
handleCloseTab(tabId)
|
||||
},
|
||||
[consumeSuppressedPtyExit, handleCloseTab]
|
||||
@@ -1800,6 +2007,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIds.has(workspace.id)
|
||||
return (
|
||||
<WorktreeSplitSurface
|
||||
key={`tab-groups-${workspace.id}`}
|
||||
@@ -1809,6 +2020,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
focusedGroupId={activeGroupIdByWorktree[workspace.id]}
|
||||
isVisible={isVisible}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
/>
|
||||
)
|
||||
@@ -1860,6 +2072,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIds.has(workspace.id)
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
@@ -1881,6 +2097,12 @@ function Terminal(): React.JSX.Element | null {
|
||||
const isActivityPortalTab = activityTerminalPortal !== null
|
||||
const isActiveTerminalTab =
|
||||
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
|
||||
// Why: parking is exactly the unmount path tab-group
|
||||
// moves use — transports detach, the PTY survives, and
|
||||
// the parked byte watcher owns side effects until reveal.
|
||||
if (shouldColdParkTerminalPanes && !isActivityPortalTab) {
|
||||
return null
|
||||
}
|
||||
const terminalPane = (
|
||||
<TerminalPane
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
@@ -2087,6 +2309,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
focusedGroupId,
|
||||
isVisible,
|
||||
shouldMeasureHiddenWorktree,
|
||||
shouldColdParkTerminalPanes,
|
||||
activityTerminalPortals
|
||||
}: {
|
||||
worktreeId: string
|
||||
@@ -2095,6 +2318,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
focusedGroupId?: string
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
shouldColdParkTerminalPanes: boolean
|
||||
activityTerminalPortals: ActivityTerminalPortalTarget[]
|
||||
}): React.JSX.Element {
|
||||
const browserPageIds = useAppStore(
|
||||
@@ -2134,6 +2358,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
worktreeId={worktreeId}
|
||||
worktreePath={worktreePath}
|
||||
isWorktreeActive={isVisible}
|
||||
coldParkTerminalPanes={shouldColdParkTerminalPanes}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
/>
|
||||
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import TerminalPane from './TerminalPane'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
|
||||
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
|
||||
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
|
||||
|
||||
type TerminalOverlayAssignment = {
|
||||
unifiedTabId: string
|
||||
@@ -237,6 +239,12 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
if (consumeSuppressedPtyExit(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: a parked multi-leaf tab has no PaneManager to promote split
|
||||
// siblings, so closing the tab here would kill them; the reveal
|
||||
// remount handles dead PTYs per leaf instead.
|
||||
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTab(terminalTabId)
|
||||
leaveWorktreeIfEmpty()
|
||||
}}
|
||||
@@ -278,11 +286,15 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes = false,
|
||||
shouldMeasureHiddenWorktree = false,
|
||||
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS
|
||||
}: {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
isWorktreeActive: boolean
|
||||
coldParkTerminalPanes?: boolean
|
||||
shouldMeasureHiddenWorktree?: boolean
|
||||
activityTerminalPortals?: ActivityTerminalPortalTarget[]
|
||||
}): React.JSX.Element | null {
|
||||
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
|
||||
@@ -346,6 +358,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
return entries
|
||||
}, [groupActiveTabById, unifiedTabs])
|
||||
|
||||
const parkedTerminalTabIds = useTerminalTabColdParking({
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes,
|
||||
shouldMeasureHiddenWorktree,
|
||||
activityTerminalPortals
|
||||
})
|
||||
|
||||
if (!worktreePath) {
|
||||
return null
|
||||
}
|
||||
@@ -360,6 +382,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
worktreeId,
|
||||
tabId: terminalTab.id
|
||||
})
|
||||
// Why: parking is exactly the unmount path tab-group moves use —
|
||||
// transports detach, the PTY and tab model survive, and the parked
|
||||
// byte watcher takes over side effects until reveal remounts here.
|
||||
if (parkedTerminalTabIds.has(terminalTab.id)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<TerminalOverlaySlot
|
||||
key={terminalTab.id}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Agent-task-complete notification policy predicates and timing constants.
|
||||
*
|
||||
* Why extracted from pty-connection.ts: the parked byte watcher and the
|
||||
* pty:sideEffect facts handler apply the exact live-path semantics without a
|
||||
* pane, and policy must not drift between the three consumers
|
||||
* (docs/reference/terminal-side-effect-authority.md). This module is
|
||||
* deliberately dependency-light — no pane/xterm imports — so pane-less
|
||||
* consumers can use it.
|
||||
*/
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
/** Delay before BEL/completion OS notifications so the richer
|
||||
* agent-task-complete notification can win a same-burst BEL race. */
|
||||
export const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
|
||||
/** Hard cap on waiting for hook detail before dispatching a completion. */
|
||||
export const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500
|
||||
export const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
|
||||
|
||||
type NotificationSettingsState = {
|
||||
settings: Pick<GlobalSettings, 'notifications' | 'experimentalTerminalAttention'> | null
|
||||
}
|
||||
|
||||
export function isAgentTaskCompleteOsNotificationEnabledFromState(
|
||||
state: NotificationSettingsState
|
||||
): boolean {
|
||||
const notifications = state.settings?.notifications
|
||||
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
|
||||
}
|
||||
|
||||
export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean {
|
||||
return state.settings?.experimentalTerminalAttention === true
|
||||
}
|
||||
|
||||
/** Completion tracking runs when either consumer (OS notification or the
|
||||
* experimental terminal-attention marker) is enabled. */
|
||||
export function isAgentTaskCompleteTrackingEnabledFromState(
|
||||
state: NotificationSettingsState
|
||||
): boolean {
|
||||
return (
|
||||
isAgentTaskCompleteOsNotificationEnabledFromState(state) ||
|
||||
isTerminalAttentionEnabledFromState(state)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean {
|
||||
return Boolean(
|
||||
entry &&
|
||||
Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS &&
|
||||
(entry.lastAssistantMessage || entry.toolName || entry.toolInput)
|
||||
)
|
||||
}
|
||||
|
||||
export function canDispatchAgentNotificationAfterGrace(
|
||||
entry: AgentStatusEntry | undefined,
|
||||
options: { allowDoneDetailAfterGrace?: boolean } = {}
|
||||
): boolean {
|
||||
// Why: hook-backed goal/mission loops can report `done` between milestones.
|
||||
// User-input states may notify as soon as detail arrives, but `done` waits
|
||||
// for the max quiet window so resumed work can cancel the pending banner.
|
||||
return (
|
||||
hasAgentNotificationDetail(entry) &&
|
||||
(entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts'
|
||||
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
|
||||
|
||||
const PTY_ID = 'pty-parked-1'
|
||||
const TAB_ID = 'tab-1'
|
||||
const WORKTREE_ID = 'repo-1::/tmp/wt-1'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
|
||||
const PANE_ID = 1
|
||||
// Mirrors PARKED_NOTIFICATION_GRACE_MS / AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS.
|
||||
const NOTIFICATION_GRACE_MS = 250
|
||||
|
||||
// Real agent-detection titles: braille spinner classifies as working,
|
||||
// the "✳ " Claude prefix as idle, and both as Claude agents.
|
||||
const WORKING_TITLE_OSC = '\x1b]0;⠋ Build feature\x07'
|
||||
const IDLE_TITLE = '✳ Build feature'
|
||||
const IDLE_TITLE_OSC = `\x1b]0;${IDLE_TITLE}\x07`
|
||||
|
||||
type MockStoreState = {
|
||||
settings: {
|
||||
theme?: 'system' | 'dark' | 'light'
|
||||
promptCacheTimerEnabled?: boolean
|
||||
experimentalTerminalAttention?: boolean
|
||||
terminalMainSideEffectAuthority?: boolean
|
||||
terminalHiddenDeliveryGate?: boolean
|
||||
notifications?: { enabled?: boolean; agentTaskComplete?: boolean }
|
||||
} | null
|
||||
setRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
updateTabTitle: ReturnType<typeof vi.fn>
|
||||
markWorktreeUnread: ReturnType<typeof vi.fn>
|
||||
markTerminalTabUnread: ReturnType<typeof vi.fn>
|
||||
markTerminalPaneUnread: ReturnType<typeof vi.fn>
|
||||
setCacheTimerStartedAt: ReturnType<typeof vi.fn>
|
||||
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const dispatchTerminalNotification = vi.fn()
|
||||
let mockStoreState: MockStoreState
|
||||
|
||||
vi.mock('./use-notification-dispatch', () => ({
|
||||
dispatchTerminalNotification
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/terminal-theme', () => ({
|
||||
getSystemPrefersDark: () => true
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockStoreState(): MockStoreState {
|
||||
return {
|
||||
// Why: terminalMainSideEffectAuthority false pins the legacy byte-parser
|
||||
// mode this suite was written for; the authority-on fact-consumer mode is
|
||||
// covered by the dedicated describe block below.
|
||||
settings: {
|
||||
theme: 'system',
|
||||
promptCacheTimerEnabled: true,
|
||||
experimentalTerminalAttention: false,
|
||||
terminalMainSideEffectAuthority: false,
|
||||
notifications: { enabled: true, agentTaskComplete: true }
|
||||
},
|
||||
setRuntimePaneTitle: vi.fn(),
|
||||
clearRuntimePaneTitle: vi.fn(),
|
||||
updateTabTitle: vi.fn(),
|
||||
markWorktreeUnread: vi.fn(),
|
||||
markTerminalTabUnread: vi.fn(),
|
||||
markTerminalPaneUnread: vi.fn(),
|
||||
setCacheTimerStartedAt: vi.fn(),
|
||||
observeTerminalGitHubPullRequestLink: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('startParkedTerminalByteWatcher', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
let onData: ((payload: { id: string; data: string }) => void) | null = null
|
||||
|
||||
function emit(data: string): void {
|
||||
onData?.({ id: PTY_ID, data })
|
||||
}
|
||||
|
||||
// The output processor defers title/bell side effects onto a 0ms drain timer.
|
||||
function flushSideEffects(): void {
|
||||
vi.advanceTimersByTime(0)
|
||||
}
|
||||
|
||||
async function startWatcher(
|
||||
overrides: Partial<ParkedTerminalByteWatcherOptions> = {}
|
||||
): Promise<{ dispose: () => void; sendInput: ReturnType<typeof vi.fn> }> {
|
||||
const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher')
|
||||
const sendInput = vi.fn()
|
||||
const dispose = startParkedTerminalByteWatcher({
|
||||
ptyId: PTY_ID,
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneId: PANE_ID,
|
||||
sendInput,
|
||||
...overrides
|
||||
})
|
||||
return { dispose, sendInput }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
dispatchTerminalNotification.mockClear()
|
||||
onData = null
|
||||
mockStoreState = createMockStoreState()
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
pty: {
|
||||
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
|
||||
onData = callback
|
||||
return () => {}
|
||||
}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
ackData: vi.fn()
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards every OSC title in order to the pane and tab title store actions', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`)
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([
|
||||
[TAB_ID, PANE_ID, '⠋ Build feature'],
|
||||
[TAB_ID, PANE_ID, IDLE_TITLE]
|
||||
])
|
||||
expect(mockStoreState.updateTabTitle.mock.calls).toEqual([
|
||||
[TAB_ID, '⠋ Build feature'],
|
||||
[TAB_ID, IDLE_TITLE]
|
||||
])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('drops the bare cursor-agent native title before it reaches the store', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x1b]0;Cursor Agent\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not drive the tab title when drivesTabTitle is false', async () => {
|
||||
const { dispose } = await startWatcher({ drivesTabTitle: false })
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('build finished\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
|
||||
expect(mockStoreState.markTerminalPaneUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'terminal-bell',
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks the exact pane unread when experimental terminal attention is enabled', async () => {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
experimentalTerminalAttention: true
|
||||
}
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not treat an OSC-terminator BEL as a bell, even split across chunks', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x1b]0;par')
|
||||
emit('tial title\x07')
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
|
||||
PANE_KEY,
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('suppresses the completion OS notification when only terminal attention is on', async () => {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
experimentalTerminalAttention: true,
|
||||
notifications: { enabled: true, agentTaskComplete: false }
|
||||
}
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY,
|
||||
suppressOsNotification: true
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('skips completion dispatch when tracking is fully disabled, keeping the cache timer', async () => {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
experimentalTerminalAttention: false,
|
||||
notifications: { enabled: false }
|
||||
}
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
|
||||
PANE_KEY,
|
||||
expect.any(Number)
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('lets a same-burst completion supersede the pending bell OS notification', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
emit(`${IDLE_TITLE_OSC}\x07`)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8)
|
||||
|
||||
// The bell still marks unread immediately; only the OS notification yields.
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
expect.objectContaining({ source: 'agent-task-complete' })
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('cancels the pending completion and clears the cache timer when working resumes', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
emit(WORKING_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => {
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
emit('\x1b[?20')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
|
||||
emit('31h')
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
// theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol.
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
|
||||
emit('\x1b[?2031l')
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('stops answering DECSET 2031 after dispose', async () => {
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
dispose()
|
||||
emit('\x1b[?2031h')
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('observes GitHub PR links across chunk boundaries', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('PR: https://github.com/orca-dev/orca/pull/42')
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
|
||||
|
||||
emit('1\r\ndone')
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
expect.objectContaining({
|
||||
url: 'https://github.com/orca-dev/orca/pull/421',
|
||||
number: 421,
|
||||
slug: { owner: 'orca-dev', repo: 'orca' }
|
||||
})
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires completion when seeded with a working title and the agent goes idle while parked', async () => {
|
||||
// Why: the pane was working at park time; the watcher's fresh tracker
|
||||
// must be seeded or this working→idle transition can never fire.
|
||||
const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' })
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not fire completion for an idle title without a seed or observed transition', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('clears the watcher-written runtime title slot on dispose', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
|
||||
dispose()
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
|
||||
})
|
||||
|
||||
it('leaves the runtime title slot alone on dispose when it never wrote one', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('plain output with no titles\r\n')
|
||||
flushSideEffects()
|
||||
|
||||
dispose()
|
||||
expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shutdown dispose cancels the armed completion timer and silences the final flush', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
|
||||
// Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds.
|
||||
dispose()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
// The teardown flush that main emits after pty.kill must be a no-op.
|
||||
emit('final teardown flush\x07')
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispose unregisters the sidecar and cancels the pending bell notification', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x07')
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
|
||||
|
||||
dispose()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
emit('\x07')
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Idempotent: a second dispose must not throw or clobber another watcher.
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('disposes the previous watcher when a new one starts for the same PTY', async () => {
|
||||
await startWatcher({ paneId: 1 })
|
||||
const second = await startWatcher({ paneId: 2 })
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE)
|
||||
second.dispose()
|
||||
})
|
||||
|
||||
// ─── Main side-effect authority (terminal-side-effect-authority.md) ────
|
||||
//
|
||||
// With the kill switch on, the watcher must not register byte parsers —
|
||||
// main is the single byte parser and the watcher's policy block consumes
|
||||
// pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031
|
||||
// reply (query authority never moves to main); PR links arrive as facts.
|
||||
describe('with main side-effect authority on', () => {
|
||||
function enableMainAuthority(): void {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
terminalMainSideEffectAuthority: true
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchFacts(
|
||||
facts: TerminalSideEffectFact[],
|
||||
options: { seq?: number; replay?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const handler = await import('./terminal-side-effect-facts-handler')
|
||||
handler._dispatchTerminalSideEffectBatchForTest({
|
||||
ptyId: PTY_ID,
|
||||
seq: options.seq ?? 0,
|
||||
...(options.replay ? { replay: true } : {}),
|
||||
facts
|
||||
})
|
||||
}
|
||||
|
||||
/** Feed chunks the way OrcaRuntimeService.onPtyData does: OSC 9999 strip,
|
||||
* shared title tracker, one fact batch per chunk — the main half of the
|
||||
* migration-safety parity check. */
|
||||
async function emitViaMainTrackerFacts(chunks: string[]): Promise<void> {
|
||||
const { createAgentStatusOscProcessor } = await import('../../../../shared/agent-status-osc')
|
||||
const { createTerminalTitleTracker } =
|
||||
await import('../../../../shared/terminal-output-side-effects')
|
||||
const handler = await import('./terminal-side-effect-facts-handler')
|
||||
const processStatusChunk = createAgentStatusOscProcessor()
|
||||
let pending: TerminalSideEffectFact[] = []
|
||||
const tracker = createTerminalTitleTracker({
|
||||
onTitle: (normalizedTitle, rawTitle) =>
|
||||
pending.push({ kind: 'title', normalizedTitle, rawTitle }),
|
||||
onAgentBecameWorking: () => pending.push({ kind: 'agent-working' }),
|
||||
onAgentBecameIdle: (title) => pending.push({ kind: 'agent-idle', title }),
|
||||
onAgentExited: () => pending.push({ kind: 'agent-exited' }),
|
||||
onBell: () => pending.push({ kind: 'bell' })
|
||||
})
|
||||
let seq = 0
|
||||
for (const chunk of chunks) {
|
||||
seq += chunk.length
|
||||
tracker.handleChunk(processStatusChunk(chunk).cleanData)
|
||||
if (pending.length > 0) {
|
||||
handler._dispatchTerminalSideEffectBatchForTest({ ptyId: PTY_ID, seq, facts: pending })
|
||||
pending = []
|
||||
}
|
||||
}
|
||||
tracker.dispose()
|
||||
}
|
||||
|
||||
type RecordedCall = [string, ...unknown[]]
|
||||
|
||||
/** Wrap the policy-visible store actions so byte mode and fact mode can be
|
||||
* compared as one ordered outcome sequence. Timestamps are masked. */
|
||||
function recordPolicyOutcomes(): RecordedCall[] {
|
||||
const calls: RecordedCall[] = []
|
||||
mockStoreState.setRuntimePaneTitle.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['setRuntimePaneTitle', ...args])
|
||||
})
|
||||
mockStoreState.updateTabTitle.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['updateTabTitle', ...args])
|
||||
})
|
||||
mockStoreState.markWorktreeUnread.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['markWorktreeUnread', ...args])
|
||||
})
|
||||
mockStoreState.markTerminalTabUnread.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['markTerminalTabUnread', ...args])
|
||||
})
|
||||
mockStoreState.markTerminalPaneUnread.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['markTerminalPaneUnread', ...args])
|
||||
})
|
||||
mockStoreState.setCacheTimerStartedAt.mockImplementation((key: unknown, at: unknown) => {
|
||||
calls.push(['setCacheTimerStartedAt', key, typeof at === 'number' ? '<ts>' : at])
|
||||
})
|
||||
dispatchTerminalNotification.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['dispatchTerminalNotification', ...args])
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
it('does not consume bytes: a byte BEL produces no unread or notification', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('build finished\x07')
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('applies bell facts with the byte-mode policy: unread now, OS notification delayed', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([{ kind: 'bell' }])
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'terminal-bell',
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires the cache timer and completion from working→idle facts', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
|
||||
{ kind: 'agent-working' }
|
||||
])
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
|
||||
{ kind: 'agent-idle', title: IDLE_TITLE }
|
||||
])
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
|
||||
PANE_KEY,
|
||||
expect.any(Number)
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('clears state without completion attention for stale-derived idle facts', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
|
||||
{ kind: 'agent-working' }
|
||||
])
|
||||
// Main's unthrottled 3s stale-title rewrite: titles/cache clear, but a
|
||||
// merely-paused agent must not earn a task-complete notification.
|
||||
await dispatchFacts([
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Build feature',
|
||||
rawTitle: 'Build feature',
|
||||
staleWorkingTitleClear: true
|
||||
},
|
||||
{ kind: 'agent-idle', title: 'Build feature', staleWorkingTitleClear: true }
|
||||
])
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenLastCalledWith(
|
||||
TAB_ID,
|
||||
PANE_ID,
|
||||
'Build feature'
|
||||
)
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('replay batches restore the title only — attention facts never replay', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
|
||||
{ kind: 'bell' },
|
||||
{ kind: 'agent-idle', title: IDLE_TITLE }
|
||||
],
|
||||
{ replay: true }
|
||||
)
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => {
|
||||
// Why: with the hidden-delivery gate on (default), parked PTY bytes are
|
||||
// dropped in main — the fact is the only 2031 signal, and the byte
|
||||
// sidecar must NOT exist (its registration would re-enable delivery).
|
||||
enableMainAuthority()
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
emit('\x1b[?2031h')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
|
||||
await dispatchFacts([{ kind: '2031-subscribe' }])
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
|
||||
// Why: pr-link facts arrive on the channel; byte-scanning here too
|
||||
// would observe every link twice.
|
||||
emit('PR: https://github.com/orca-dev/orca/pull/42\r\n')
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks the PTY hidden for delivery on start and clears it on dispose', async () => {
|
||||
enableMainAuthority()
|
||||
const setHiddenRendererPty = vi.fn()
|
||||
;(
|
||||
window as unknown as { api: { pty: Record<string, unknown> } }
|
||||
).api.pty.setHiddenRendererPty = setHiddenRendererPty
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true)
|
||||
|
||||
dispose()
|
||||
// Why: the unhide must land before reveal re-registers pane handlers —
|
||||
// the watcher registry disposes watchers before the remount effect runs.
|
||||
expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false)
|
||||
})
|
||||
|
||||
it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => {
|
||||
enableMainAuthority()
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
terminalHiddenDeliveryGate: false
|
||||
} as MockStoreState['settings']
|
||||
const setHiddenRendererPty = vi.fn()
|
||||
;(
|
||||
window as unknown as { api: { pty: Record<string, unknown> } }
|
||||
).api.pty.setHiddenRendererPty = setHiddenRendererPty
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
// Gate off — bytes keep flowing, so the split-chunk byte scan answers.
|
||||
emit('\x1b[?20')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
emit('31h')
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
|
||||
// Why: a 2031-subscribe fact must not double-fire the reply in byte
|
||||
// mode — exactly one responder owns the answer at any time.
|
||||
await dispatchFacts([{ kind: '2031-subscribe' }])
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(setHiddenRendererPty).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('observes PR links from pr-link facts with worktree attribution', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
const link = {
|
||||
url: 'https://github.com/orca-dev/orca/pull/421',
|
||||
slug: { owner: 'orca-dev', repo: 'orca' },
|
||||
number: 421
|
||||
}
|
||||
await dispatchFacts([{ kind: 'pr-link', link }])
|
||||
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
link
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('dispose unregisters the fact consumer and clears a written title slot', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }])
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
|
||||
dispose()
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
|
||||
|
||||
await dispatchFacts([{ kind: 'bell' }])
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The key migration-safety check: the same bytes produce the identical
|
||||
// ordered store outcome whether the watcher parses them directly (kill
|
||||
// switch off) or consumes main-derived facts over the channel.
|
||||
it('produces identical store outcomes via the channel as the byte parser did', async () => {
|
||||
const fixtureChunks = [WORKING_TITLE_OSC, 'agent response body\r\n', `${IDLE_TITLE_OSC}\x07`]
|
||||
|
||||
// Pass 1: legacy byte-parser mode.
|
||||
const byteModeCalls = recordPolicyOutcomes()
|
||||
{
|
||||
const { dispose } = await startWatcher()
|
||||
for (const chunk of fixtureChunks) {
|
||||
emit(chunk)
|
||||
flushSideEffects()
|
||||
}
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
dispose()
|
||||
}
|
||||
|
||||
// Pass 2: fresh modules/store, authority on, facts derived by the
|
||||
// shared main tracker from the same bytes.
|
||||
vi.resetModules()
|
||||
mockStoreState = createMockStoreState()
|
||||
dispatchTerminalNotification.mockReset()
|
||||
const factModeCalls = recordPolicyOutcomes()
|
||||
{
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
await emitViaMainTrackerFacts(fixtureChunks)
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
dispose()
|
||||
}
|
||||
|
||||
expect(byteModeCalls.length).toBeGreaterThan(0)
|
||||
expect(factModeCalls).toEqual(byteModeCalls)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Parked terminal side-effect watcher.
|
||||
*
|
||||
* Why: parking unmounts the TerminalPane subtree, which tears down the pane's
|
||||
* side-effect consumers — the parked tab's only source of bell, title,
|
||||
* agent-completion, and PR-link policy. (Losing them is the gap that sank the
|
||||
* first parking attempt.) Under main side-effect authority the watcher is
|
||||
* purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the
|
||||
* kill switch off it registers the legacy byte parsers on the dispatcher
|
||||
* sidecar channel. DECSET 2031 ownership follows the hidden-delivery gate:
|
||||
* gate ON answers from main's '2031-subscribe' fact (no parked bytes exist),
|
||||
* gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts).
|
||||
* Either way the reply is sent from the renderer — query authority never
|
||||
* moves to main. See docs/reference/terminal-hidden-view-parking.md and
|
||||
* docs/reference/terminal-side-effect-authority.md.
|
||||
*/
|
||||
import { isClaudeAgent } from '../../../../shared/agent-detection'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
mode2031SequenceFor,
|
||||
resolveTerminalColorSchemeMode
|
||||
} from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import {
|
||||
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
|
||||
isAgentTaskCompleteOsNotificationEnabledFromState,
|
||||
isAgentTaskCompleteTrackingEnabledFromState
|
||||
} from './agent-task-complete-policy'
|
||||
import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder'
|
||||
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
|
||||
import { createPtyOutputProcessor } from './pty-transport'
|
||||
import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate'
|
||||
import {
|
||||
isMainTerminalSideEffectAuthorityForPty,
|
||||
registerTerminalSideEffectFactConsumer
|
||||
} from './terminal-side-effect-facts-handler'
|
||||
import { dispatchTerminalNotification } from './use-notification-dispatch'
|
||||
|
||||
// Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts.
|
||||
// The parked path must keep the live path's BEL-vs-completion race window so
|
||||
// notification behavior is identical whether a tab is parked or mounted.
|
||||
const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS
|
||||
|
||||
type StoreState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean {
|
||||
return isAgentTaskCompleteOsNotificationEnabledFromState(state)
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean {
|
||||
return isAgentTaskCompleteTrackingEnabledFromState(state)
|
||||
}
|
||||
|
||||
export type ParkedTerminalByteWatcherOptions = {
|
||||
ptyId: string
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
/** Stable terminal-layout leaf UUID. Combined with tabId into the paneKey
|
||||
* used for cache-timer, unread, and notification attribution. */
|
||||
leafId: string
|
||||
/** PaneManager pane id the unmounted pane used. Runtime pane titles are
|
||||
* keyed by it, so the watcher must write the slot the live path wrote —
|
||||
* a different id would leave a stale (possibly "working") title behind. */
|
||||
paneId: number
|
||||
/** Whether this PTY's pane was the tab's active split pane. Mirrors the
|
||||
* live path, where only the focused split drives the tab title. */
|
||||
drivesTabTitle?: boolean
|
||||
/** The pane's last known runtime title at park time. Seeds the agent
|
||||
* tracker so an agent that was working when the pane unmounted still
|
||||
* fires its completion when it goes idle while parked. */
|
||||
initialTitle?: string
|
||||
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
|
||||
sendInput: (data: string) => void
|
||||
}
|
||||
|
||||
const parkedWatcherDisposersByPtyId = new Map<string, () => void>()
|
||||
|
||||
export function startParkedTerminalByteWatcher(
|
||||
options: ParkedTerminalByteWatcherOptions
|
||||
): () => void {
|
||||
const { ptyId, tabId, worktreeId, paneId, sendInput } = options
|
||||
const drivesTabTitle = options.drivesTabTitle ?? true
|
||||
const paneKey = makePaneKey(tabId, options.leafId)
|
||||
|
||||
// Why: one watcher per PTY. A stale watcher from a previous park cycle would
|
||||
// double-fire bell/completion side effects for the same bytes.
|
||||
parkedWatcherDisposersByPtyId.get(ptyId)?.()
|
||||
|
||||
let disposed = false
|
||||
let pendingBellNotification = false
|
||||
// Why: a watcher-written runtime title (especially into a negative fallback
|
||||
// slot) has no live pane to overwrite it after reveal; a stale 'working'
|
||||
// entry would pin worktree status forever. Track writes so dispose can
|
||||
// clear exactly the slot this watcher touched.
|
||||
let wroteRuntimeTitleSlot = false
|
||||
let bellNotificationTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearBellNotificationTimer = (): void => {
|
||||
if (bellNotificationTimer !== null) {
|
||||
clearTimeout(bellNotificationTimer)
|
||||
bellNotificationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearAgentTaskCompleteTimer = (): void => {
|
||||
if (agentTaskCompleteTimer !== null) {
|
||||
clearTimeout(agentTaskCompleteTimer)
|
||||
agentTaskCompleteTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: like the live path, a BEL OS notification only yields when the
|
||||
// pending completion would itself produce an OS notification.
|
||||
const hasPendingAgentTaskCompleteNotification = (): boolean =>
|
||||
agentTaskCompleteTimer !== null &&
|
||||
isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
|
||||
|
||||
const scheduleTerminalBellNotification = (): void => {
|
||||
if (bellNotificationTimer !== null) {
|
||||
return
|
||||
}
|
||||
bellNotificationTimer = setTimeout(() => {
|
||||
bellNotificationTimer = null
|
||||
if (disposed) {
|
||||
pendingBellNotification = false
|
||||
return
|
||||
}
|
||||
if (hasPendingAgentTaskCompleteNotification()) {
|
||||
return
|
||||
}
|
||||
pendingBellNotification = false
|
||||
dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey })
|
||||
}, PARKED_NOTIFICATION_GRACE_MS)
|
||||
}
|
||||
|
||||
// Why: one policy block for both consumption modes — byte parsing (kill
|
||||
// switch off) and pty:sideEffect facts (main authority on). The semantics
|
||||
// must be identical or flipping the switch changes notification behavior.
|
||||
const sideEffectCallbacks = {
|
||||
onTitleChange: (title: string): void => {
|
||||
const state = useAppStore.getState()
|
||||
wroteRuntimeTitleSlot = true
|
||||
state.setRuntimePaneTitle(tabId, paneId, title)
|
||||
if (drivesTabTitle) {
|
||||
state.updateTabTitle(tabId, title)
|
||||
}
|
||||
},
|
||||
onBell: (): void => {
|
||||
const state = useAppStore.getState()
|
||||
state.markWorktreeUnread(worktreeId)
|
||||
state.markTerminalTabUnread(tabId)
|
||||
if (state.settings?.experimentalTerminalAttention === true) {
|
||||
state.markTerminalPaneUnread(paneKey)
|
||||
}
|
||||
// Why: agent CLIs often emit BEL in the same completion burst as their
|
||||
// working→idle title change. Delay only the OS notification so the
|
||||
// richer agent-task-complete notification can win (live-path parity).
|
||||
pendingBellNotification = true
|
||||
if (!hasPendingAgentTaskCompleteNotification()) {
|
||||
scheduleTerminalBellNotification()
|
||||
}
|
||||
},
|
||||
onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => {
|
||||
// Why: stale-derived idles come from main's unthrottled 3s timer, not
|
||||
// observed bytes — clear session state, never schedule the completion
|
||||
// notification a merely-paused agent did not earn (live-path parity).
|
||||
if (meta?.staleWorkingTitleClear) {
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
// Why: mirrors pty-connection — null settings means "not hydrated yet";
|
||||
// a spurious timestamp is harmless while a dropped one loses the timer.
|
||||
if (
|
||||
isClaudeAgent(title) &&
|
||||
(state.settings === null || state.settings.promptCacheTimerEnabled)
|
||||
) {
|
||||
state.setCacheTimerStartedAt(paneKey, Date.now())
|
||||
}
|
||||
if (!isAgentTaskCompleteTrackingEnabled(state)) {
|
||||
return
|
||||
}
|
||||
clearAgentTaskCompleteTimer()
|
||||
agentTaskCompleteTimer = setTimeout(() => {
|
||||
agentTaskCompleteTimer = null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// Why: the completion supersedes a concurrent BEL so each completion
|
||||
// burst yields exactly one OS notification, same as the live path.
|
||||
pendingBellNotification = false
|
||||
clearBellNotificationTimer()
|
||||
dispatchTerminalNotification(worktreeId, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: title,
|
||||
paneKey,
|
||||
...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
|
||||
? {}
|
||||
: { suppressOsNotification: true })
|
||||
})
|
||||
}, PARKED_NOTIFICATION_GRACE_MS)
|
||||
},
|
||||
onAgentBecameWorking: (): void => {
|
||||
// Why: a new API call refreshes the prompt-cache TTL, so clear any
|
||||
// running countdown; it restarts when the agent next becomes idle.
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
clearAgentTaskCompleteTimer()
|
||||
if (pendingBellNotification) {
|
||||
scheduleTerminalBellNotification()
|
||||
}
|
||||
},
|
||||
onAgentExited: (): void => {
|
||||
// Why: title reverting to a plain shell means the agent session ended;
|
||||
// a stale countdown must not survive in the sidebar while parked.
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: parking eligibility excludes remote-runtime and SSH PTYs, so every
|
||||
// watched PTY's bytes transit local main — when the authority switch is on,
|
||||
// the watcher must NOT register byte parsers (the fact consumer below is
|
||||
// the single policy consumer; double registration would double-fire bells).
|
||||
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: useAppStore.getState().settings,
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
// Why: under the Phase-4 gate a parked PTY needs no renderer bytes at all —
|
||||
// facts carry side effects and the reveal remount restores from the model
|
||||
// snapshot. Decided once at watcher start: it picks which 2031 responder
|
||||
// (byte sidecar vs fact reply) exists, so it must never flip per chunk.
|
||||
const hiddenDeliveryGateActive =
|
||||
mainSideEffectAuthority &&
|
||||
isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings)
|
||||
|
||||
const sendMode2031Reply = (): void => {
|
||||
const settings = useAppStore.getState().settings
|
||||
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
|
||||
}
|
||||
|
||||
// Why (byte-parser mode only): reuse the transport's output processor so
|
||||
// the parked path keeps the exact live-path parsing semantics — all-titles
|
||||
// ordering, normalization, the cursor-agent native-title drop, the
|
||||
// OSC-aware stateful bell detector, and the working/idle agent tracker.
|
||||
// initialAgentTitle: an agent already working at park time must still
|
||||
// produce a working→idle transition; main's continuous tracker covers this
|
||||
// in fact-consumer mode.
|
||||
const processor = mainSideEffectAuthority
|
||||
? null
|
||||
: createPtyOutputProcessor({
|
||||
...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}),
|
||||
...sideEffectCallbacks
|
||||
})
|
||||
// Why (byte-parser mode only): with main authority, pr-link facts arrive on
|
||||
// the channel below; byte-scanning too would observe every link twice.
|
||||
const observeTerminalGitHubPRLink = mainSideEffectAuthority
|
||||
? null
|
||||
: createTerminalGitHubPRLinkDetector()
|
||||
const unregisterFactConsumer = mainSideEffectAuthority
|
||||
? registerTerminalSideEffectFactConsumer({
|
||||
ptyId,
|
||||
// Why: no title snapshot on park — the pane's runtime title slot is
|
||||
// already current at park time, exactly like the byte-parser mode.
|
||||
callbacks: {
|
||||
...sideEffectCallbacks,
|
||||
onPrLink: (link) =>
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
|
||||
// Why (gate mode only): bytes never arrive while gated, so the 2031
|
||||
// subscribe arrives as a main-tracker fact instead of a byte scan.
|
||||
// The reply is still sent from here — query authority stays with
|
||||
// the view/watcher (model/view contract invariant 6).
|
||||
...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {})
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
// Why: no xterm exists while parked, so nothing answers a DECSET 2031
|
||||
// subscription. With the hidden-delivery gate OFF the byte responder is the
|
||||
// parked path's only byte consumer under main authority. With the gate ON it
|
||||
// must NOT register: its subscribeToPtyData sidecar doubles as a
|
||||
// delivery-interest signal that would force-feed bytes to the gated PTY —
|
||||
// the fact callback above replaces the byte scan.
|
||||
const stopMode2031Responder = hiddenDeliveryGateActive
|
||||
? null
|
||||
: startParkedTerminalMode2031Responder({ ptyId, sendInput })
|
||||
|
||||
// Why: parked tabs are the canonical hidden view — mark the PTY gated so
|
||||
// main stops renderer byte delivery; dispose clears the bit before the
|
||||
// reveal remount re-registers pane handlers (existing dispose ordering).
|
||||
if (hiddenDeliveryGateActive) {
|
||||
;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, true)
|
||||
}
|
||||
|
||||
// Why (byte-parser mode only): with main authority the watcher consumes
|
||||
// pty:sideEffect facts exclusively and registers NO byte parsers here —
|
||||
// title/bell/agent parsing and the PR-link scan would double-fire policy.
|
||||
const unsubscribeByteParsers =
|
||||
processor === null
|
||||
? null
|
||||
: subscribeToPtyData(ptyId, (data) => {
|
||||
// Why: empty pane callbacks — the watcher wants only the parser
|
||||
// side effects; there is no xterm to deliver bytes to.
|
||||
processor.processData(data, {})
|
||||
if (observeTerminalGitHubPRLink) {
|
||||
for (const link of observeTerminalGitHubPRLink(data)) {
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const dispose = (): void => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
// Why: unhide BEFORE the reveal remount registers pane handlers — main
|
||||
// resumes delivery and (if bytes were dropped) emits the restore marker
|
||||
// the remounted pane's restore machinery consumes.
|
||||
if (hiddenDeliveryGateActive) {
|
||||
;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, false)
|
||||
}
|
||||
stopMode2031Responder?.()
|
||||
unsubscribeByteParsers?.()
|
||||
unregisterFactConsumer?.()
|
||||
// Why: cancels the deferred side-effect drain, stale-title timer, and
|
||||
// tracker/bell-detector state so the watcher cannot fire after the
|
||||
// revealed pane's live parsers take over.
|
||||
processor?.clearAccumulatedState()
|
||||
clearBellNotificationTimer()
|
||||
clearAgentTaskCompleteTimer()
|
||||
pendingBellNotification = false
|
||||
// Why: the store merge never deletes title slots, so a watcher-written
|
||||
// entry would strand after reveal (the revealing pane re-registers under
|
||||
// its own pane id) and could pin worktree status 'working'. The revealed
|
||||
// pane repopulates its slot via its own title flow.
|
||||
if (wroteRuntimeTitleSlot) {
|
||||
wroteRuntimeTitleSlot = false
|
||||
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
|
||||
}
|
||||
if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) {
|
||||
parkedWatcherDisposersByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
parkedWatcherDisposersByPtyId.set(ptyId, dispose)
|
||||
return dispose
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DECSET 2031 color-scheme responder for parked terminals (byte-scan mode).
|
||||
*
|
||||
* Why a dedicated byte sidecar: no xterm exists while a tab is parked, so
|
||||
* nothing answers a TUI's mode-2031 theme subscription. Query authority stays
|
||||
* with the view/watcher (model/view contract invariant 6), so this reply can
|
||||
* never move to main. Phase 4: this subscribeToPtyData registration doubles
|
||||
* as a delivery-interest signal, so it is only used while the hidden-delivery
|
||||
* gate is OFF — gated parked PTYs answer from the main tracker's
|
||||
* '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts).
|
||||
*
|
||||
* Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes
|
||||
* answer 2031 from xterm once the background queue drains, but a PARKED tab
|
||||
* has no xterm in any switch-off mode, and the '2031-subscribe' fact is only
|
||||
* consumed while the gate is ON — this sidecar stays the only answerer here.
|
||||
*/
|
||||
import {
|
||||
mode2031SequenceFor,
|
||||
resolveTerminalColorSchemeMode,
|
||||
scanMode2031Sequences
|
||||
} from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
|
||||
|
||||
export type ParkedTerminalMode2031ResponderOptions = {
|
||||
ptyId: string
|
||||
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
|
||||
sendInput: (data: string) => void
|
||||
}
|
||||
|
||||
export function startParkedTerminalMode2031Responder(
|
||||
options: ParkedTerminalMode2031ResponderOptions
|
||||
): () => void {
|
||||
const { ptyId, sendInput } = options
|
||||
// Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan
|
||||
// carries a bounded tail between chunks so split sequences still match.
|
||||
let scanTail = ''
|
||||
return subscribeToPtyData(ptyId, (data) => {
|
||||
const scan = scanMode2031Sequences(scanTail, data)
|
||||
scanTail = scan.tail
|
||||
if (!scan.subscribe) {
|
||||
return
|
||||
}
|
||||
// Why: reply with the resolved theme so TUIs that subscribe while parked
|
||||
// still learn it before the pane is ever revealed.
|
||||
const settings = useAppStore.getState().settings
|
||||
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
|
||||
})
|
||||
}
|
||||
@@ -69,4 +69,10 @@ export type PtyConnectionDeps = {
|
||||
setCacheTimerStartedAt: (key: string, ts: number | null) => void
|
||||
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
|
||||
clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void
|
||||
/** Records a DECSET 2031 subscription answered from main's
|
||||
* '2031-subscribe' fact, mirroring the xterm CSI handler's registry write
|
||||
* (paneMode2031 + last replied theme) so later theme flips push CSI 997.
|
||||
* The reply itself is sent by the fact handler — query authority stays
|
||||
* with the view (model/view contract invariant 6). */
|
||||
recordPaneMode2031Subscription?: (paneId: number, repliedMode: 'dark' | 'light') => void
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,7 +78,10 @@ import {
|
||||
writeTerminalOutput
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity'
|
||||
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
|
||||
import {
|
||||
isLocalNativeWindowsConpty,
|
||||
resolveWindowsShellOverride
|
||||
} from '@/lib/pane-manager/windows-pty-compatibility'
|
||||
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
|
||||
import {
|
||||
captureTerminalWriteScrollIntent,
|
||||
@@ -119,10 +122,10 @@ import {
|
||||
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
|
||||
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
|
||||
import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier'
|
||||
import { createCommandCodeOutputStatusDetector } from './command-code-output-status'
|
||||
import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status'
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
import { getEagerPtyBufferHandle } from './pty-dispatcher'
|
||||
import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector'
|
||||
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
|
||||
import {
|
||||
CONPTY_DA1_RESPONSE,
|
||||
@@ -130,6 +133,7 @@ import {
|
||||
installTerminalCapabilityReplyHandlers,
|
||||
sendTerminalOscColorQueryReplies
|
||||
} from './terminal-capability-replies'
|
||||
import { registerPtyModelRestoreNeededHandler } from './pty-model-restore-channel'
|
||||
import {
|
||||
cancelScheduledHiddenOutputRestore,
|
||||
scheduleHiddenOutputRestore
|
||||
@@ -166,14 +170,23 @@ import {
|
||||
releaseAgentStartupDeliveryAttempt
|
||||
} from '@/lib/agent-startup-delayed-delivery'
|
||||
import { isExpectedAgentProcess } from '../../../../shared/agent-process-recognition'
|
||||
import {
|
||||
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
|
||||
AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS,
|
||||
canDispatchAgentNotificationAfterGrace,
|
||||
isAgentTaskCompleteOsNotificationEnabledFromState,
|
||||
isAgentTaskCompleteTrackingEnabledFromState
|
||||
} from './agent-task-complete-policy'
|
||||
import {
|
||||
isMainTerminalSideEffectAuthorityForPty,
|
||||
registerTerminalSideEffectFactConsumer
|
||||
} from './terminal-side-effect-facts-handler'
|
||||
import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate'
|
||||
|
||||
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
|
||||
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
|
||||
const REMOTE_PTY_ID_PREFIX = 'remote:'
|
||||
const PTY_CONNECT_DIAG_LIMIT = 200
|
||||
const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
|
||||
const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500
|
||||
const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
|
||||
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
|
||||
const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500
|
||||
const STARTUP_DRAFT_PASTE_QUIET_MS = 1500
|
||||
@@ -333,6 +346,9 @@ type E2eTerminalHiddenSnapshotOverride = {
|
||||
|
||||
const e2eTerminalHiddenSnapshotOverrides = new Map<string, E2eTerminalHiddenSnapshotOverride>()
|
||||
|
||||
// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes
|
||||
// either never reach the renderer (delivery gate) or ride the background
|
||||
// scheduler queue. Only the mode-2031 fact-reply counter still has a producer.
|
||||
type E2eTerminalPtyOutputDebugSnapshot = {
|
||||
hiddenRendererSkipCount: number
|
||||
hiddenRendererSkippedChars: number
|
||||
@@ -652,40 +668,19 @@ type PanePtyBinding = IDisposable & {
|
||||
noteVisibilityResume: () => void
|
||||
reconcileIfSessionDead: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
|
||||
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
|
||||
/** True when the hidden-delivery gate structurally manages the pane's
|
||||
* current PTY. The lifecycle's xterm CSI ?2031h observer consults this to
|
||||
* stay silent — main's '2031-subscribe' fact is the sole responder for
|
||||
* gate-managed PTYs. */
|
||||
isHiddenDeliveryGateManagedPty: () => boolean
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteNotificationEnabled(): boolean {
|
||||
return isAgentTaskCompleteNotificationEnabledFromState(useAppStore.getState())
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteNotificationEnabledFromState(
|
||||
state: ReturnType<typeof useAppStore.getState>
|
||||
): boolean {
|
||||
const notifications = state.settings?.notifications
|
||||
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
|
||||
}
|
||||
|
||||
function isTerminalAttentionEnabledFromState(
|
||||
state: ReturnType<typeof useAppStore.getState>
|
||||
): boolean {
|
||||
return state.settings?.experimentalTerminalAttention === true
|
||||
return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState())
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteTrackingEnabled(): boolean {
|
||||
const state = useAppStore.getState()
|
||||
return (
|
||||
isAgentTaskCompleteNotificationEnabledFromState(state) ||
|
||||
isTerminalAttentionEnabledFromState(state)
|
||||
)
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteTrackingEnabledFromState(
|
||||
state: ReturnType<typeof useAppStore.getState>
|
||||
): boolean {
|
||||
return (
|
||||
isAgentTaskCompleteNotificationEnabledFromState(state) ||
|
||||
isTerminalAttentionEnabledFromState(state)
|
||||
)
|
||||
return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState())
|
||||
}
|
||||
|
||||
const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>()
|
||||
@@ -695,7 +690,7 @@ let agentTaskCompleteTrackingSettingsSnapshot: string | null = null
|
||||
function getAgentTaskCompleteTrackingSettingsSnapshot(
|
||||
state: ReturnType<typeof useAppStore.getState>
|
||||
): string {
|
||||
return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteNotificationEnabledFromState(state)}`
|
||||
return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}`
|
||||
}
|
||||
|
||||
function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void {
|
||||
@@ -729,27 +724,6 @@ function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () =>
|
||||
}
|
||||
}
|
||||
|
||||
function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean {
|
||||
return Boolean(
|
||||
entry &&
|
||||
Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS &&
|
||||
(entry.lastAssistantMessage || entry.toolName || entry.toolInput)
|
||||
)
|
||||
}
|
||||
|
||||
function canDispatchAgentNotificationAfterGrace(
|
||||
entry: AgentStatusEntry | undefined,
|
||||
options: { allowDoneDetailAfterGrace?: boolean } = {}
|
||||
): boolean {
|
||||
// Why: hook-backed goal/mission loops can report `done` between milestones.
|
||||
// User-input states may notify as soon as detail arrives, but `done` waits
|
||||
// for the max quiet window so resumed work can cancel the pending banner.
|
||||
return (
|
||||
hasAgentNotificationDetail(entry) &&
|
||||
(entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true)
|
||||
)
|
||||
}
|
||||
|
||||
function recordPtyConnectDiagnostic(message: string): void {
|
||||
if (!e2eConfig.exposeStore) {
|
||||
return
|
||||
@@ -1056,6 +1030,11 @@ export function connectPanePty(
|
||||
// window still drains on the fast path instead of the 1s coalesce fallback.
|
||||
let synchronizedForegroundFrameInteractive = false
|
||||
let suppressSnapshotReplayPtyResize = false
|
||||
// Why: hidden-delivery gate sync is wired up alongside the deferred PTY
|
||||
// output plumbing inside the connect frame; lifecycle hooks (visibility
|
||||
// flips, exit, dispose) run before/after it exists, so start with no-ops.
|
||||
let syncHiddenRendererPtyDelivery: () => void = () => {}
|
||||
let releaseHiddenRendererPtyDelivery: () => void = () => {}
|
||||
// Why: idle callbacks are registered before the deferred PTY output plumbing
|
||||
// exists. Start with the shared scheduler, then switch to the PTY writer
|
||||
// below so hidden-tab resets keep backlog-recovery callbacks and byte order.
|
||||
@@ -1519,36 +1498,43 @@ export function connectPanePty(
|
||||
}
|
||||
return pendingWrite.then(() => interruptInference.flushPending())
|
||||
}
|
||||
const commandLifecycle = createTerminalCommandLifecycle({
|
||||
onCommandFinished: () => {
|
||||
// Why: the finished command may have moved HEAD or the index (e.g.
|
||||
// `git checkout`); nudge git UI now instead of waiting for a poll.
|
||||
dispatchTerminalCommandFinishedEvent(deps.worktreeId)
|
||||
const state = useAppStore.getState()
|
||||
const entry = state.agentStatusByPaneKey[cacheKey]
|
||||
const inferenceResult = flushPendingInterruptInference()
|
||||
if (inferenceResult === true) {
|
||||
// Why: OSC 133 D means the foreground shell command exited. If an
|
||||
// interrupt was inferred first, drop only when the current interrupted
|
||||
// row is still the same turn; otherwise a killed OpenCode CLI leaves a
|
||||
// stale "interrupted" row even though the process is gone.
|
||||
dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true })
|
||||
return
|
||||
}
|
||||
if (inferenceResult instanceof Promise) {
|
||||
void inferenceResult.then((applied) => {
|
||||
dropCommandFinishedStatusIfSameTurn(entry, {
|
||||
allowInferredInterrupt: applied === true
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: OSC 133 D marks the foreground shell command exiting. Remove the
|
||||
// row without retaining a done snapshot; this section represents a live
|
||||
// agent process, and the shell prompt means that process is gone.
|
||||
dropCommandFinishedStatusIfSameTurn(entry)
|
||||
// Why: one command-finished policy whether the signal arrives as bytes
|
||||
// (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact —
|
||||
// routing both through this handler keeps the drop/interrupt semantics
|
||||
// identical across authority modes.
|
||||
const handleCommandFinished = (_bestEffortExitCode: number | null): void => {
|
||||
// Why: the finished command may have moved HEAD or the index (e.g.
|
||||
// `git checkout`); nudge git UI now instead of waiting for a poll.
|
||||
dispatchTerminalCommandFinishedEvent(deps.worktreeId)
|
||||
const state = useAppStore.getState()
|
||||
const entry = state.agentStatusByPaneKey[cacheKey]
|
||||
const inferenceResult = flushPendingInterruptInference()
|
||||
if (inferenceResult === true) {
|
||||
// Why: OSC 133 D means the foreground shell command exited. If an
|
||||
// interrupt was inferred first, drop only when the current interrupted
|
||||
// row is still the same turn; otherwise a killed OpenCode CLI leaves a
|
||||
// stale "interrupted" row even though the process is gone.
|
||||
dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true })
|
||||
return
|
||||
}
|
||||
if (inferenceResult instanceof Promise) {
|
||||
void inferenceResult.then((applied) => {
|
||||
dropCommandFinishedStatusIfSameTurn(entry, {
|
||||
allowInferredInterrupt: applied === true
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: OSC 133 D marks the foreground shell command exiting. Remove the
|
||||
// row without retaining a done snapshot; this section represents a live
|
||||
// agent process, and the shell prompt means that process is gone.
|
||||
dropCommandFinishedStatusIfSameTurn(entry)
|
||||
}
|
||||
const commandLifecycle = createTerminalCommandLifecycle({
|
||||
onCommandFinished: handleCommandFinished
|
||||
})
|
||||
// Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect —
|
||||
// it stays attached in every authority mode.
|
||||
commandLifecycle.attachXtermConsumer(pane.terminal)
|
||||
const onTerminalKeyDown = (event: KeyboardEvent): void => {
|
||||
if (isPlainEscapeKeyEvent(event)) {
|
||||
@@ -1611,6 +1597,47 @@ export function connectPanePty(
|
||||
// Why: bind time lets async liveness reconcile ignore a request started
|
||||
// before this PTY bound (newborn race). Null disables the guard (fail-safe).
|
||||
let activePanePtyBindingBoundAt: number | null = null
|
||||
|
||||
// Why: with main side-effect authority on, the pane's title/bell/agent
|
||||
// policy callbacks consume pty:sideEffect facts instead of transport byte
|
||||
// parsers (which stay unregistered) — same policy code, single consumer.
|
||||
// restoreTitleOnRegister replaces the eager-replay title restore: main's
|
||||
// title-only snapshot carries the no-attention-replay rule.
|
||||
let unregisterSideEffectFactConsumer: (() => void) | null = null
|
||||
const registerSideEffectFactConsumerForPty = (ptyId: string): void => {
|
||||
if (!mainSideEffectAuthority || disposed) {
|
||||
return
|
||||
}
|
||||
unregisterSideEffectFactConsumer?.()
|
||||
unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({
|
||||
ptyId,
|
||||
callbacks: {
|
||||
onTitleChange,
|
||||
onBell,
|
||||
onAgentBecameIdle,
|
||||
onAgentBecameWorking,
|
||||
onAgentExited,
|
||||
onCommandFinished: handleCommandFinished,
|
||||
onPrLink: (link) =>
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link),
|
||||
// Why: the Command Code settle policy stays here — the done settle
|
||||
// timer must consult the live store row (which hook events and
|
||||
// renderer seeds also write), so main only emits scrape facts.
|
||||
onCommandCodeWorking: seedCommandCodeOutputWorkingStatus,
|
||||
onCommandCodeDone: scheduleCommandCodeOutputDoneStatus,
|
||||
// Why: gated hidden panes never see the subscribe bytes; the fact
|
||||
// replaces the byte scan (and the old post-latch subscribe drop).
|
||||
...(hiddenDeliveryGateActive
|
||||
? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact }
|
||||
: {})
|
||||
},
|
||||
restoreTitleOnRegister: true
|
||||
})
|
||||
}
|
||||
const dropSideEffectFactConsumer = (): void => {
|
||||
unregisterSideEffectFactConsumer?.()
|
||||
unregisterSideEffectFactConsumer = null
|
||||
}
|
||||
const clearPanePtyFitBinding = (): void => {
|
||||
// Why: fit bindings live in a module-level map, so pane teardown must
|
||||
// clear them explicitly instead of relying on DOM removal.
|
||||
@@ -1712,6 +1739,10 @@ export function connectPanePty(
|
||||
}
|
||||
handledExitPtyId = ptyId
|
||||
agentCompletionCoordinator.dispose()
|
||||
dropSideEffectFactConsumer()
|
||||
// Why: main clears gate state on PTY exit too; this only resets the
|
||||
// pane-local marker so a reused pane cannot skip re-marking a new PTY.
|
||||
releaseHiddenRendererPtyDelivery()
|
||||
clearPanePtyFitBinding()
|
||||
const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId)
|
||||
if (!isSuppressedExit) {
|
||||
@@ -1782,7 +1813,11 @@ export function connectPanePty(
|
||||
let hasConsideredInitialCacheTimerSeed = false
|
||||
let allowInitialIdleCacheSeed = false
|
||||
|
||||
const onTitleChange = (title: string, rawTitle: string): void => {
|
||||
const onTitleChange = (
|
||||
title: string,
|
||||
rawTitle: string,
|
||||
meta?: { staleWorkingTitleClear?: boolean }
|
||||
): void => {
|
||||
const paneTitle = normalizeCompatibleAgentTitleForOwner(title, getAuthoritativePaneAgent())
|
||||
if (
|
||||
shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, {
|
||||
@@ -1795,7 +1830,11 @@ export function connectPanePty(
|
||||
}
|
||||
manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle))
|
||||
deps.setRuntimePaneTitle(deps.tabId, pane.id, paneTitle)
|
||||
if (syncAgentTaskCompleteTrackingEnabled()) {
|
||||
// Why: a stale-derived cleared title comes from main's unthrottled 3s
|
||||
// timer, not agent output. It must update the visible title but never
|
||||
// feed completion tracking — observeTitle would classify the cleared
|
||||
// title as idle and mint a task-complete for a merely-paused agent.
|
||||
if (!meta?.staleWorkingTitleClear && syncAgentTaskCompleteTrackingEnabled()) {
|
||||
agentCompletionCoordinator.observeTitle(rawTitle)
|
||||
}
|
||||
// Why: only the focused pane should drive the tab title — otherwise two
|
||||
@@ -1914,11 +1953,6 @@ export function connectPanePty(
|
||||
}, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
|
||||
}
|
||||
|
||||
const commandCodeOutputStatusDetector = createCommandCodeOutputStatusDetector({
|
||||
startupCommand: paneStartup?.command,
|
||||
onWorking: seedCommandCodeOutputWorkingStatus,
|
||||
onDone: scheduleCommandCodeOutputDoneStatus
|
||||
})
|
||||
const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector()
|
||||
const reportPanePtyVisibility = (ptyId: string | null | undefined, visible: boolean): void => {
|
||||
if (!ptyId || isRemoteRuntimePtyId(ptyId)) {
|
||||
@@ -1941,6 +1975,8 @@ export function connectPanePty(
|
||||
// Why: record bind time on the spawn/attach chokepoint so the reconcile
|
||||
// guard knows this binding is newer than any pre-bind snapshot.
|
||||
activePanePtyBindingBoundAt = performance.now()
|
||||
registerSideEffectFactConsumerForPty(ptyId)
|
||||
syncHiddenRendererPtyDelivery()
|
||||
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
|
||||
const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? []
|
||||
if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) {
|
||||
@@ -2155,7 +2191,16 @@ export function connectPanePty(
|
||||
// findable after the OS banner is gone. Double-firing with a concurrent BEL
|
||||
// is handled by delaying the BEL OS notification below; main still keeps a
|
||||
// 5 s per-worktree dedupe as the final guard.
|
||||
const onAgentBecameIdle = (title: string): void => {
|
||||
const onAgentBecameIdle = (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => {
|
||||
// Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not
|
||||
// observed bytes — a merely-paused agent (>3s silent mid-task, window
|
||||
// minimized) would otherwise mint a false task-complete OS notification
|
||||
// that renderer timer throttling previously damped. Clear session-tied
|
||||
// state only; never schedule completion attention from it.
|
||||
if (meta?.staleWorkingTitleClear) {
|
||||
deps.setCacheTimerStartedAt(cacheKey, null)
|
||||
return
|
||||
}
|
||||
// Why: only start the prompt-cache countdown for Claude agents — other
|
||||
// agents have different (or no) prompt-caching semantics and showing a
|
||||
// timer for them would be misleading.
|
||||
@@ -2243,7 +2288,10 @@ export function connectPanePty(
|
||||
userAgent: navigator.userAgent,
|
||||
connectionId,
|
||||
cwd: deps.cwd,
|
||||
shellOverride,
|
||||
// Why: main folds the global Windows shell into its spawn classification
|
||||
// (pty.ts effectiveShellOverride); fold it here too so both sides treat
|
||||
// a global-WSL default identically (terminal-query-authority.md ConPTY).
|
||||
shellOverride: resolveWindowsShellOverride(shellOverride, state.settings?.terminalWindowsShell),
|
||||
executionHostId
|
||||
})
|
||||
if (isNativeWindowsConpty) {
|
||||
@@ -2287,6 +2335,37 @@ export function connectPanePty(
|
||||
})
|
||||
: undefined
|
||||
const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null
|
||||
// Why: when main holds side-effect authority for this PTY's bytes, the
|
||||
// transport must NOT register title/bell/agent byte parsers — the
|
||||
// pty:sideEffect fact consumer below is the single policy consumer.
|
||||
// Decided once at transport creation so a fact never has two consumers.
|
||||
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: state.settings,
|
||||
runtimeEnvironmentId
|
||||
})
|
||||
// Why: Phase-4 hidden-delivery gate — only meaningful under main authority
|
||||
// (renderer byte parsers need bytes otherwise). Decided once at pane
|
||||
// creation: it picks the mode-2031 answer path (fact reply vs byte scan),
|
||||
// which must have exactly one owner.
|
||||
const hiddenDeliveryGateActive =
|
||||
mainSideEffectAuthority && isRendererHiddenPtyDeliveryGateEnabled(state.settings)
|
||||
// Why: structural per-PTY gate predicate (authority on + gate on + bytes
|
||||
// transit local main, which implies snapshot-backed). Shared by the hidden
|
||||
// mark sync and mode-2031 reply ownership so reply ownership can never
|
||||
// disagree with what main may drop — and never depends on the racy hidden
|
||||
// mark (a fact can outrun the pty:data task that sets it).
|
||||
const isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string =>
|
||||
hiddenDeliveryGateActive && Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId)
|
||||
// Why (byte-parser mode only): with main authority the Command Code scrape
|
||||
// runs in main's per-PTY tracker and arrives as command-code facts; running
|
||||
// the byte detector too would double-drive the seed/settle policy above.
|
||||
const commandCodeOutputStatusDetector = mainSideEffectAuthority
|
||||
? null
|
||||
: createCommandCodeOutputStatusDetector({
|
||||
startupCommand: paneStartup?.command,
|
||||
onWorking: seedCommandCodeOutputWorkingStatus,
|
||||
onDone: scheduleCommandCodeOutputDoneStatus
|
||||
})
|
||||
const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste'
|
||||
const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0
|
||||
let lastTerminalInputAt = Number.NEGATIVE_INFINITY
|
||||
@@ -2329,12 +2408,16 @@ export function connectPanePty(
|
||||
...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}),
|
||||
...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}),
|
||||
onPtyExit: onExit,
|
||||
onTitleChange,
|
||||
onPtySpawn,
|
||||
onBell,
|
||||
onAgentBecameIdle,
|
||||
onAgentBecameWorking,
|
||||
onAgentExited,
|
||||
...(mainSideEffectAuthority
|
||||
? {}
|
||||
: {
|
||||
onTitleChange,
|
||||
onBell,
|
||||
onAgentBecameIdle,
|
||||
onAgentBecameWorking,
|
||||
onAgentExited
|
||||
}),
|
||||
// Why: local IPC terminals are now model-owned in main: OrcaRuntimeService
|
||||
// parses OSC 9999 before renderer delivery and forwards through the hook
|
||||
// server with local/SSH identity. Remote-runtime streams do not pass through
|
||||
@@ -2404,6 +2487,28 @@ export function connectPanePty(
|
||||
const transport = runtimeEnvironmentId
|
||||
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
|
||||
: createIpcPtyTransport(transportOptions)
|
||||
// Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031
|
||||
// responder — visible, hidden, marked or not. Conditioning the reply on the
|
||||
// hidden mark double-fired (mark set + bytes delivered live via interest →
|
||||
// fact AND xterm both replied) or dropped the reply entirely (fact outran
|
||||
// the pty:data task that set the mark). The xterm-side CSI reply and the
|
||||
// skipped-byte scan are disabled for these panes (same structural
|
||||
// predicate), so exactly one reply goes out.
|
||||
const handleHiddenMode2031SubscribeFact = (): void => {
|
||||
if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) {
|
||||
return
|
||||
}
|
||||
const mode = resolveTerminalColorSchemeMode(
|
||||
useAppStore.getState().settings,
|
||||
getSystemPrefersDark()
|
||||
)
|
||||
transport.sendInput(mode2031SequenceFor(mode))
|
||||
// Why: register the subscription exactly like the xterm CSI handler
|
||||
// would — without the registry entry, later theme flips never push the
|
||||
// CSI 997 update and the TUI keeps a stale theme after reveal.
|
||||
deps.recordPaneMode2031Subscription?.(pane.id, mode)
|
||||
recordHiddenMode2031Reply()
|
||||
}
|
||||
deps.paneTransportsRef.current.set(pane.id, transport)
|
||||
const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({
|
||||
terminal: pane.terminal,
|
||||
@@ -3277,6 +3382,7 @@ export function connectPanePty(
|
||||
...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}),
|
||||
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
|
||||
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
|
||||
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
callbacks: {
|
||||
onData: dataCallback,
|
||||
onReplayData: replayDataCallback,
|
||||
@@ -3633,6 +3739,54 @@ export function connectPanePty(
|
||||
// can reuse the pane object for a different session before visibility.
|
||||
let hiddenOutputRestorePtyId: string | null = null
|
||||
let hiddenOutputRestoreGeneration = 0
|
||||
// Why: after a snapshot restore, main can still drain ACK-backlog chunks
|
||||
// whose bytes the snapshot already covers — writing them unguarded
|
||||
// duplicates visible output. Track the restored baseline seq (per PTY)
|
||||
// and the expected next chunk start so dataCallback can drop/slice
|
||||
// overlaps and detect seq gaps from main-side pending-cap trims whose
|
||||
// one-shot marker was already consumed.
|
||||
let restoredSnapshotBaselineSeq: number | null = null
|
||||
let restoredSnapshotBaselinePtyId: string | null = null
|
||||
let restoredSnapshotExpectedStartSeq: number | null = null
|
||||
// Why: main samples its pending renderer-delivery queue with the snapshot.
|
||||
// Chunks at or below this seq can never be backlog duplicates (delivery is
|
||||
// once-and-in-order), so the dedupe window is (windowStart, baseline].
|
||||
let restoredSnapshotDeliveryWindowStartSeq: number | null = null
|
||||
|
||||
function setRestoredSnapshotBaseline(
|
||||
ptyId: string,
|
||||
snapshot: { seq?: number; pendingDeliveryStartSeq?: number }
|
||||
): void {
|
||||
if (typeof snapshot.seq !== 'number') {
|
||||
clearRestoredSnapshotBaseline()
|
||||
return
|
||||
}
|
||||
const windowStartSeq =
|
||||
typeof snapshot.pendingDeliveryStartSeq === 'number'
|
||||
? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq)
|
||||
: null
|
||||
if (windowStartSeq !== null && windowStartSeq >= snapshot.seq) {
|
||||
// Why: main reported an empty undelivered backlog — no chunk at or
|
||||
// below the snapshot seq can ever arrive again (delivery is once and
|
||||
// in order) and a future pending-cap trim re-arms the out-of-band
|
||||
// marker. Arming a baseline anyway would misread live chunks from a
|
||||
// foreign seq domain (restarted counter / synthetic injection) as
|
||||
// duplicates or trim gaps and silently drop genuinely-new output.
|
||||
clearRestoredSnapshotBaseline()
|
||||
return
|
||||
}
|
||||
restoredSnapshotBaselineSeq = snapshot.seq
|
||||
restoredSnapshotBaselinePtyId = ptyId
|
||||
restoredSnapshotExpectedStartSeq = snapshot.seq
|
||||
restoredSnapshotDeliveryWindowStartSeq = windowStartSeq
|
||||
}
|
||||
|
||||
function clearRestoredSnapshotBaseline(): void {
|
||||
restoredSnapshotBaselineSeq = null
|
||||
restoredSnapshotBaselinePtyId = null
|
||||
restoredSnapshotExpectedStartSeq = null
|
||||
restoredSnapshotDeliveryWindowStartSeq = null
|
||||
}
|
||||
let foregroundImmediateBudgetChars = 0
|
||||
let foregroundImmediateBudgetWindowStart = 0
|
||||
let foregroundRewriteChunkEndedWithCarriageReturn = false
|
||||
@@ -3678,26 +3832,115 @@ export function connectPanePty(
|
||||
return transport.serializeBuffer(opts)
|
||||
}
|
||||
|
||||
function respondToSkippedMode2031Subscribe(data: string): void {
|
||||
const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data)
|
||||
hiddenMode2031ScanTail = scan.tail
|
||||
if (scan.finalState === 'unsubscribed') {
|
||||
deps.paneMode2031Ref.current.delete(pane.id)
|
||||
deps.paneLastThemeModeRef.current.delete(pane.id)
|
||||
}
|
||||
if (scan.finalState !== 'subscribed') {
|
||||
// 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 spawn-time DA1 loss). Declaring hidden on the
|
||||
// spawn IPC lets main mark the PTY before its first byte — including
|
||||
// codex spawns: the model responder answers their startup probes from
|
||||
// byte zero now that the 10s renderer query window is gone.
|
||||
// Remote-runtime PTYs are never gate-markable (no local main transit).
|
||||
function shouldDeclareHiddenAtSpawn(): boolean {
|
||||
return (
|
||||
hiddenDeliveryGateActive &&
|
||||
!runtimeEnvironmentId &&
|
||||
!disposed &&
|
||||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current)
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// restores from the snapshot. The marked id is tracked locally so PTY
|
||||
// changes (reattach/restart) can never leave a stale id gated.
|
||||
let hiddenDeliverySyncedPtyId: string | null = null
|
||||
let hiddenDeliveryMarkedHidden = false
|
||||
let modelRestoreSubscribedPtyId: string | null = null
|
||||
let unregisterModelRestoreNeeded: (() => void) | null = null
|
||||
|
||||
function sendHiddenRendererPtyDelivery(ptyId: string, hidden: boolean): void {
|
||||
window.api.pty.setHiddenRendererPty?.(ptyId, hidden)
|
||||
}
|
||||
|
||||
// Why: main reports dropped renderer-bound bytes (hidden gate / pending
|
||||
// cap) out-of-band — routed per PTY by pty-model-restore-channel.ts.
|
||||
function handleModelRestoreNeededMarker(): void {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const settings = useAppStore.getState().settings
|
||||
const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
|
||||
// Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer
|
||||
// mode 2031 out-of-band so TUIs still render the snapshot with the same
|
||||
// theme-dependent styling they would have used in a visible pane.
|
||||
deps.paneMode2031Ref.current.set(pane.id, true)
|
||||
transport.sendInput(mode2031SequenceFor(mode))
|
||||
deps.paneLastThemeModeRef.current.set(pane.id, mode)
|
||||
recordHiddenMode2031Reply()
|
||||
// Why: dropped bytes invalidate every cross-chunk carry — a partial
|
||||
// OSC-9999 prefix spanning the gap would corrupt the next live chunk.
|
||||
transport.resetCrossChunkParserState?.()
|
||||
// Why: parity with the hidden skip path — a marker landing while a
|
||||
// restore is in flight means the in-flight snapshot may predate the
|
||||
// drop, so a fresh snapshot must follow. Captured BEFORE the mark: on a
|
||||
// visible pane the mark starts a restore synchronously, which must not
|
||||
// count as "already in flight".
|
||||
const restoreWasInFlight = hiddenOutputRestoreInFlight !== null
|
||||
markHiddenOutputRestoreNeeded()
|
||||
if (restoreWasInFlight) {
|
||||
hiddenOutputRestoreFreshSnapshotNeeded = true
|
||||
}
|
||||
}
|
||||
|
||||
function syncModelRestoreNeededSubscription(ptyId: string | null): void {
|
||||
if (modelRestoreSubscribedPtyId === ptyId) {
|
||||
return
|
||||
}
|
||||
unregisterModelRestoreNeeded?.()
|
||||
unregisterModelRestoreNeeded = null
|
||||
modelRestoreSubscribedPtyId = ptyId
|
||||
// Why: markers exist only for PTYs whose bytes transit local main;
|
||||
// remote-runtime transports are structurally unaffected.
|
||||
if (!ptyId || isRemoteRuntimePtyId(ptyId)) {
|
||||
return
|
||||
}
|
||||
unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler(
|
||||
ptyId,
|
||||
handleModelRestoreNeededMarker
|
||||
)
|
||||
}
|
||||
|
||||
syncHiddenRendererPtyDelivery = (): void => {
|
||||
const ptyId = transport.getPtyId()
|
||||
syncModelRestoreNeededSubscription(ptyId)
|
||||
if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) {
|
||||
if (hiddenDeliveryMarkedHidden) {
|
||||
sendHiddenRendererPtyDelivery(hiddenDeliverySyncedPtyId, false)
|
||||
}
|
||||
hiddenDeliverySyncedPtyId = null
|
||||
hiddenDeliveryMarkedHidden = false
|
||||
}
|
||||
if (!isHiddenDeliveryGateManagedPty(ptyId) || !canUseHiddenOutputSnapshot(ptyId)) {
|
||||
return
|
||||
}
|
||||
const shouldHide = !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current)
|
||||
const isFirstSyncForPty = hiddenDeliverySyncedPtyId !== ptyId
|
||||
hiddenDeliverySyncedPtyId = ptyId
|
||||
if (shouldHide) {
|
||||
if (!hiddenDeliveryMarkedHidden) {
|
||||
hiddenDeliveryMarkedHidden = true
|
||||
sendHiddenRendererPtyDelivery(ptyId, true)
|
||||
}
|
||||
} else if (hiddenDeliveryMarkedHidden || isFirstSyncForPty) {
|
||||
// Why: clear unconditionally on the first sync for a PTY — a stale
|
||||
// main-side hidden bit can survive a renderer reload for
|
||||
// daemon-backed PTYs that keep their session id.
|
||||
hiddenDeliveryMarkedHidden = false
|
||||
sendHiddenRendererPtyDelivery(ptyId, false)
|
||||
}
|
||||
}
|
||||
releaseHiddenRendererPtyDelivery = (): void => {
|
||||
if (hiddenDeliverySyncedPtyId !== null && hiddenDeliveryMarkedHidden) {
|
||||
sendHiddenRendererPtyDelivery(hiddenDeliverySyncedPtyId, false)
|
||||
}
|
||||
hiddenDeliverySyncedPtyId = null
|
||||
hiddenDeliveryMarkedHidden = false
|
||||
unregisterModelRestoreNeeded?.()
|
||||
unregisterModelRestoreNeeded = null
|
||||
modelRestoreSubscribedPtyId = null
|
||||
}
|
||||
|
||||
function beforeTerminalOutputWrite(): void {
|
||||
recordTerminalOutput(pane.terminal)
|
||||
}
|
||||
@@ -3813,6 +4056,27 @@ export function connectPanePty(
|
||||
}
|
||||
}
|
||||
|
||||
function respondToSkippedMode2031Subscribe(data: string): void {
|
||||
const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data)
|
||||
hiddenMode2031ScanTail = scan.tail
|
||||
if (scan.finalState === 'unsubscribed') {
|
||||
deps.paneMode2031Ref.current.delete(pane.id)
|
||||
deps.paneLastThemeModeRef.current.delete(pane.id)
|
||||
}
|
||||
if (scan.finalState !== 'subscribed') {
|
||||
return
|
||||
}
|
||||
const settings = useAppStore.getState().settings
|
||||
const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
|
||||
// Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer
|
||||
// mode 2031 out-of-band so TUIs still render the snapshot with the same
|
||||
// theme-dependent styling they would have used in a visible pane.
|
||||
deps.paneMode2031Ref.current.set(pane.id, true)
|
||||
transport.sendInput(mode2031SequenceFor(mode))
|
||||
deps.paneLastThemeModeRef.current.set(pane.id, mode)
|
||||
recordHiddenMode2031Reply()
|
||||
}
|
||||
|
||||
function writePtyOutputToXterm(
|
||||
data: string,
|
||||
foreground: boolean,
|
||||
@@ -4120,6 +4384,75 @@ export function connectPanePty(
|
||||
return chunk.data.slice(offset)
|
||||
}
|
||||
|
||||
type RestoredSnapshotReconciliation =
|
||||
| { action: 'write'; data: string; meta: PtyDataMeta | undefined }
|
||||
| { action: 'drop-duplicate' }
|
||||
| { action: 'force-fresh-restore' }
|
||||
|
||||
// Why: same slicing rules as getChunkDataAfterSnapshot, applied to LIVE
|
||||
// chunks after a restore completed — main's ACK backlog keeps draining
|
||||
// chunks at or before the snapshot seq, and pending-cap trims can drop
|
||||
// seq ranges silently once the one-shot overflow marker was consumed.
|
||||
function reconcileChunkAgainstRestoredSnapshot(
|
||||
data: string,
|
||||
meta: PtyDataMeta | undefined
|
||||
): RestoredSnapshotReconciliation {
|
||||
if (restoredSnapshotBaselineSeq === null) {
|
||||
return { action: 'write', data, meta }
|
||||
}
|
||||
if (transport.getPtyId() !== restoredSnapshotBaselinePtyId) {
|
||||
clearRestoredSnapshotBaseline()
|
||||
return { action: 'write', data, meta }
|
||||
}
|
||||
if (typeof meta?.seq !== 'number') {
|
||||
// Why: seq-less chunks (no runtime metering) cannot be reconciled;
|
||||
// mirror getChunkDataAfterSnapshot and pass them through.
|
||||
return { action: 'write', data, meta }
|
||||
}
|
||||
if (
|
||||
restoredSnapshotDeliveryWindowStartSeq !== null &&
|
||||
meta.seq <= restoredSnapshotDeliveryWindowStartSeq
|
||||
) {
|
||||
// Why: every byte main could still deliver at snapshot time started
|
||||
// AFTER this seq, and delivery is once-and-in-order — so this chunk
|
||||
// cannot be a backlog duplicate. It is a new seq domain (restarted
|
||||
// counter / synthetic source); retire the stale baseline and write
|
||||
// instead of silently dropping genuinely-new live output.
|
||||
clearRestoredSnapshotBaseline()
|
||||
return { action: 'write', data, meta }
|
||||
}
|
||||
const rawLength = meta.rawLength ?? data.length
|
||||
const startSeq = meta.seq - rawLength
|
||||
const expectedStartSeq = restoredSnapshotExpectedStartSeq
|
||||
restoredSnapshotExpectedStartSeq = Math.max(expectedStartSeq ?? meta.seq, meta.seq)
|
||||
if (expectedStartSeq !== null && startSeq > expectedStartSeq) {
|
||||
// Why: the chunk starts past the continuity point — bytes between
|
||||
// were dropped (pending-cap trim after the marker fired). Only the
|
||||
// model snapshot can heal the gap.
|
||||
return { action: 'force-fresh-restore' }
|
||||
}
|
||||
if (meta.seq <= restoredSnapshotBaselineSeq) {
|
||||
return { action: 'drop-duplicate' }
|
||||
}
|
||||
if (startSeq >= restoredSnapshotBaselineSeq) {
|
||||
return { action: 'write', data, meta }
|
||||
}
|
||||
if (rawLength !== data.length) {
|
||||
// Why: renderer-only OSC stripping makes raw sequence offsets
|
||||
// impossible to map onto cleaned text — fetch a fresh snapshot
|
||||
// instead of risking duplicate visible output.
|
||||
return { action: 'force-fresh-restore' }
|
||||
}
|
||||
const sliced = data.slice(restoredSnapshotBaselineSeq - startSeq)
|
||||
return {
|
||||
action: 'write',
|
||||
data: sliced,
|
||||
// Why: keep seq metadata consistent with the sliced payload so a
|
||||
// later restore queue drain slices against accurate offsets.
|
||||
meta: { ...meta, rawLength: sliced.length }
|
||||
}
|
||||
}
|
||||
|
||||
function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean {
|
||||
if (hiddenOutputRestorePendingOverflow) {
|
||||
hiddenOutputRestorePendingOverflow = false
|
||||
@@ -4141,6 +4474,12 @@ export function connectPanePty(
|
||||
hiddenOutputRestorePendingChars = 0
|
||||
return false
|
||||
}
|
||||
// Why: drained chunks advance the post-restore continuity point so
|
||||
// the live-chunk reconciliation neither re-drops them as duplicates
|
||||
// nor misreads the next live chunk as a gap.
|
||||
if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) {
|
||||
restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq)
|
||||
}
|
||||
if (data) {
|
||||
writePtyOutputToXterm(data, true)
|
||||
}
|
||||
@@ -4342,6 +4681,7 @@ export function connectPanePty(
|
||||
// Why: renderer backlog is tied to the old PTY stream; after reattach,
|
||||
// queued hidden bytes must not delay or replay before the new PTY.
|
||||
clearHiddenOutputRestoreState()
|
||||
clearRestoredSnapshotBaseline()
|
||||
clearPaneMode2031State()
|
||||
discardTerminalOutput(pane.terminal)
|
||||
}
|
||||
@@ -4534,6 +4874,10 @@ export function connectPanePty(
|
||||
}
|
||||
hiddenOutputRestoreDeferredRetryAttempts = 0
|
||||
applyMainBufferSnapshot(snapshot)
|
||||
// Why: everything at or before snapshot.seq is now painted; chunks
|
||||
// still draining from main's ACK backlog below that point are
|
||||
// duplicates the dataCallback reconciliation must suppress.
|
||||
setRestoredSnapshotBaseline(currentPtyId, snapshot)
|
||||
const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded
|
||||
hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) {
|
||||
@@ -4574,16 +4918,22 @@ export function connectPanePty(
|
||||
return true
|
||||
}
|
||||
|
||||
unregisterBacklogRecovery = registerTerminalBacklogRecovery(
|
||||
pane.terminal,
|
||||
requestHiddenOutputRestoreIfNeeded
|
||||
)
|
||||
unregisterBacklogRecovery = registerTerminalBacklogRecovery(pane.terminal, () => {
|
||||
// Why: clear the hidden-delivery bit BEFORE the restore snapshot
|
||||
// request — bytes arriving between the unhide IPC and the snapshot
|
||||
// are reconciled by the existing seq guard.
|
||||
syncHiddenRendererPtyDelivery()
|
||||
return requestHiddenOutputRestoreIfNeeded()
|
||||
})
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
typeof document.addEventListener === 'function' &&
|
||||
typeof document.removeEventListener === 'function'
|
||||
) {
|
||||
const onDocumentVisibilityChange = (): void => {
|
||||
// Why: document hide/show flips the foreground predicate without any
|
||||
// pane lifecycle event — re-sync the hidden-delivery gate both ways.
|
||||
syncHiddenRendererPtyDelivery()
|
||||
if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) {
|
||||
requestHiddenOutputRestoreIfNeeded()
|
||||
}
|
||||
@@ -4616,19 +4966,52 @@ export function connectPanePty(
|
||||
}
|
||||
respondToTerminalPixelSizeQueries(data)
|
||||
observeTerminalBracketedPasteModeOutput(pane.terminal, data)
|
||||
for (const link of observeTerminalGitHubPRLink(data)) {
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link)
|
||||
// Why: with main side-effect authority, command-finished, pr-link, and
|
||||
// the Command Code scrape arrive as pty:sideEffect facts —
|
||||
// byte-scanning here too would double-fire the same policy.
|
||||
// Remote-runtime PTYs (and the kill switch off) keep this byte path as
|
||||
// their only parser.
|
||||
if (!mainSideEffectAuthority) {
|
||||
for (const link of observeTerminalGitHubPRLink(data)) {
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link)
|
||||
}
|
||||
commandLifecycle.handlePtyData(data)
|
||||
}
|
||||
commandCodeOutputStatusDetector.observe(data)
|
||||
commandLifecycle.handlePtyData(data)
|
||||
commandCodeOutputStatusDetector?.observe(data)
|
||||
// Why: split-pane layouts have multiple visible-but-inactive panes whose
|
||||
// output the user is watching. Throttle only when the pane or whole
|
||||
// Electron document is hidden.
|
||||
const foreground =
|
||||
shouldWritePtyOutputForeground(deps.isVisibleRef.current) && meta?.background !== true
|
||||
// Why: latch the hidden-delivery gate from the byte path too — covers a
|
||||
// PTY id arriving after the initial sync. No-op when state is current.
|
||||
if (!foreground) {
|
||||
syncHiddenRendererPtyDelivery()
|
||||
}
|
||||
if (foreground && hiddenMode2031ScanTail) {
|
||||
respondToSkippedMode2031Subscribe(data)
|
||||
}
|
||||
// Why: post-restore reconciliation — drop/slice backlog chunks the
|
||||
// restored snapshot already covers, and force a fresh restore for seq
|
||||
// gaps or overlaps whose offsets cannot be mapped. Runs after the byte
|
||||
// observers above (those bytes were never delivered before; their side
|
||||
// effects are still real) but before any xterm write decision.
|
||||
const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta)
|
||||
if (reconciliation.action === 'drop-duplicate') {
|
||||
return
|
||||
}
|
||||
if (reconciliation.action === 'force-fresh-restore') {
|
||||
// Why: in-flight captured BEFORE the mark — on a visible pane the
|
||||
// mark starts the restore synchronously and must not flag itself.
|
||||
const restoreWasInFlight = hiddenOutputRestoreInFlight !== null
|
||||
markHiddenOutputRestoreNeeded()
|
||||
if (restoreWasInFlight) {
|
||||
hiddenOutputRestoreFreshSnapshotNeeded = true
|
||||
}
|
||||
return
|
||||
}
|
||||
data = reconciliation.data
|
||||
meta = reconciliation.meta
|
||||
// Why: a hidden Codex query can be split just before visibility changes;
|
||||
// xterm needs the completed query, while other bytes still follow restore.
|
||||
const pendingForegroundQuery = foreground
|
||||
@@ -4677,7 +5060,15 @@ export function connectPanePty(
|
||||
hiddenOutputRestoreNeeded = true
|
||||
hiddenOutputRestoreFreshSnapshotNeeded = true
|
||||
}
|
||||
// Why: hidden chunks with a restore already latched are dropped here —
|
||||
// the model snapshot fetched on reveal covers their bytes.
|
||||
} else {
|
||||
// Why: gate-managed hidden panes normally receive no bytes (main
|
||||
// drops after model ingestion). Any hidden chunk that still arrives
|
||||
// (kill switch off, interest-held delivery) rides the bounded
|
||||
// background scheduler queue; on overflow the scheduler latches the
|
||||
// model restore. The kill-switch-off startup-query grammar above is
|
||||
// the byte-identical fallback.
|
||||
if (pendingForegroundQuery?.statefulQueryData) {
|
||||
writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, {
|
||||
hiddenStartupRendererQuery: true
|
||||
@@ -4749,6 +5140,8 @@ export function connectPanePty(
|
||||
}
|
||||
setPanePtyFitBinding(ptyId)
|
||||
reportPanePtyVisibility(ptyId, deps.isVisibleRef.current)
|
||||
registerSideEffectFactConsumerForPty(ptyId)
|
||||
syncHiddenRendererPtyDelivery()
|
||||
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
|
||||
deps.updateTabPtyId(deps.tabId, ptyId)
|
||||
agentCompletionCoordinator.startProcessTracking()
|
||||
@@ -5035,6 +5428,7 @@ export function connectPanePty(
|
||||
? { launchToken: coldRestoreStartup.launchToken }
|
||||
: {}),
|
||||
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
|
||||
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
callbacks: {
|
||||
onData: dataCallback,
|
||||
onReplayData: replayDataCallback,
|
||||
@@ -5212,6 +5606,7 @@ export function connectPanePty(
|
||||
: {}),
|
||||
...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}),
|
||||
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
|
||||
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
callbacks: {
|
||||
onData: dataCallback,
|
||||
onReplayData: replayDataCallback,
|
||||
@@ -5460,6 +5855,12 @@ export function connectPanePty(
|
||||
return {
|
||||
syncProcessTracking() {
|
||||
agentCompletionCoordinator.startProcessTracking()
|
||||
// Why: the lifecycle hook calls this on every pane visibility flip —
|
||||
// the hidden-delivery gate must follow the same transitions.
|
||||
syncHiddenRendererPtyDelivery()
|
||||
},
|
||||
isHiddenDeliveryGateManagedPty() {
|
||||
return isHiddenDeliveryGateManagedPty(transport.getPtyId())
|
||||
},
|
||||
// Why: called from the lifecycle visibility effect so the visible-resume
|
||||
// size readback can repair dropped hidden resizes without refitting against
|
||||
@@ -5482,6 +5883,9 @@ export function connectPanePty(
|
||||
cancelAnimationFrame(pendingForegroundGridDriftCheckRaf)
|
||||
pendingForegroundGridDriftCheckRaf = null
|
||||
}
|
||||
// Why: a pane unmount (tab move, parking teardown) must never leave its
|
||||
// PTY gated — the parked watcher or the remounted pane re-decides.
|
||||
releaseHiddenRendererPtyDelivery()
|
||||
if (terminalKeyTargetSupportsEvents) {
|
||||
terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true })
|
||||
}
|
||||
@@ -5522,6 +5926,9 @@ export function connectPanePty(
|
||||
unregisterDocumentVisibilityRecovery?.()
|
||||
unregisterDocumentVisibilityRecovery = null
|
||||
reportPanePtyVisibility(activePanePtyBinding ?? transport.getPtyId(), false)
|
||||
// Why: a parked-tab watcher may take over this PTY's facts in the same
|
||||
// effect flush; the pane's consumer must be gone before that handoff.
|
||||
dropSideEffectFactConsumer()
|
||||
clearPanePtyFitBinding()
|
||||
discardTerminalOutput(pane.terminal)
|
||||
unregisterE2ePtyDataInjection()
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { acquirePtyDeliveryInterest } from './pty-delivery-interest'
|
||||
import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher'
|
||||
|
||||
/** Register a side-channel data watcher for a PTY without taking ownership
|
||||
* of the primary handler. Returns an unsubscribe fn. */
|
||||
export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void {
|
||||
ensurePtyDispatcher()
|
||||
// Why: a sidecar is, by definition, a raw-byte consumer — its registration
|
||||
// doubles as the delivery-interest signal that suppresses main's
|
||||
// hidden-delivery gate (terminal-side-effect-authority.md, Open Items).
|
||||
const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId)
|
||||
let set = ptyDataSidecars.get(ptyId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
@@ -11,6 +16,7 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi
|
||||
}
|
||||
set.add(watcher)
|
||||
return () => {
|
||||
releaseDeliveryInterest()
|
||||
const current = ptyDataSidecars.get(ptyId)
|
||||
if (!current) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Renderer-side delivery-interest registry for the Phase-4 hidden-delivery
|
||||
* gate (docs/reference/terminal-side-effect-authority.md, Open Items).
|
||||
*
|
||||
* Why: main only drops hidden PTY byte delivery while NO renderer party needs
|
||||
* raw bytes. Dispatcher sidecars and eager pre-mount buffers register
|
||||
* interest here; ref-counted so main sees only the 0↔1 transitions.
|
||||
*/
|
||||
const ptyDeliveryInterestRefCounts = new Map<string, number>()
|
||||
|
||||
function sendPtyDeliveryInterest(ptyId: string, interested: boolean): void {
|
||||
;(globalThis as { window?: Window }).window?.api?.pty?.setPtyDeliveryInterest?.(ptyId, interested)
|
||||
}
|
||||
|
||||
/** Acquire a delivery-interest hold for a PTY. Returns a release fn that is
|
||||
* safe to call more than once (only the first call decrements). */
|
||||
export function acquirePtyDeliveryInterest(ptyId: string): () => void {
|
||||
const next = (ptyDeliveryInterestRefCounts.get(ptyId) ?? 0) + 1
|
||||
ptyDeliveryInterestRefCounts.set(ptyId, next)
|
||||
if (next === 1) {
|
||||
sendPtyDeliveryInterest(ptyId, true)
|
||||
}
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
const current = ptyDeliveryInterestRefCounts.get(ptyId) ?? 0
|
||||
if (current <= 1) {
|
||||
ptyDeliveryInterestRefCounts.delete(ptyId)
|
||||
sendPtyDeliveryInterest(ptyId, false)
|
||||
} else {
|
||||
ptyDeliveryInterestRefCounts.set(ptyId, current - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: drop ref counts between tests (no IPC is sent). */
|
||||
export function _resetPtyDeliveryInterestForTest(): void {
|
||||
ptyDeliveryInterestRefCounts.clear()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Why: the Phase-4 hidden-delivery gate only drops bytes while NO renderer
|
||||
// party needs them. These tests pin the dispatcher-side interest signal: every
|
||||
// subscribeToPtyData sidecar and every eager pre-mount buffer must surface a
|
||||
// ref-counted delivery-interest hold to main, and release it exactly once.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('pty dispatcher delivery interest', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
let setPtyDeliveryInterest: ReturnType<typeof vi.fn>
|
||||
let exitCallback: ((payload: { id: string; code: number }) => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
exitCallback = null
|
||||
setPtyDeliveryInterest = vi.fn()
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
setPtyDeliveryInterest,
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => {
|
||||
exitCallback ??= cb
|
||||
return () => {}
|
||||
}),
|
||||
ackData: vi.fn()
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('registers interest on the first sidecar and releases on the last unsubscribe', async () => {
|
||||
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
|
||||
|
||||
const unsubscribeFirst = subscribeToPtyData('pty-1', vi.fn())
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-1', true)
|
||||
|
||||
// Why: ref-counted — main only sees the 0↔1 transitions.
|
||||
const unsubscribeSecond = subscribeToPtyData('pty-1', vi.fn())
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
|
||||
unsubscribeFirst()
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
unsubscribeSecond()
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
|
||||
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false)
|
||||
})
|
||||
|
||||
it('releases sidecar interest only once for repeated unsubscribes', async () => {
|
||||
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
|
||||
|
||||
const unsubscribe = subscribeToPtyData('pty-1', vi.fn())
|
||||
unsubscribe()
|
||||
unsubscribe()
|
||||
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
|
||||
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false)
|
||||
})
|
||||
|
||||
it('holds interest for an eager pre-mount buffer until the pane attach disposes it', async () => {
|
||||
const { registerEagerPtyBuffer } = await import('./pty-dispatcher')
|
||||
|
||||
const handle = registerEagerPtyBuffer('pty-eager', vi.fn())
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-eager', true)
|
||||
|
||||
handle.dispose()
|
||||
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-eager', false)
|
||||
|
||||
// Why: dispose + a later exit event must not double-release the hold a
|
||||
// concurrent sidecar may still own.
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases eager-buffer interest when the PTY exits before any pane mounts', async () => {
|
||||
const { registerEagerPtyBuffer } = await import('./pty-dispatcher')
|
||||
|
||||
registerEagerPtyBuffer('pty-eager', vi.fn())
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-eager', true)
|
||||
|
||||
exitCallback?.({ id: 'pty-eager', code: 0 })
|
||||
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-eager', false)
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps interest held while a sidecar and an eager buffer overlap', async () => {
|
||||
const { registerEagerPtyBuffer } = await import('./pty-dispatcher')
|
||||
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
|
||||
|
||||
const handle = registerEagerPtyBuffer('pty-1', vi.fn())
|
||||
const unsubscribe = subscribeToPtyData('pty-1', vi.fn())
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
|
||||
handle.dispose()
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
|
||||
unsubscribe()
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
|
||||
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
* and the eager-buffer reconnection logic share.
|
||||
*/
|
||||
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits'
|
||||
import { acquirePtyDeliveryInterest } from './pty-delivery-interest'
|
||||
import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate'
|
||||
import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp'
|
||||
import {
|
||||
@@ -218,6 +219,10 @@ export function registerEagerPtyBuffer(
|
||||
onExit: (ptyId: string, code: number) => void
|
||||
): EagerPtyHandle {
|
||||
ensurePtyDispatcher()
|
||||
// Why: an eager buffer means a pane mount is (potentially) pending — the
|
||||
// hidden-delivery gate must keep bytes flowing until the pane attaches and
|
||||
// takes over, so the buffer holds delivery interest for its lifetime.
|
||||
const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId)
|
||||
|
||||
// Why: a head index instead of Array.shift() — shift() is O(n), making
|
||||
// pre-attach buffering quadratic under many small chunks. Compaction is deferred.
|
||||
@@ -246,6 +251,7 @@ export function registerEagerPtyBuffer(
|
||||
const exitHandler = (code: number): void => {
|
||||
// Shell died before TerminalPane attached — clean up and notify the store
|
||||
// so the tab's ptyId is cleared and connectPanePty falls through to connect().
|
||||
releaseDeliveryInterest()
|
||||
ptyDataHandlers.delete(ptyId)
|
||||
ptyReplayHandlers.delete(ptyId)
|
||||
ptyExitHandlers.delete(ptyId)
|
||||
@@ -268,6 +274,9 @@ export function registerEagerPtyBuffer(
|
||||
return data
|
||||
},
|
||||
dispose() {
|
||||
// Why: dispose runs at pane attach (mount completed) — the pane's own
|
||||
// visibility sync now owns the hidden-delivery decision for this PTY.
|
||||
releaseDeliveryInterest()
|
||||
// Only remove if the current handler is still the temp one (compare by
|
||||
// reference). After attach() replaces the handler this becomes a no-op.
|
||||
if (ptyDataHandlers.get(ptyId) === dataHandler) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Why: the out-of-band pty:modelRestoreNeeded channel replaces the in-band
|
||||
// empty-chunk sentinel (ambiguous with chunks fully consumed by OSC-9999
|
||||
// stripping). These tests pin the channel routing: one channel subscription,
|
||||
// handlers keyed by PTY id, replace-on-reregister semantics.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('pty model-restore channel routing', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
let onModelRestoreNeeded: ReturnType<typeof vi.fn>
|
||||
let channelCallback: ((event: { id: string; reason: string; markerSeq?: number }) => void) | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
channelCallback = null
|
||||
onModelRestoreNeeded = vi.fn(
|
||||
(callback: (event: { id: string; reason: string; markerSeq?: number }) => void) => {
|
||||
channelCallback ??= callback
|
||||
return () => {}
|
||||
}
|
||||
)
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
onModelRestoreNeeded
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches the channel once and routes markers to the registered PTY handler', async () => {
|
||||
const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel')
|
||||
const handlerA = vi.fn()
|
||||
const handlerB = vi.fn()
|
||||
|
||||
registerPtyModelRestoreNeededHandler('pty-a', handlerA)
|
||||
registerPtyModelRestoreNeededHandler('pty-b', handlerB)
|
||||
expect(onModelRestoreNeeded).toHaveBeenCalledTimes(1)
|
||||
|
||||
channelCallback?.({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 })
|
||||
expect(handlerA).toHaveBeenCalledWith({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 })
|
||||
expect(handlerB).not.toHaveBeenCalled()
|
||||
|
||||
// Markers for PTYs without a registered handler are dropped silently.
|
||||
channelCallback?.({ id: 'pty-unknown', reason: 'pending-cap' })
|
||||
expect(handlerA).toHaveBeenCalledTimes(1)
|
||||
expect(handlerB).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a new registration replace a stale one without the stale unregister clobbering it', async () => {
|
||||
const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel')
|
||||
const staleHandler = vi.fn()
|
||||
const liveHandler = vi.fn()
|
||||
|
||||
const unregisterStale = registerPtyModelRestoreNeededHandler('pty-a', staleHandler)
|
||||
registerPtyModelRestoreNeededHandler('pty-a', liveHandler)
|
||||
// Why: a reattaching pane can re-register before the old connection's
|
||||
// teardown runs — the stale unregister must not remove the live handler.
|
||||
unregisterStale()
|
||||
|
||||
channelCallback?.({ id: 'pty-a', reason: 'unhide' })
|
||||
expect(staleHandler).not.toHaveBeenCalled()
|
||||
expect(liveHandler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops routing after the live handler unregisters', async () => {
|
||||
const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel')
|
||||
const handler = vi.fn()
|
||||
|
||||
const unregister = registerPtyModelRestoreNeededHandler('pty-a', handler)
|
||||
unregister()
|
||||
|
||||
channelCallback?.({ id: 'pty-a', reason: 'hidden-drop' })
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Singleton router for the out-of-band `pty:modelRestoreNeeded` channel
|
||||
* (sibling of the pty-dispatcher's data/exit routing — split out to keep the
|
||||
* dispatcher under the line limit).
|
||||
*
|
||||
* Why a dedicated channel + registry: the marker means "main dropped
|
||||
* renderer-bound bytes (hidden gate / pending cap); restore from the model
|
||||
* snapshot". It must NOT ride the transport data path — an in-band empty
|
||||
* chunk is ambiguous with chunks fully consumed by OSC-9999 stripping, and
|
||||
* remote-runtime transports (which never see main's gate) must stay
|
||||
* structurally unaffected.
|
||||
*/
|
||||
import type { PtyModelRestoreNeededEvent } from '../../../../shared/pty-model-restore-marker'
|
||||
|
||||
const ptyModelRestoreNeededHandlers = new Map<string, (event: PtyModelRestoreNeededEvent) => void>()
|
||||
let modelRestoreNeededChannelAttached = false
|
||||
|
||||
function dispatchPtyModelRestoreNeeded(event: PtyModelRestoreNeededEvent): void {
|
||||
ptyModelRestoreNeededHandlers.get(event.id)?.(event)
|
||||
}
|
||||
|
||||
function ensureModelRestoreNeededChannel(): void {
|
||||
if (modelRestoreNeededChannelAttached) {
|
||||
return
|
||||
}
|
||||
// Why optional-chained: unit tests and the web remote client expose a
|
||||
// partial pty API; missing channel means "no markers", never a throw.
|
||||
const onModelRestoreNeeded = (globalThis as { window?: Window }).window?.api?.pty
|
||||
?.onModelRestoreNeeded
|
||||
if (typeof onModelRestoreNeeded !== 'function') {
|
||||
return
|
||||
}
|
||||
modelRestoreNeededChannelAttached = true
|
||||
onModelRestoreNeeded(dispatchPtyModelRestoreNeeded)
|
||||
}
|
||||
|
||||
/** Register the single model-restore-needed handler for a PTY (the pane
|
||||
* connection that owns its view). A new registration replaces a stale one. */
|
||||
export function registerPtyModelRestoreNeededHandler(
|
||||
ptyId: string,
|
||||
handler: (event: PtyModelRestoreNeededEvent) => void
|
||||
): () => void {
|
||||
ensureModelRestoreNeededChannel()
|
||||
ptyModelRestoreNeededHandlers.set(ptyId, handler)
|
||||
return () => {
|
||||
if (ptyModelRestoreNeededHandlers.get(ptyId) === handler) {
|
||||
ptyModelRestoreNeededHandlers.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: deliver a marker as if it arrived on the channel. */
|
||||
export function _dispatchPtyModelRestoreNeededForTest(event: PtyModelRestoreNeededEvent): void {
|
||||
dispatchPtyModelRestoreNeeded(event)
|
||||
}
|
||||
@@ -12,6 +12,11 @@ export type PtyBufferSnapshot = {
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
/** Lowest seq main could still deliver when the snapshot was taken (start
|
||||
* of its pending renderer-delivery queue; equals `seq` when empty). Bytes
|
||||
* are delivered once and in order, so a post-restore chunk at or below
|
||||
* this seq can never be a duplicate the snapshot already covers. */
|
||||
pendingDeliveryStartSeq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
/** True when the snapshot captures an alternate-screen TUI (Claude Code,
|
||||
* vim). Restore must NOT clear xterm's buffer in that case — the TUI's
|
||||
@@ -50,6 +55,11 @@ export type PtyTransport = {
|
||||
cols?: number
|
||||
rows?: number
|
||||
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.
|
||||
* Ignored by remote-runtime transports (not gate-markable). */
|
||||
initiallyHidden?: boolean
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
@@ -77,6 +87,10 @@ export type PtyTransport = {
|
||||
getPtyId: () => string | null
|
||||
getConnectionId?: () => string | null | undefined
|
||||
getLocalSessionMetadata?: () => LocalPtySessionMetadata | null
|
||||
/** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a
|
||||
* model-restore marker reports dropped bytes — a carry spanning the gap
|
||||
* would corrupt the next live chunk. IPC transports only. */
|
||||
resetCrossChunkParserState?: () => void
|
||||
serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise<PtyBufferSnapshot | null>
|
||||
preserve?: () => void
|
||||
detach?: () => void
|
||||
|
||||
@@ -215,6 +215,42 @@ describe('createIpcPtyTransport', () => {
|
||||
transport.disconnect()
|
||||
})
|
||||
|
||||
it('runs title side effects even when the data callback does not render the chunk', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const onTitleChange = vi.fn()
|
||||
const onDataCallback = vi.fn()
|
||||
const transport = createIpcPtyTransport({ onTitleChange })
|
||||
|
||||
await transport.connect({ url: '', callbacks: { onData: onDataCallback } })
|
||||
|
||||
onData?.({ id: 'pty-1', data: '\u001b]0;hidden-title\u0007' })
|
||||
|
||||
expect(onDataCallback).toHaveBeenCalledWith('\u001b]0;hidden-title\u0007')
|
||||
expect(onTitleChange).not.toHaveBeenCalled()
|
||||
|
||||
await flushPtySideEffects()
|
||||
|
||||
expect(onTitleChange).toHaveBeenCalledWith('hidden-title', 'hidden-title')
|
||||
transport.disconnect()
|
||||
})
|
||||
|
||||
it('drops the OSC-9999 cross-chunk carry on resetAgentStatusCarry', async () => {
|
||||
// Why: a model-restore marker means bytes were dropped between chunks —
|
||||
// a partial OSC-9999 prefix carried across that gap would swallow the
|
||||
// next live chunk's head as bogus status payload.
|
||||
const { createPtyOutputProcessor } = await import('./pty-transport')
|
||||
const processor = createPtyOutputProcessor({})
|
||||
const callbacks = { onData: vi.fn() }
|
||||
|
||||
processor.processData('\x1b]9999;', callbacks)
|
||||
expect(callbacks.onData).toHaveBeenLastCalledWith('')
|
||||
|
||||
processor.resetAgentStatusCarry()
|
||||
processor.processData('plain output after the gap', callbacks)
|
||||
|
||||
expect(callbacks.onData).toHaveBeenLastCalledWith('plain output after the gap')
|
||||
})
|
||||
|
||||
it('does not schedule PTY side-effect drains for ordinary output with no working title', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer'
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types'
|
||||
import { createBellDetector } from './bell-detector'
|
||||
import { createBellDetector } from '../../../../shared/terminal-bell-detector'
|
||||
import {
|
||||
hasTerminalDisplayContent,
|
||||
trimIncompleteTerminalControlTail
|
||||
@@ -80,7 +80,12 @@ type PtyOutputProcessorOptions = Pick<
|
||||
| 'onAgentBecameWorking'
|
||||
| 'onAgentExited'
|
||||
| 'onAgentStatus'
|
||||
>
|
||||
> & {
|
||||
/** Seed for processors that start mid-session (parked-tab byte watchers):
|
||||
* the pane's last known title, so a working agent that finishes while the
|
||||
* processor owns the stream still yields a working→idle transition. */
|
||||
initialAgentTitle?: string
|
||||
}
|
||||
|
||||
type ProcessPtyOutputOptions = {
|
||||
replayingBufferedData?: boolean
|
||||
@@ -102,7 +107,8 @@ export function createPtyOutputProcessor({
|
||||
onAgentBecameIdle,
|
||||
onAgentBecameWorking,
|
||||
onAgentExited,
|
||||
onAgentStatus
|
||||
onAgentStatus,
|
||||
initialAgentTitle
|
||||
}: PtyOutputProcessorOptions): {
|
||||
processData: (
|
||||
data: string,
|
||||
@@ -114,10 +120,18 @@ export function createPtyOutputProcessor({
|
||||
clearStaleTitleTimer: () => void
|
||||
flushPendingSideEffects: () => void
|
||||
resetBellDetector: () => void
|
||||
resetAgentStatusCarry: () => void
|
||||
} {
|
||||
const bellDetector = createBellDetector()
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
let lastEmittedTitle: string | null = null
|
||||
// Why `let`: a model-restore marker means bytes were dropped between
|
||||
// chunks; a partial OSC-9999 prefix carried across that gap would swallow
|
||||
// the next live chunk's head as bogus payload. Reset recreates the parser.
|
||||
let processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
// Why: seed both the emitted-title memory (stale-title probe) and the agent
|
||||
// tracker so a mid-session processor behaves as if it had observed the
|
||||
// pane's last live title — full parity with the live path it replaces.
|
||||
let lastEmittedTitle: string | null =
|
||||
initialAgentTitle !== undefined ? normalizeTerminalTitle(initialAgentTitle) : null
|
||||
let staleTitleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let sideEffectDrainTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingSideEffects: PendingPtySideEffect[] = []
|
||||
@@ -130,7 +144,8 @@ export function createPtyOutputProcessor({
|
||||
onAgentBecameIdle?.(title)
|
||||
},
|
||||
onAgentBecameWorking,
|
||||
onAgentExited
|
||||
onAgentExited,
|
||||
initialAgentTitle
|
||||
)
|
||||
: null
|
||||
|
||||
@@ -433,7 +448,10 @@ export function createPtyOutputProcessor({
|
||||
clearAccumulatedState,
|
||||
clearStaleTitleTimer,
|
||||
flushPendingSideEffects,
|
||||
resetBellDetector: () => bellDetector.reset()
|
||||
resetBellDetector: () => bellDetector.reset(),
|
||||
resetAgentStatusCarry: () => {
|
||||
processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,6 +721,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
: {}),
|
||||
...(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 } : {}),
|
||||
@@ -969,6 +991,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
}
|
||||
},
|
||||
|
||||
resetCrossChunkParserState() {
|
||||
// Why: only the OSC-9999 carry spans the dropped-byte gap a
|
||||
// model-restore marker reports; title/bell trackers re-sync from the
|
||||
// snapshot's side-effect replay and must not be reset here.
|
||||
outputProcessor.resetAgentStatusCarry()
|
||||
},
|
||||
|
||||
destroy() {
|
||||
destroyed = true
|
||||
this.disconnect()
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import {
|
||||
applyTerminalAppearance,
|
||||
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
|
||||
@@ -373,6 +378,133 @@ describe('installMode2031Handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyTerminalAppearance theme assignment', () => {
|
||||
// xterm's OptionsService fires the theme change on object IDENTITY, and
|
||||
// ThemeService._setTheme then rebuilds the palette, discarding OSC
|
||||
// 4/10/11/12 SET mutations. Attribute-neutral applies (font size, padding,
|
||||
// zoom) compose a fresh-but-value-identical theme; assigning it anyway
|
||||
// wipes TUI color mutations on visible panes while the deduped publisher
|
||||
// keeps hidden overlays — so the assignment must be value-gated.
|
||||
function makePane(id: number): ManagedPane {
|
||||
return { id, terminal: { options: {}, cols: 80, rows: 24 } } as unknown as ManagedPane
|
||||
}
|
||||
|
||||
function makeManager(panes: ManagedPane[]): PaneManager {
|
||||
return {
|
||||
getPanes: () => panes,
|
||||
setPaneLigaturesEnabled: vi.fn(),
|
||||
setPaneStyleOptions: vi.fn()
|
||||
} as unknown as PaneManager
|
||||
}
|
||||
|
||||
function apply(pane: ManagedPane, settings: ReturnType<typeof getDefaultSettings>): void {
|
||||
applyTerminalAppearance(
|
||||
makeManager([pane]),
|
||||
settings,
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
'false',
|
||||
new Map(),
|
||||
new Map()
|
||||
)
|
||||
}
|
||||
|
||||
it('keeps options.theme identity across attribute-neutral applies (font size tweak)', () => {
|
||||
const pane = makePane(1)
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
|
||||
apply(pane, settings)
|
||||
const firstTheme = pane.terminal.options.theme
|
||||
expect(firstTheme).toBeDefined()
|
||||
|
||||
apply(pane, { ...settings, terminalFontSize: settings.terminalFontSize + 2 })
|
||||
|
||||
// Identity-stable theme means xterm never re-runs _setTheme, so a TUI's
|
||||
// modifyColors mutation survives the font tweak.
|
||||
expect(pane.terminal.options.theme).toBe(firstTheme)
|
||||
expect(pane.terminal.options.fontSize).toBe(settings.terminalFontSize + 2)
|
||||
})
|
||||
|
||||
it('still assigns a fresh theme when composed values actually change', () => {
|
||||
const pane = makePane(1)
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
|
||||
apply(pane, settings)
|
||||
const firstTheme = pane.terminal.options.theme
|
||||
|
||||
apply(pane, { ...settings, terminalColorOverrides: { background: '#102030' } })
|
||||
|
||||
expect(pane.terminal.options.theme).not.toBe(firstTheme)
|
||||
expect(pane.terminal.options.theme?.background).toBe('#102030')
|
||||
})
|
||||
})
|
||||
|
||||
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)')
|
||||
|
||||
@@ -21,6 +21,8 @@ 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 }
|
||||
|
||||
@@ -206,6 +208,54 @@ 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 {
|
||||
if (!a) {
|
||||
return false
|
||||
}
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
||||
for (const key of keys) {
|
||||
if (key === 'extendedAnsi') {
|
||||
continue
|
||||
}
|
||||
if (a[key as keyof ITheme] !== b[key as keyof ITheme]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const extA = a.extendedAnsi
|
||||
const extB = b.extendedAnsi
|
||||
if (!extA || !extB) {
|
||||
return extA === extB
|
||||
}
|
||||
return extA.length === extB.length && extA.every((value, i) => value === extB[i])
|
||||
}
|
||||
|
||||
export function applyTerminalAppearance(
|
||||
manager: PaneManager,
|
||||
settings: GlobalSettings,
|
||||
@@ -220,6 +270,11 @@ export function applyTerminalAppearance(
|
||||
const paneStyles = resolvePaneStyleOptions(settings)
|
||||
const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
|
||||
const theme = composeActiveTerminalTheme(baseTheme, settings)
|
||||
// View-attribute bridge (Phase 5 slice 2): this is the single point where
|
||||
// the composed app-global terminal appearance exists, so publish it to
|
||||
// main's hidden-PTY query responder here. Deduped inside the publisher —
|
||||
// per-pane re-applies and attribute-neutral tweaks do not re-push.
|
||||
publishTerminalViewAttributes(theme, appearance.mode, settings)
|
||||
const paneBackground = theme?.background ?? '#000000'
|
||||
|
||||
const terminalFontWeights = resolveTerminalFontWeights(settings.terminalFontWeight)
|
||||
@@ -229,7 +284,14 @@ export function applyTerminalAppearance(
|
||||
)
|
||||
|
||||
for (const pane of manager.getPanes()) {
|
||||
if (theme) {
|
||||
// Why value-gated: xterm's OptionsService fires on object identity, and
|
||||
// ThemeService._setTheme rebuilds the palette, discarding TUI OSC
|
||||
// 4/10/11/12 SET mutations. Attribute-neutral applies (font size/family,
|
||||
// line height, padding, per-pane zoom) compose a fresh-but-identical
|
||||
// theme; skipping the write keeps visible-pane mutations alive (a
|
||||
// pre-existing loss this also fixes) and matches the hidden responder's
|
||||
// deduped overlay behavior, so hidden and visible no longer drift.
|
||||
if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) {
|
||||
pane.terminal.options.theme = theme
|
||||
}
|
||||
// Why: xterm's allowTransparency has measurable rendering cost, so clear
|
||||
|
||||
@@ -1,99 +1,32 @@
|
||||
import type { Terminal, IDisposable } from '@xterm/xterm'
|
||||
import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished'
|
||||
|
||||
type TerminalCommandLifecycleOptions = {
|
||||
onCommandFinished: (bestEffortExitCode: number | null) => void
|
||||
}
|
||||
|
||||
type OscTerminator = {
|
||||
index: number
|
||||
length: number
|
||||
}
|
||||
|
||||
const OSC_133_PREFIX = '\x1b]133;'
|
||||
const MAX_OSC_CARRY_LENGTH = 4096
|
||||
|
||||
function findOscTerminator(data: string, startIndex: number): OscTerminator | null {
|
||||
const bel = data.indexOf('\x07', startIndex)
|
||||
const st = data.indexOf('\x1b\\', startIndex)
|
||||
|
||||
if (bel === -1 && st === -1) {
|
||||
return null
|
||||
}
|
||||
if (bel !== -1 && (st === -1 || bel < st)) {
|
||||
return { index: bel, length: 1 }
|
||||
}
|
||||
return { index: st, length: 2 }
|
||||
}
|
||||
|
||||
function parseBestEffortExitCode(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
function findPrefixCarry(data: string): string {
|
||||
const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1)
|
||||
for (let length = maxCarryLength; length > 0; length -= 1) {
|
||||
const suffix = data.slice(data.length - length)
|
||||
if (OSC_133_PREFIX.startsWith(suffix)) {
|
||||
return suffix
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function createTerminalCommandLifecycle(options: TerminalCommandLifecycleOptions): {
|
||||
handlePtyData: (data: string) => void
|
||||
attachXtermConsumer: (terminal: Terminal) => IDisposable
|
||||
dispose: () => void
|
||||
} {
|
||||
let carry = ''
|
||||
// Why: the byte parsing lives in shared so main's side-effect tracker emits
|
||||
// identical command-finished facts for local/SSH PTYs; this renderer wrapper
|
||||
// remains the byte path for remote-runtime PTYs and the kill-switch-off mode.
|
||||
const scanner = createOsc133CommandFinishedScanner(options.onCommandFinished)
|
||||
const disposables: IDisposable[] = []
|
||||
|
||||
const handleOsc133 = (payload: string): void => {
|
||||
const [sequence, exitCode] = payload.split(';')
|
||||
if (sequence === 'D') {
|
||||
options.onCommandFinished(parseBestEffortExitCode(exitCode))
|
||||
}
|
||||
}
|
||||
|
||||
const handlePtyData = (data: string): void => {
|
||||
let combined = carry + data
|
||||
carry = ''
|
||||
|
||||
while (combined.length > 0) {
|
||||
const start = combined.indexOf(OSC_133_PREFIX)
|
||||
if (start === -1) {
|
||||
carry = findPrefixCarry(combined)
|
||||
return
|
||||
}
|
||||
|
||||
const payloadStart = start + OSC_133_PREFIX.length
|
||||
const terminator = findOscTerminator(combined, payloadStart)
|
||||
if (!terminator) {
|
||||
carry = combined.slice(start)
|
||||
if (carry.length > MAX_OSC_CARRY_LENGTH) {
|
||||
carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handleOsc133(combined.slice(payloadStart, terminator.index))
|
||||
combined = combined.slice(terminator.index + terminator.length)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handlePtyData,
|
||||
handlePtyData: scanner.scan,
|
||||
attachXtermConsumer(terminal) {
|
||||
// Why: swallow OSC 133 so shell-integration markers never paint —
|
||||
// rendering hygiene that applies regardless of side-effect authority.
|
||||
const disposable = terminal.parser.registerOscHandler(133, () => true)
|
||||
disposables.push(disposable)
|
||||
return disposable
|
||||
},
|
||||
dispose() {
|
||||
carry = ''
|
||||
scanner.reset()
|
||||
for (const disposable of disposables.splice(0)) {
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Renderer-side predicate for main's Phase-4 hidden PTY delivery gate.
|
||||
*
|
||||
* The gate only operates when main holds side-effect authority for the PTY
|
||||
* (see isMainTerminalSideEffectAuthorityForPty) AND the gate-specific kill
|
||||
* switch is on. Callers decide once at pane/watcher creation — the decision
|
||||
* picks which mode-2031 responder is registered (byte sidecar vs fact reply),
|
||||
* so it must never flip per chunk.
|
||||
*/
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
// Why: cached once per session — the blocking read should only ever run on
|
||||
// the pre-hydration startup path, never per pane bind.
|
||||
let persistedGateFlagCache: boolean | null | undefined
|
||||
|
||||
function readPersistedHiddenDeliveryGateFlagSync(): boolean | null {
|
||||
if (persistedGateFlagCache === undefined) {
|
||||
try {
|
||||
const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync
|
||||
persistedGateFlagCache =
|
||||
typeof getSync === 'function' ? (getSync()?.terminalHiddenDeliveryGate ?? null) : null
|
||||
} catch {
|
||||
persistedGateFlagCache = null
|
||||
}
|
||||
}
|
||||
return persistedGateFlagCache
|
||||
}
|
||||
|
||||
export function isRendererHiddenPtyDeliveryGateEnabled(
|
||||
settings: Pick<GlobalSettings, 'terminalHiddenDeliveryGate'> | null
|
||||
): boolean {
|
||||
if (settings !== null) {
|
||||
return settings.terminalHiddenDeliveryGate !== false
|
||||
}
|
||||
// Why: settings hydrate asynchronously; a pane/watcher bound before
|
||||
// hydration must honor the persisted kill switch — the responder-mode
|
||||
// decision made here is never revisited (same rationale as the
|
||||
// side-effect-authority sync read).
|
||||
return readPersistedHiddenDeliveryGateFlagSync() !== false
|
||||
}
|
||||
|
||||
/** Test seam: reset the persisted-flag cache between tests. */
|
||||
export function _resetHiddenPtyDeliveryGateFlagCacheForTest(): void {
|
||||
persistedGateFlagCache = undefined
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_TAB_HOT_RETAIN_MS,
|
||||
TERMINAL_WORKTREE_HOT_RETAIN_MS,
|
||||
TERMINAL_WORKTREE_PARK_DELAY_MS,
|
||||
canParkTerminalTabRenderer,
|
||||
canParkTerminalWorktreeRenderers,
|
||||
getTerminalTabColdParkRecheckDelayMs,
|
||||
getTerminalWorktreeColdParkRecheckDelayMs,
|
||||
isSnapshotBackedTerminalPty,
|
||||
selectColdParkedTerminalTabs,
|
||||
selectColdParkedTerminalWorktrees
|
||||
} from './terminal-hidden-view-parking'
|
||||
|
||||
describe('isSnapshotBackedTerminalPty', () => {
|
||||
it('allows local daemon sessions owned by the worktree', () => {
|
||||
expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true)
|
||||
expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true)
|
||||
})
|
||||
|
||||
// Why: separator-less ids ('1', '2', 'pty-local-detached') come from the
|
||||
// daemon-fail-open LocalPtyProvider and have no daemon session model —
|
||||
// revealing a parked pane would silently respawn a fresh shell, so they
|
||||
// must not count as snapshot-backed (changed from the ported prior art).
|
||||
it('rejects separator-less local PTY ids that lack a daemon session model', () => {
|
||||
expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(false)
|
||||
expect(isSnapshotBackedTerminalPty('1', 'wt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects tabs that do not have a PTY yet', () => {
|
||||
expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects daemon sessions owned by another worktree', () => {
|
||||
expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false)
|
||||
expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects SSH and remote runtime PTY handles', () => {
|
||||
expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false)
|
||||
expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canParkTerminalWorktreeRenderers', () => {
|
||||
const hiddenSinceMs = 1_000
|
||||
const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
|
||||
const base = {
|
||||
worktreeId: 'repo::/worktree',
|
||||
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
isVisible: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs,
|
||||
nowMs
|
||||
}
|
||||
|
||||
it('parks hidden local terminal renderers after the idle delay', () => {
|
||||
expect(canParkTerminalWorktreeRenderers(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('never parks when the settings kill switch disables parking', () => {
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
parkingEnabled: false,
|
||||
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_HOT_RETAIN_MS * 10
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => {
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false)
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe(
|
||||
false
|
||||
)
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('honors a per-call cold-park delay override', () => {
|
||||
const shortDelayArgs = { ...base, coldParkDelayMs: 100 }
|
||||
expect(canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 99 })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 100 })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => {
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
terminalTabs: [
|
||||
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' },
|
||||
{ id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' }
|
||||
]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps renderers mounted while a tab has startup or activation work pending', () => {
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
terminalTabs: [
|
||||
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true }
|
||||
]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
terminalTabs: [
|
||||
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 }
|
||||
]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canParkTerminalTabRenderer', () => {
|
||||
const hiddenSinceMs = 1_000
|
||||
const base = {
|
||||
worktreeId: 'wt-1',
|
||||
terminalTab: {
|
||||
id: 'tab-1',
|
||||
ptyId: 'wt-1@@session-1',
|
||||
isVisible: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs
|
||||
},
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
|
||||
}
|
||||
|
||||
it('parks an idle hidden local tab and honors the kill switch', () => {
|
||||
expect(canParkTerminalTabRenderer(base)).toBe(true)
|
||||
expect(canParkTerminalTabRenderer({ ...base, parkingEnabled: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('honors a per-call cold-park delay override', () => {
|
||||
expect(
|
||||
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 99 })
|
||||
).toBe(false)
|
||||
expect(
|
||||
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 100 })
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectColdParkedTerminalWorktrees', () => {
|
||||
const nowMs = 500_000
|
||||
|
||||
function localCandidate(worktreeId: string, hiddenSinceMs: number) {
|
||||
return {
|
||||
worktreeId,
|
||||
terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }],
|
||||
isVisible: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps recent hidden local worktrees hot up to the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
|
||||
localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['wt-3']))
|
||||
})
|
||||
|
||||
it('cold-parks aged local worktrees even when under the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 4
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['wt-1']))
|
||||
})
|
||||
|
||||
it('selects nothing when the settings kill switch disables parking', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS * 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: false,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('does not cold-park terminals without local snapshot recovery', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
{
|
||||
...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }]
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }]
|
||||
}
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['wt-local']))
|
||||
})
|
||||
|
||||
it('keeps visible, measuring, portaled, and pending terminals mounted', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
{
|
||||
...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
shouldMeasureHiddenWorktree: true
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
hasActivityTerminalPortal: true
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
terminalTabs: [
|
||||
{
|
||||
id: 'tab-activation',
|
||||
ptyId: 'wt-activation@@session-1',
|
||||
pendingActivationSpawn: true
|
||||
}
|
||||
]
|
||||
},
|
||||
localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)
|
||||
],
|
||||
pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } },
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectColdParkedTerminalTabs', () => {
|
||||
const nowMs = 500_000
|
||||
|
||||
function localTab(id: string, hiddenSinceMs: number) {
|
||||
return {
|
||||
id,
|
||||
ptyId: `wt-1@@session-${id}`,
|
||||
pendingActivationSpawn: false,
|
||||
isVisible: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps visible and recent inactive terminal tabs mounted', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
{ ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true },
|
||||
localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('cold-parks the oldest inactive local tabs beyond the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
|
||||
localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['tab-3']))
|
||||
})
|
||||
|
||||
it('cold-parks aged inactive local tabs even when under the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 12
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['tab-1']))
|
||||
})
|
||||
|
||||
it('selects nothing when the settings kill switch disables parking', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
localTab('tab-2', nowMs - TERMINAL_TAB_HOT_RETAIN_MS * 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: false,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('does not cold-park inactive terminal tabs without local snapshot recovery', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
{
|
||||
...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
ptyId: 'ssh:ssh-1@@pty-1'
|
||||
},
|
||||
{
|
||||
...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
ptyId: 'remote:env-1@@terminal-1'
|
||||
}
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['tab-local']))
|
||||
})
|
||||
|
||||
it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
{
|
||||
...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
hasActivityTerminalPortal: true
|
||||
},
|
||||
localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
{
|
||||
...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
pendingActivationSpawn: true
|
||||
}
|
||||
],
|
||||
pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } },
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTerminalWorktreeColdParkRecheckDelayMs', () => {
|
||||
it('returns the next cold-park policy deadline', () => {
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: null,
|
||||
nowMs: 1_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(50)
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_100,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(900)
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 2_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('schedules no recheck when the settings kill switch disables parking', () => {
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: false,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTerminalTabColdParkRecheckDelayMs', () => {
|
||||
it('returns the next terminal-tab cold-park policy deadline', () => {
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: null,
|
||||
nowMs: 1_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(50)
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_100,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(900)
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 2_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('schedules no recheck when the settings kill switch disables parking', () => {
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: false,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,285 @@
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format'
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
|
||||
// Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab
|
||||
// flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible
|
||||
// working set warm for 5 minutes beyond that.
|
||||
export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000
|
||||
export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000
|
||||
export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4
|
||||
export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
|
||||
export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000
|
||||
export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000
|
||||
export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12
|
||||
|
||||
// Why: tests override these per call (instead of process.env reads inside the
|
||||
// module) to shrink the 30s hysteresis to test-friendly durations.
|
||||
export type TerminalColdParkPolicyOverrides = {
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
hotRetainLimit?: number
|
||||
}
|
||||
|
||||
export type ColdParkableTerminalTab = Pick<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>
|
||||
|
||||
export type TerminalWorktreeColdParkCandidate = {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly ColdParkableTerminalTab[]
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hiddenSinceMs: number | null
|
||||
}
|
||||
|
||||
export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & {
|
||||
isVisible: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hiddenSinceMs: number | null
|
||||
}
|
||||
|
||||
function getPendingActivationSpawnCount(value: boolean | number | undefined): number {
|
||||
if (value === true) {
|
||||
return 1
|
||||
}
|
||||
return typeof value === 'number' && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
// Why: parking relies on the daemon model snapshot to re-hydrate. Remote
|
||||
// runtime and SSH PTYs have no local snapshot in this phase, and a session id
|
||||
// minted for another worktree reattaches through a path parking cannot replay.
|
||||
export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean {
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) {
|
||||
return false
|
||||
}
|
||||
// Why: separator-less ids come from the daemon-fail-open LocalPtyProvider;
|
||||
// they have no daemon session model, so revealing a parked pane would
|
||||
// silently respawn a fresh shell instead of restoring the snapshot.
|
||||
const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR)
|
||||
return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId
|
||||
}
|
||||
|
||||
export function canParkTerminalWorktreeRenderers(args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly ColdParkableTerminalTab[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
// Why: callers pass settings.terminalHiddenViewParking !== false — the
|
||||
// design-doc kill switch that disables parking entirely.
|
||||
parkingEnabled: boolean
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
}): boolean {
|
||||
if (
|
||||
!args.parkingEnabled ||
|
||||
args.isVisible ||
|
||||
args.shouldMeasureHiddenWorktree ||
|
||||
args.hasActivityTerminalPortal ||
|
||||
args.hiddenSinceMs === null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
args.nowMs - args.hiddenSinceMs <
|
||||
(args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return args.terminalTabs.every((tab) => {
|
||||
if (args.pendingStartupByTabId[tab.id] !== undefined) {
|
||||
return false
|
||||
}
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
return false
|
||||
}
|
||||
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
|
||||
})
|
||||
}
|
||||
|
||||
export function canParkTerminalTabRenderer(args: {
|
||||
worktreeId: string
|
||||
terminalTab: TerminalTabColdParkCandidate
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
parkingEnabled: boolean
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
}): boolean {
|
||||
const tab = args.terminalTab
|
||||
if (
|
||||
!args.parkingEnabled ||
|
||||
tab.isVisible ||
|
||||
tab.hasActivityTerminalPortal ||
|
||||
tab.hiddenSinceMs === null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) {
|
||||
return false
|
||||
}
|
||||
if (args.pendingStartupByTabId[tab.id] !== undefined) {
|
||||
return false
|
||||
}
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
return false
|
||||
}
|
||||
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
|
||||
}
|
||||
|
||||
type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number }
|
||||
|
||||
// Why: hot-retain keeps the most recently hidden ids warm up to the limit;
|
||||
// ids hidden past hotRetainMs or beyond the limit cold-park. Ties sort by id
|
||||
// so the selection is deterministic.
|
||||
function selectIdsBeyondHotRetain(
|
||||
candidates: ColdParkRetainCandidate[],
|
||||
args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number }
|
||||
): Set<string> {
|
||||
const coldParkedIds = new Set<string>()
|
||||
const retainedCandidates: ColdParkRetainCandidate[] = []
|
||||
for (const candidate of candidates) {
|
||||
if (args.nowMs - candidate.hiddenSinceMs >= args.hotRetainMs) {
|
||||
coldParkedIds.add(candidate.id)
|
||||
} else {
|
||||
retainedCandidates.push(candidate)
|
||||
}
|
||||
}
|
||||
retainedCandidates.sort((a, b) => {
|
||||
const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs
|
||||
return recencyDelta === 0 ? a.id.localeCompare(b.id) : recencyDelta
|
||||
})
|
||||
for (const candidate of retainedCandidates.slice(Math.max(0, args.hotRetainLimit))) {
|
||||
coldParkedIds.add(candidate.id)
|
||||
}
|
||||
return coldParkedIds
|
||||
}
|
||||
|
||||
export function selectColdParkedTerminalWorktrees(
|
||||
args: {
|
||||
worktrees: readonly TerminalWorktreeColdParkCandidate[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
parkingEnabled: boolean
|
||||
nowMs: number
|
||||
} & TerminalColdParkPolicyOverrides
|
||||
): Set<string> {
|
||||
if (!args.parkingEnabled) {
|
||||
return new Set()
|
||||
}
|
||||
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
|
||||
const candidates: ColdParkRetainCandidate[] = []
|
||||
for (const worktree of args.worktrees) {
|
||||
if (
|
||||
worktree.hiddenSinceMs === null ||
|
||||
!canParkTerminalWorktreeRenderers({
|
||||
...worktree,
|
||||
pendingStartupByTabId: args.pendingStartupByTabId,
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs })
|
||||
}
|
||||
return selectIdsBeyondHotRetain(candidates, {
|
||||
nowMs: args.nowMs,
|
||||
hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS,
|
||||
hotRetainLimit: args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT
|
||||
})
|
||||
}
|
||||
|
||||
export function selectColdParkedTerminalTabs(
|
||||
args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly TerminalTabColdParkCandidate[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
parkingEnabled: boolean
|
||||
nowMs: number
|
||||
} & TerminalColdParkPolicyOverrides
|
||||
): Set<string> {
|
||||
if (!args.parkingEnabled) {
|
||||
return new Set()
|
||||
}
|
||||
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS
|
||||
const candidates: ColdParkRetainCandidate[] = []
|
||||
for (const tab of args.terminalTabs) {
|
||||
if (
|
||||
tab.hiddenSinceMs === null ||
|
||||
!canParkTerminalTabRenderer({
|
||||
worktreeId: args.worktreeId,
|
||||
terminalTab: tab,
|
||||
pendingStartupByTabId: args.pendingStartupByTabId,
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
candidates.push({ id: tab.id, hiddenSinceMs: tab.hiddenSinceMs })
|
||||
}
|
||||
return selectIdsBeyondHotRetain(candidates, {
|
||||
nowMs: args.nowMs,
|
||||
hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS,
|
||||
hotRetainLimit: args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT
|
||||
})
|
||||
}
|
||||
|
||||
// Why: parking decisions change only at the cold-park and hot-retain
|
||||
// deadlines, so callers schedule one recheck at the next deadline instead of
|
||||
// polling.
|
||||
function nextColdParkDeadlineDelayMs(args: {
|
||||
parkingEnabled: boolean
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs: number
|
||||
hotRetainMs: number
|
||||
}): number | null {
|
||||
if (!args.parkingEnabled || args.hiddenSinceMs === null) {
|
||||
return null
|
||||
}
|
||||
const pendingDeadlines = [
|
||||
args.hiddenSinceMs + args.coldParkDelayMs,
|
||||
args.hiddenSinceMs + args.hotRetainMs
|
||||
].filter((deadlineMs) => deadlineMs > args.nowMs)
|
||||
return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs
|
||||
}
|
||||
|
||||
export function getTerminalWorktreeColdParkRecheckDelayMs(args: {
|
||||
parkingEnabled: boolean
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
}): number | null {
|
||||
return nextColdParkDeadlineDelayMs({
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
hiddenSinceMs: args.hiddenSinceMs,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
|
||||
hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getTerminalTabColdParkRecheckDelayMs(args: {
|
||||
parkingEnabled: boolean
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
}): number | null {
|
||||
return nextColdParkDeadlineDelayMs({
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
hiddenSinceMs: args.hiddenSinceMs,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
|
||||
hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const OTHER_WORKTREE_ID = 'repo::/other-worktree'
|
||||
const TAB_ID = 'tab-1'
|
||||
const PTY_ID = `${WORKTREE_ID}@@session-1`
|
||||
const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2`
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
type StartedWatcher = {
|
||||
options: ParkedTerminalByteWatcherOptions
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const startedWatchers: StartedWatcher[] = []
|
||||
const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => {
|
||||
const dispose = vi.fn()
|
||||
startedWatchers.push({ options, dispose })
|
||||
return dispose
|
||||
})
|
||||
|
||||
vi.mock('./parked-terminal-byte-watcher', () => ({
|
||||
startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) =>
|
||||
startParkedTerminalByteWatcher(options)
|
||||
}))
|
||||
|
||||
type ExitSubscription = {
|
||||
ptyId: string
|
||||
callback: (code: number) => void
|
||||
unsubscribe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const exitSubscriptions: ExitSubscription[] = []
|
||||
const subscribeToPtyExit = vi.fn((ptyId: string, callback: (code: number) => void) => {
|
||||
const unsubscribe = vi.fn()
|
||||
exitSubscriptions.push({ ptyId, callback, unsubscribe })
|
||||
return unsubscribe
|
||||
})
|
||||
|
||||
vi.mock('./pty-dispatcher', () => ({
|
||||
subscribeToPtyExit: (ptyId: string, callback: (code: number) => void) =>
|
||||
subscribeToPtyExit(ptyId, callback)
|
||||
}))
|
||||
|
||||
type MockStoreState = {
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
{
|
||||
root: unknown
|
||||
activeLeafId: string | null
|
||||
expandedLeafId: string | null
|
||||
ptyIdsByLeafId?: Record<string, string>
|
||||
}
|
||||
>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let mockStoreState: MockStoreState
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mockStoreState }
|
||||
}))
|
||||
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
captureParkedTerminalPaneCandidates,
|
||||
disposeParkedTerminalWatchersForPtyIds,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
fallbackParkedPaneCandidates,
|
||||
getParkedTerminalWatcherTabIds,
|
||||
pruneParkedTerminalWatchers,
|
||||
shouldDeferParkedPtyExitTabClose,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-parked-tab-watchers'
|
||||
|
||||
const ptyWrite = vi.fn()
|
||||
const originalWindow = (globalThis as { window?: unknown }).window
|
||||
|
||||
function capturePanes(
|
||||
panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[],
|
||||
args?: { tabId?: string; worktreeId?: string }
|
||||
): void {
|
||||
captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes)
|
||||
}
|
||||
|
||||
function syncParked(args?: {
|
||||
worktreeId?: string
|
||||
tabs?: { id: string; ptyId: string | null }[]
|
||||
parkedTabIds?: Iterable<string>
|
||||
}): void {
|
||||
syncParkedTerminalTabWatchers({
|
||||
worktreeId: args?.worktreeId ?? WORKTREE_ID,
|
||||
tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }],
|
||||
parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID])
|
||||
})
|
||||
}
|
||||
|
||||
describe('terminal-parked-tab-watchers', () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState = {
|
||||
terminalLayoutsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
clearRuntimePaneTitle: vi.fn()
|
||||
}
|
||||
;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Module-level registries persist across tests; clear them through the
|
||||
// public prune path so each test starts from an empty parked state.
|
||||
pruneParkedTerminalWatchers(new Set())
|
||||
startedWatchers.length = 0
|
||||
exitSubscriptions.length = 0
|
||||
vi.clearAllMocks()
|
||||
;(globalThis as { window?: unknown }).window = originalWindow
|
||||
})
|
||||
|
||||
it('starts one watcher per captured snapshot-backed PTY with the captured pane identity', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
|
||||
expect(startedWatchers[0].options).toMatchObject({
|
||||
ptyId: PTY_ID,
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneId: 1,
|
||||
drivesTabTitle: true
|
||||
})
|
||||
expect(startedWatchers[1].options).toMatchObject({
|
||||
ptyId: SECOND_PTY_ID,
|
||||
paneId: 2,
|
||||
drivesTabTitle: false
|
||||
})
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('routes watcher sendInput to window.api.pty.write for the watched PTY', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
startedWatchers[0].options.sendInput('\x1b[?2031;1$y')
|
||||
expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y')
|
||||
})
|
||||
|
||||
it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID })
|
||||
})
|
||||
|
||||
it('never starts watchers for remote-runtime or SSH PTYs', () => {
|
||||
capturePanes([
|
||||
{ ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
|
||||
|
||||
expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled()
|
||||
// Why: the tab is still tracked as parked so debug introspection
|
||||
// (window.__terminalParkingDebug) reflects every parked tab.
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('keeps existing watchers across repeated syncs of the same parked state', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
syncParked()
|
||||
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[0].dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes the watcher and exit subscription when the tab unparks', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
syncParked({ parkedTabIds: [] })
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('disposes the watcher when the tab closes while parked', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
syncParked({ tabs: [], parkedTabIds: [TAB_ID] })
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('disposes a PTY watcher when that PTY exits while parked', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
const exited = exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)
|
||||
exited?.callback(0)
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[1].dispose).not.toHaveBeenCalled()
|
||||
// The tab itself is still parked, only the exited PTY's watcher is gone.
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('seeds each watcher with the pane slot last known runtime title', () => {
|
||||
mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Build feature' } }
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
expect(startedWatchers[0].options.initialTitle).toBe('⠋ Build feature')
|
||||
expect(startedWatchers[1].options.initialTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops the parked tab entry when the pty-exit sidecar disposes the last watcher', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
disposeParkedTerminalWatchersForPtyIds([PTY_ID])
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[1].dispose).not.toHaveBeenCalled()
|
||||
// Why: the entry survives so a sleeping parked tab cannot restart a
|
||||
// watcher against its stale PTY ids before wake re-mints them.
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
syncParked()
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('restarts watchers from store layout when the tab PTY was re-minted', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
const remintedPtyId = `${WORKTREE_ID}@@session-after-wake`
|
||||
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId }
|
||||
}
|
||||
syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] })
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
|
||||
expect(startedWatchers[1].options).toMatchObject({ ptyId: remintedPtyId, leafId: LEAF_ID })
|
||||
})
|
||||
|
||||
it('scopes sync disposal to the given worktree', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
const otherPtyId = `${OTHER_WORKTREE_ID}@@session-9`
|
||||
capturePanes([{ ptyId: otherPtyId, paneId: 1, leafId: SECOND_LEAF_ID, drivesTabTitle: true }], {
|
||||
tabId: 'tab-other',
|
||||
worktreeId: OTHER_WORKTREE_ID
|
||||
})
|
||||
syncParked({
|
||||
worktreeId: OTHER_WORKTREE_ID,
|
||||
tabs: [{ id: 'tab-other', ptyId: otherPtyId }],
|
||||
parkedTabIds: ['tab-other']
|
||||
})
|
||||
|
||||
// Unparking everything in the other worktree must not touch this one.
|
||||
syncParked({ worktreeId: OTHER_WORKTREE_ID, tabs: [], parkedTabIds: [] })
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
expect(startedWatchers[0].dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes all of a worktree watchers on worktree teardown and prunes deleted worktrees', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
disposeParkedTerminalWatchersForWorktree(WORKTREE_ID)
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
pruneParkedTerminalWatchers(new Set([OTHER_WORKTREE_ID]))
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
describe('shouldDeferParkedPtyExitTabClose', () => {
|
||||
const closeTab = vi.fn()
|
||||
|
||||
// Mirrors both hosts' onPtyExit wiring: the guard runs before closeTab.
|
||||
function hostOnPtyExit(tabId: string, ptyId: string): void {
|
||||
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}
|
||||
|
||||
it('defers tab close on PTY exit in a parked multi-leaf tab and clears the dead slot', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
hostOnPtyExit(TAB_ID, PTY_ID)
|
||||
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
// The dead leaf's runtime-title slot cannot pin worktree status.
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1)
|
||||
})
|
||||
|
||||
it('keeps exit→closeTab parity for a parked single-leaf tab', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
hostOnPtyExit(TAB_ID, PTY_ID)
|
||||
|
||||
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
|
||||
})
|
||||
|
||||
it('keeps exit→closeTab parity when the tab is not parked', () => {
|
||||
hostOnPtyExit(TAB_ID, PTY_ID)
|
||||
|
||||
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
|
||||
})
|
||||
|
||||
it('closes the tab when the last surviving leaf of a parked split exits', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
// First leaf dies: deferred, then its exit sidecar drops the watcher.
|
||||
hostOnPtyExit(TAB_ID, PTY_ID)
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
|
||||
hostOnPtyExit(TAB_ID, SECOND_PTY_ID)
|
||||
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canWatcherCoverParkedTerminalTab', () => {
|
||||
it('rejects a tab with no unmount capture and no layout snapshot', () => {
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a current capture whose panes are all snapshot-backed', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a capture containing a legacy non-UUID leaf id', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a capture containing a PTY without snapshot backing', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts layout-derived candidates when the capture is stale', () => {
|
||||
capturePanes([{ ptyId: 'old-pty', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
}
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects layout-derived candidates missing a leaf PTY binding', () => {
|
||||
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'row',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
|
||||
},
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
}
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fallbackParkedPaneCandidates', () => {
|
||||
it('returns nothing without a layout snapshot', () => {
|
||||
expect(
|
||||
fallbackParkedPaneCandidates(
|
||||
{ id: TAB_ID, ptyId: PTY_ID },
|
||||
{ terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} }
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reuses the single runtime-title slot for a single-pane tab', () => {
|
||||
expect(
|
||||
fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, {
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null }
|
||||
},
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } }
|
||||
} as never)
|
||||
).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
})
|
||||
|
||||
it('maps split leaves to layout PTYs with collision-free negative pane ids', () => {
|
||||
expect(
|
||||
fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, {
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'row',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
|
||||
},
|
||||
activeLeafId: SECOND_LEAF_ID,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID }
|
||||
}
|
||||
},
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } }
|
||||
} as never)
|
||||
).toEqual([
|
||||
{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Parked terminal tab watcher lifecycle.
|
||||
*
|
||||
* Why: parking unmounts a tab's TerminalPane, so its PTYs lose the renderer
|
||||
* byte parsers. This module owns the pane-less replacement: it remembers the
|
||||
* unmounted panes' identities (pane id / leaf id), starts one
|
||||
* parked-terminal-byte-watcher per PTY when a tab parks, and disposes them on
|
||||
* reveal, tab close, PTY exit, or worktree teardown. The bookkeeping maps
|
||||
* live in terminal-parked-watcher-registry so the terminals store slice can
|
||||
* dispose watchers without importing this store-coupled module.
|
||||
* See docs/reference/terminal-hidden-view-parking.md.
|
||||
*/
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids'
|
||||
import { subscribeToPtyExit } from './pty-dispatcher'
|
||||
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
|
||||
import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking'
|
||||
import {
|
||||
capturedPanesByTabId,
|
||||
disposeParkedTabWatchers,
|
||||
parkedWatchersByTabId,
|
||||
type ParkedTerminalPaneCapture
|
||||
} from './terminal-parked-watcher-registry'
|
||||
|
||||
// Why: re-exported so park wiring keeps one import surface; the registry
|
||||
// split exists only to break the store-slice import cycle.
|
||||
export {
|
||||
captureParkedTerminalPaneCandidates,
|
||||
disposeParkedTerminalWatchersForPtyIds,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
getParkedTerminalWatcherTabIds,
|
||||
pruneParkedTerminalWatchers
|
||||
} from './terminal-parked-watcher-registry'
|
||||
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
|
||||
|
||||
export type ParkableTerminalTabModel = Pick<TerminalTab, 'id' | 'ptyId'>
|
||||
|
||||
type ParkedPaneFallbackState = {
|
||||
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
|
||||
runtimePaneTitlesByTabId: ReturnType<typeof useAppStore.getState>['runtimePaneTitlesByTabId']
|
||||
}
|
||||
|
||||
// Why: if no unmount capture exists (or it predates a PTY respawn), derive
|
||||
// pane identities from the persisted layout snapshot. Numeric pane ids are
|
||||
// unknown here: reuse the single existing runtime-title slot when unambiguous
|
||||
// so a stale "working" title still gets overwritten, otherwise use negative
|
||||
// slots that can never collide with real PaneManager ids.
|
||||
export function fallbackParkedPaneCandidates(
|
||||
tab: ParkableTerminalTabModel,
|
||||
state: ParkedPaneFallbackState
|
||||
): ParkedTerminalPaneCapture[] {
|
||||
const layout = state.terminalLayoutsByTabId[tab.id]
|
||||
const leafIds = collectLeafIdsInOrder(layout?.root)
|
||||
if (leafIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {}
|
||||
const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {})
|
||||
const reusableSlot =
|
||||
leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null
|
||||
return leafIds.map((leafId, index) => ({
|
||||
ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null),
|
||||
paneId: reusableSlot ?? -(index + 1),
|
||||
leafId,
|
||||
drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0
|
||||
}))
|
||||
}
|
||||
|
||||
// Why: unmount captures and layout fallbacks must resolve identically for the
|
||||
// watcher start path and the park-eligibility coverage check, or a tab could
|
||||
// pass the check and then start with different (uncoverable) candidates.
|
||||
function resolveParkedTerminalPaneCandidates(
|
||||
tab: ParkableTerminalTabModel,
|
||||
state: ParkedPaneFallbackState
|
||||
): ParkedTerminalPaneCapture[] {
|
||||
const captured = capturedPanesByTabId.get(tab.id)
|
||||
// Why: a capture that no longer mentions the tab's current PTY is stale
|
||||
// (the PTY was re-minted since the unmount); fall back to the layout.
|
||||
const capturedIsCurrent =
|
||||
captured !== undefined &&
|
||||
captured.panes.length > 0 &&
|
||||
(tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId))
|
||||
return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the parked byte watchers can fully cover this tab's PTYs (some
|
||||
* candidate exists and every candidate has a snapshot-backed PTY bound to a
|
||||
* valid leaf). Hosts must refuse to park a tab that fails this check —
|
||||
* parking it would silently drop bell/title/completion side effects, the
|
||||
* exact failure that sank the first parking attempt.
|
||||
*/
|
||||
export function canWatcherCoverParkedTerminalTab(
|
||||
worktreeId: string,
|
||||
tab: ParkableTerminalTabModel
|
||||
): boolean {
|
||||
const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())
|
||||
return (
|
||||
panes.length > 0 &&
|
||||
panes.every(
|
||||
(pane) =>
|
||||
pane.ptyId !== null &&
|
||||
isTerminalLeafId(pane.leafId) &&
|
||||
isSnapshotBackedTerminalPty(pane.ptyId, worktreeId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabModel): void {
|
||||
const state = useAppStore.getState()
|
||||
const panes = resolveParkedTerminalPaneCandidates(tab, state)
|
||||
const disposersByPtyId = new Map<string, () => void>()
|
||||
const paneIdByPtyId = new Map<string, number>()
|
||||
for (const pane of panes) {
|
||||
const ptyId = pane.ptyId
|
||||
// Why: the park policy already excludes non-snapshot-backed PTYs, but the
|
||||
// tab model can change between the park decision and this effect — guard
|
||||
// again so remote-runtime/SSH PTYs never get a local watcher. Legacy
|
||||
// non-UUID leaf ids are skipped because makePaneKey throws on them.
|
||||
if (
|
||||
!ptyId ||
|
||||
disposersByPtyId.has(ptyId) ||
|
||||
!isTerminalLeafId(pane.leafId) ||
|
||||
!isSnapshotBackedTerminalPty(ptyId, worktreeId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId]
|
||||
const disposeWatcher = startParkedTerminalByteWatcher({
|
||||
ptyId,
|
||||
tabId: tab.id,
|
||||
worktreeId,
|
||||
leafId: pane.leafId,
|
||||
paneId: pane.paneId,
|
||||
drivesTabTitle: pane.drivesTabTitle,
|
||||
// Why: seed the watcher's agent tracker with the pane's last known
|
||||
// title so an agent already working at park time still notifies when
|
||||
// it finishes while parked.
|
||||
...(initialTitle !== undefined ? { initialTitle } : {}),
|
||||
// Why: no pane transport exists while parked; write straight to the
|
||||
// PTY, the same channel background agent launches use.
|
||||
sendInput: (data) => window.api.pty.write(ptyId, data)
|
||||
})
|
||||
// Why: a PTY that exits while parked has no pane to run exit cleanup; at
|
||||
// minimum its watcher must not outlive it.
|
||||
const unsubscribeExit = subscribeToPtyExit(ptyId, () => {
|
||||
disposersByPtyId.get(ptyId)?.()
|
||||
disposersByPtyId.delete(ptyId)
|
||||
// Why: with the last watcher gone there is nothing left to watch or
|
||||
// dispose; dropping the entry keeps the registry bounded to parked
|
||||
// tabs that still hold live PTYs.
|
||||
const entry = parkedWatchersByTabId.get(tab.id)
|
||||
if (disposersByPtyId.size === 0 && entry?.disposersByPtyId === disposersByPtyId) {
|
||||
parkedWatchersByTabId.delete(tab.id)
|
||||
}
|
||||
})
|
||||
paneIdByPtyId.set(ptyId, pane.paneId)
|
||||
disposersByPtyId.set(ptyId, () => {
|
||||
unsubscribeExit()
|
||||
disposeWatcher()
|
||||
})
|
||||
}
|
||||
// Why: tracked even with zero watchers so parked-state introspection
|
||||
// (window.__terminalParkingDebug) reflects every parked tab.
|
||||
parkedWatchersByTabId.set(tab.id, {
|
||||
worktreeId,
|
||||
tabPtyId: tab.ptyId,
|
||||
paneIdByPtyId,
|
||||
disposersByPtyId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts call this from their onPtyExit handlers before closing the tab.
|
||||
* Returns true when the close must be deferred: a parked tab has no
|
||||
* PaneManager to promote split siblings, so the live exit path degenerates to
|
||||
* "close the whole tab" — which would kill the surviving sibling panes. The
|
||||
* reveal remount handles dead PTYs per leaf instead. Single-leaf parked tabs
|
||||
* return false so exit→closeTab parity is preserved. Also clears the dead
|
||||
* leaf's runtime-title slot so a stale title cannot pin worktree status.
|
||||
*/
|
||||
export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): boolean {
|
||||
const entry = parkedWatchersByTabId.get(tabId)
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
const paneId = entry.paneIdByPtyId.get(ptyId)
|
||||
if (paneId !== undefined) {
|
||||
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
|
||||
}
|
||||
const remaining = entry.disposersByPtyId.size
|
||||
if (remaining === 0) {
|
||||
return false
|
||||
}
|
||||
// Why: this runs from the PTY exit handler, before the exit sidecar above
|
||||
// removes the dead PTY's watcher — so the watcher count still includes the
|
||||
// exiting PTY. More than one watcher (or an exit for an unwatched PTY)
|
||||
// means live sibling leaves remain.
|
||||
return remaining > 1 || !entry.disposersByPtyId.has(ptyId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles watchers for one worktree against its rendered parked set.
|
||||
* Callers run this from an effect keyed on the committed render state, so
|
||||
* disposal lands in the same effect flush as a reveal remount (before any
|
||||
* PTY data IPC can be delivered) and start lands after the park unmount.
|
||||
*/
|
||||
export function syncParkedTerminalTabWatchers(args: {
|
||||
worktreeId: string
|
||||
tabs: readonly ParkableTerminalTabModel[]
|
||||
parkedTabIds: ReadonlySet<string>
|
||||
}): void {
|
||||
const liveTabIds = new Set(args.tabs.map((tab) => tab.id))
|
||||
for (const [tabId, entry] of parkedWatchersByTabId) {
|
||||
if (entry.worktreeId !== args.worktreeId) {
|
||||
continue
|
||||
}
|
||||
if (!args.parkedTabIds.has(tabId) || !liveTabIds.has(tabId)) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
}
|
||||
// Why: captures for closed tabs have no future park/reveal; drop them so
|
||||
// the registry stays bounded by live tabs.
|
||||
for (const [tabId, capture] of capturedPanesByTabId) {
|
||||
if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) {
|
||||
capturedPanesByTabId.delete(tabId)
|
||||
}
|
||||
}
|
||||
for (const tab of args.tabs) {
|
||||
if (!args.parkedTabIds.has(tab.id)) {
|
||||
continue
|
||||
}
|
||||
const entry = parkedWatchersByTabId.get(tab.id)
|
||||
if (entry && entry.tabPtyId !== tab.ptyId) {
|
||||
disposeParkedTabWatchers(tab.id)
|
||||
}
|
||||
if (!parkedWatchersByTabId.has(tab.id)) {
|
||||
startParkedTabWatchers(args.worktreeId, tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Parked terminal watcher registry (store-free bookkeeping).
|
||||
*
|
||||
* Why a separate module: shutdownWorktreeTerminals (a store slice) must
|
||||
* synchronously dispose parked watchers, but the watcher lifecycle module
|
||||
* imports the store — a slice importing it would re-enter store creation
|
||||
* mid-evaluation. Keeping the maps and pure disposal here lets the slice
|
||||
* import cycle-free, mirroring how pty-dispatcher exports its handler maps.
|
||||
*/
|
||||
|
||||
export type ParkedTerminalPaneCapture = {
|
||||
ptyId: string | null
|
||||
/** PaneManager numeric pane id the live pane used for runtime titles. */
|
||||
paneId: number
|
||||
/** Stable terminal-layout leaf UUID (paneKey attribution). */
|
||||
leafId: string
|
||||
drivesTabTitle: boolean
|
||||
}
|
||||
|
||||
export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] }
|
||||
|
||||
export const capturedPanesByTabId = new Map<string, CapturedTabPanes>()
|
||||
|
||||
// Why: PaneManager pane ids die with the unmounted pane, but the watcher must
|
||||
// keep writing the exact runtime-title slots the live pane used — a different
|
||||
// slot would strand a stale "working" title that pins worktree status.
|
||||
// TerminalPane unmount records the identities here for the park wiring.
|
||||
export function captureParkedTerminalPaneCandidates(
|
||||
tabId: string,
|
||||
worktreeId: string,
|
||||
panes: ParkedTerminalPaneCapture[]
|
||||
): void {
|
||||
capturedPanesByTabId.set(tabId, { worktreeId, panes })
|
||||
}
|
||||
|
||||
export type ParkedTabWatcherEntry = {
|
||||
worktreeId: string
|
||||
/** Tab-level ptyId at watcher start; a change means the PTY was re-minted
|
||||
* (e.g. wake respawn) and the watchers must restart against fresh ids. */
|
||||
tabPtyId: string | null
|
||||
/** Runtime-title slot each watcher writes, so parked PTY-exit handling can
|
||||
* clear the dead leaf's slot (no live pane will ever overwrite it). */
|
||||
paneIdByPtyId: Map<string, number>
|
||||
disposersByPtyId: Map<string, () => void>
|
||||
}
|
||||
|
||||
export const parkedWatchersByTabId = new Map<string, ParkedTabWatcherEntry>()
|
||||
|
||||
export function getParkedTerminalWatcherTabIds(): string[] {
|
||||
return Array.from(parkedWatchersByTabId.keys())
|
||||
}
|
||||
|
||||
export function disposeParkedTabWatchers(tabId: string): void {
|
||||
const entry = parkedWatchersByTabId.get(tabId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
parkedWatchersByTabId.delete(tabId)
|
||||
for (const dispose of entry.disposersByPtyId.values()) {
|
||||
dispose()
|
||||
}
|
||||
entry.disposersByPtyId.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously disposes any parked watcher subscribed to these PTYs.
|
||||
* shutdownWorktreeTerminals silences the live transports' final teardown
|
||||
* flush via unregisterPtyDataHandlers, but parked watchers ride the
|
||||
* dispatcher SIDECAR channel that call does not touch — without this, the
|
||||
* flush still marks unread and arms notification timers for a worktree that
|
||||
* is already sleeping or deleted. The tab entries are kept so a sleeping
|
||||
* parked tab does not restart watchers against its stale PTY ids; wake
|
||||
* re-mints the ids and the sync path restarts watchers then.
|
||||
*/
|
||||
export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[]): void {
|
||||
for (const entry of parkedWatchersByTabId.values()) {
|
||||
for (const ptyId of ptyIds) {
|
||||
const dispose = entry.disposersByPtyId.get(ptyId)
|
||||
if (dispose) {
|
||||
entry.disposersByPtyId.delete(ptyId)
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeParkedTerminalWatchersForWorktree(worktreeId: string): void {
|
||||
for (const [tabId, entry] of parkedWatchersByTabId) {
|
||||
if (entry.worktreeId === worktreeId) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops watchers and captures for worktrees that no longer exist. */
|
||||
export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet<string>): void {
|
||||
for (const [tabId, entry] of parkedWatchersByTabId) {
|
||||
if (!liveWorktreeIds.has(entry.worktreeId)) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
}
|
||||
for (const [tabId, capture] of capturedPanesByTabId) {
|
||||
if (!liveWorktreeIds.has(capture.worktreeId)) {
|
||||
capturedPanesByTabId.delete(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null }
|
||||
|
||||
let mockE2EConfig: MockE2EConfig
|
||||
|
||||
vi.mock('@/lib/e2e-config', () => ({
|
||||
get e2eConfig() {
|
||||
return mockE2EConfig
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-parked-tab-watchers', () => ({
|
||||
getParkedTerminalWatcherTabIds: () => ['tab-parked']
|
||||
}))
|
||||
|
||||
const originalWindow = (globalThis as { window?: unknown }).window
|
||||
|
||||
type TerminalParkingE2EOverridesModule = {
|
||||
getTerminalParkingPolicyOverrides: () => {
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
hotRetainLimit?: number
|
||||
}
|
||||
registerTerminalParkingDebugHandle: () => void
|
||||
}
|
||||
|
||||
async function importOverridesModule(): Promise<TerminalParkingE2EOverridesModule> {
|
||||
vi.resetModules()
|
||||
return import('./terminal-parking-e2e-overrides')
|
||||
}
|
||||
|
||||
describe('getTerminalParkingPolicyOverrides', () => {
|
||||
beforeEach(() => {
|
||||
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
;(globalThis as { window?: unknown }).window = originalWindow
|
||||
})
|
||||
|
||||
it('ignores the delay override outside e2e (exposeStore off)', async () => {
|
||||
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 }
|
||||
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
|
||||
expect(getTerminalParkingPolicyOverrides()).toEqual({})
|
||||
})
|
||||
|
||||
it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => {
|
||||
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
|
||||
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
|
||||
expect(getTerminalParkingPolicyOverrides()).toEqual({
|
||||
coldParkDelayMs: 500,
|
||||
hotRetainMs: 500
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no overrides when no delay is configured', async () => {
|
||||
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null }
|
||||
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
|
||||
expect(getTerminalParkingPolicyOverrides()).toEqual({})
|
||||
})
|
||||
|
||||
it('registers window.__terminalParkingDebug on import under exposeStore', async () => {
|
||||
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
|
||||
const testWindow: {
|
||||
__terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] }
|
||||
} = {}
|
||||
;(globalThis as { window?: unknown }).window = testWindow
|
||||
await importOverridesModule()
|
||||
expect(testWindow.__terminalParkingDebug?.parkDelayMs).toBe(500)
|
||||
expect(testWindow.__terminalParkingDebug?.parkedTabIds()).toEqual(['tab-parked'])
|
||||
})
|
||||
|
||||
it('does not register the debug handle outside e2e', async () => {
|
||||
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
|
||||
const testWindow: { __terminalParkingDebug?: unknown } = {}
|
||||
;(globalThis as { window?: unknown }).window = testWindow
|
||||
await importOverridesModule()
|
||||
expect(testWindow.__terminalParkingDebug).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import {
|
||||
TERMINAL_TAB_COLD_PARK_DELAY_MS,
|
||||
type TerminalColdParkPolicyOverrides
|
||||
} from './terminal-hidden-view-parking'
|
||||
import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers'
|
||||
|
||||
// Why: ORCA_E2E_TERMINAL_PARKING_DELAY_MS must shrink BOTH the cold-park
|
||||
// hysteresis and the hot-retain window — recently hidden tabs otherwise sit
|
||||
// in the hot-retain working set for 5 minutes and never park within a test
|
||||
// run. Gated on exposeStore so packaged builds ignore stray env vars.
|
||||
export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides {
|
||||
const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null
|
||||
return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0
|
||||
? { coldParkDelayMs: delayMs, hotRetainMs: delayMs }
|
||||
: {}
|
||||
}
|
||||
|
||||
export function registerTerminalParkingDebugHandle(): void {
|
||||
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.__terminalParkingDebug = {
|
||||
parkDelayMs:
|
||||
getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
|
||||
parkedTabIds: () => getParkedTerminalWatcherTabIds()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the parking e2e spec gates on window.__terminalParkingDebug existing
|
||||
// shortly after launch. This module is statically imported by the park
|
||||
// wiring, so registering at module load makes the handle visible before any
|
||||
// tab parks.
|
||||
registerTerminalParkingDebugHandle()
|
||||
@@ -0,0 +1,509 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSideEffectBatch } from '../../../../shared/terminal-side-effect-facts'
|
||||
import {
|
||||
_dispatchTerminalSideEffectBatchForTest,
|
||||
_resetTerminalSideEffectFactConsumersForTest,
|
||||
isMainTerminalSideEffectAuthorityForPty,
|
||||
registerTerminalSideEffectFactConsumer,
|
||||
type TerminalSideEffectFactConsumerCallbacks
|
||||
} from './terminal-side-effect-facts-handler'
|
||||
|
||||
const PTY_ID = 'wt-1#1'
|
||||
|
||||
function createCallbackRecorder(): {
|
||||
callbacks: TerminalSideEffectFactConsumerCallbacks
|
||||
events: unknown[][]
|
||||
} {
|
||||
const events: unknown[][] = []
|
||||
return {
|
||||
events,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle, rawTitle) =>
|
||||
events.push(['title', normalizedTitle, rawTitle]),
|
||||
onBell: () => events.push(['bell']),
|
||||
onAgentBecameIdle: (title) => events.push(['idle', title]),
|
||||
onAgentBecameWorking: () => events.push(['working']),
|
||||
onAgentExited: () => events.push(['exited'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function batch(
|
||||
facts: TerminalSideEffectBatch['facts'],
|
||||
options: Partial<TerminalSideEffectBatch> = {}
|
||||
): TerminalSideEffectBatch {
|
||||
return { ptyId: PTY_ID, seq: 0, facts, ...options }
|
||||
}
|
||||
|
||||
describe('isMainTerminalSideEffectAuthorityForPty', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
|
||||
beforeEach(() => {
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
function setPersistedSettingsSync(settings: unknown): void {
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: { settings: { getSync: () => settings } }
|
||||
}
|
||||
}
|
||||
|
||||
it('is on by default for PTYs whose bytes transit local main', () => {
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: {}, runtimeEnvironmentId: null })
|
||||
).toBe(true)
|
||||
// Why: settings hydrate asynchronously; the default-on switch must not
|
||||
// flip authority off during the null-settings startup window.
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is off for remote-runtime PTYs regardless of the setting', () => {
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: { terminalMainSideEffectAuthority: true },
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('is off when the kill switch is disabled', () => {
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: { terminalMainSideEffectAuthority: false },
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('honors the persisted kill switch before settings hydrate', () => {
|
||||
// Why: the authority decision is made once at transport creation; a pane
|
||||
// bound during startup must not pick main authority when the user
|
||||
// persisted the switch off.
|
||||
setPersistedSettingsSync({ terminalMainSideEffectAuthority: false })
|
||||
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stays on pre-hydration when the persisted switch is on or unset', () => {
|
||||
setPersistedSettingsSync({ terminalMainSideEffectAuthority: true })
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
).toBe(true)
|
||||
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
setPersistedSettingsSync({})
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers hydrated settings over the persisted sync read', () => {
|
||||
setPersistedSettingsSync({ terminalMainSideEffectAuthority: false })
|
||||
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: { terminalMainSideEffectAuthority: true },
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('caches the sync read so panes do not re-block per bind', () => {
|
||||
const getSync = vi.fn(() => ({ terminalMainSideEffectAuthority: false }))
|
||||
;(globalThis as { window: unknown }).window = { api: { settings: { getSync } } }
|
||||
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
|
||||
expect(getSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerTerminalSideEffectFactConsumer', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
|
||||
beforeEach(() => {
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('routes live facts to the registered consumer in batch order', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' },
|
||||
{ kind: 'agent-working' },
|
||||
{ kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' },
|
||||
{ kind: 'agent-idle', title: '✳ Claude' },
|
||||
{ kind: 'bell' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['title', '⠋ Claude', '⠋ Claude'],
|
||||
['working'],
|
||||
['title', '✳ Claude', '✳ Claude'],
|
||||
['idle', '✳ Claude'],
|
||||
['bell']
|
||||
])
|
||||
})
|
||||
|
||||
it('routes command-finished and pr-link facts to the registered consumer', () => {
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
|
||||
onPrLink: (link) => events.push(['pr', link.url, link.number])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([
|
||||
{ kind: 'command-finished', exitCode: 130 },
|
||||
{
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
number: 42
|
||||
}
|
||||
},
|
||||
{ kind: 'command-finished', exitCode: null }
|
||||
])
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['finished', 130],
|
||||
['pr', 'https://github.com/acme/orca/pull/42', 42],
|
||||
['finished', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('routes command-code scrape facts to the registered consumer', () => {
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]),
|
||||
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([
|
||||
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
|
||||
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['cc-working', 'Fix the spinner'],
|
||||
['cc-done', 'Fix the spinner']
|
||||
])
|
||||
})
|
||||
|
||||
it('never replays command-code scrape facts', () => {
|
||||
// Why: a replayed working/done seed would resurrect a finished turn's
|
||||
// status row — replay batches restore title state only.
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
|
||||
onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]),
|
||||
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
|
||||
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
|
||||
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
|
||||
],
|
||||
{ replay: true, seq: 5 }
|
||||
)
|
||||
)
|
||||
|
||||
expect(events).toEqual([['title', 'restored']])
|
||||
})
|
||||
|
||||
it('routes 2031-subscribe facts to the registered consumer but never replays them', () => {
|
||||
// Why: the fact lets hidden-delivery-gated views answer the color-scheme
|
||||
// query without byte access; a replayed subscribe would re-answer a query
|
||||
// the snapshot already satisfied.
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
|
||||
onMode2031Subscribe: () => events.push(['2031-subscribe'])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: '2031-subscribe' }]))
|
||||
expect(events).toEqual([['2031-subscribe']])
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
|
||||
{ kind: '2031-subscribe' }
|
||||
],
|
||||
{ replay: true, seq: 5 }
|
||||
)
|
||||
)
|
||||
expect(events).toEqual([['2031-subscribe'], ['title', 'restored']])
|
||||
})
|
||||
|
||||
it('never replays command-finished or pr-link facts', () => {
|
||||
// Why: like bells and agent transitions, command/PR facts are attention
|
||||
// signals — replay snapshots restore title state only.
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
|
||||
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
|
||||
onPrLink: (link) => events.push(['pr', link.url])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
|
||||
{ kind: 'command-finished', exitCode: 0 },
|
||||
{
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
number: 42
|
||||
}
|
||||
}
|
||||
],
|
||||
{ replay: true, seq: 5 }
|
||||
)
|
||||
)
|
||||
|
||||
expect(events).toEqual([['title', 'restored']])
|
||||
})
|
||||
|
||||
it('passes stale-clear provenance through to the title and idle callbacks', () => {
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle, _rawTitle, meta) =>
|
||||
events.push(['title', normalizedTitle, meta]),
|
||||
onAgentBecameIdle: (title, meta) => events.push(['idle', title, meta])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([
|
||||
{ kind: 'agent-idle', title: 'Codex done' },
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Codex',
|
||||
rawTitle: 'Codex',
|
||||
staleWorkingTitleClear: true
|
||||
},
|
||||
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
|
||||
])
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['idle', 'Codex done', undefined],
|
||||
['title', 'Codex', { staleWorkingTitleClear: true }],
|
||||
['idle', 'Codex', { staleWorkingTitleClear: true }]
|
||||
])
|
||||
})
|
||||
|
||||
it('drops batches for PTYs without a registered consumer', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'other-pty' }))
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('applies only title facts from replay batches — no attention replay', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' },
|
||||
{ kind: 'bell' },
|
||||
{ kind: 'agent-idle', title: '✳ Claude' }
|
||||
],
|
||||
{ replay: true, seq: 10 }
|
||||
)
|
||||
)
|
||||
|
||||
expect(events).toEqual([['title', '✳ Claude', '✳ Claude']])
|
||||
})
|
||||
|
||||
it('drops a replay title not newer than the last applied live title', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 })
|
||||
)
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], {
|
||||
replay: true,
|
||||
seq: 20
|
||||
})
|
||||
)
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([{ kind: 'title', normalizedTitle: 'newer', rawTitle: 'newer' }], {
|
||||
replay: true,
|
||||
seq: 21
|
||||
})
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['title', 'live', 'live'],
|
||||
['title', 'newer', 'newer']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps exactly one consumer per PTY: a new registration replaces the old', () => {
|
||||
const first = createCallbackRecorder()
|
||||
const second = createCallbackRecorder()
|
||||
const disposeFirst = registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: first.callbacks
|
||||
})
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
|
||||
// A stale registration's dispose must not evict the live consumer.
|
||||
disposeFirst()
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
|
||||
|
||||
expect(first.events).toEqual([])
|
||||
expect(second.events).toEqual([['bell'], ['bell']])
|
||||
})
|
||||
|
||||
it('stops routing after the consumer unregisters', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
const dispose = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
dispose()
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('subscribes to the channel once and routes IPC batches', () => {
|
||||
let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null
|
||||
const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => {
|
||||
channelCallback = callback
|
||||
return () => {
|
||||
channelCallback = null
|
||||
}
|
||||
})
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: { pty: { onSideEffect } }
|
||||
}
|
||||
const first = createCallbackRecorder()
|
||||
const second = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: first.callbacks })
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: 'pty-2', callbacks: second.callbacks })
|
||||
|
||||
expect(onSideEffect).toHaveBeenCalledTimes(1)
|
||||
channelCallback!(batch([{ kind: 'bell' }], { ptyId: 'pty-2' }))
|
||||
expect(second.events).toEqual([['bell']])
|
||||
})
|
||||
|
||||
it('applies the title snapshot on register unless the registration was replaced', async () => {
|
||||
let resolveSnapshot: (value: TerminalSideEffectBatch | null) => void = () => {}
|
||||
const getSideEffectSnapshot = vi.fn(
|
||||
() =>
|
||||
new Promise<TerminalSideEffectBatch | null>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
)
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: { pty: { getSideEffectSnapshot } }
|
||||
}
|
||||
|
||||
const first = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: first.callbacks,
|
||||
restoreTitleOnRegister: true
|
||||
})
|
||||
expect(getSideEffectSnapshot).toHaveBeenCalledWith(PTY_ID)
|
||||
|
||||
// Replace before the snapshot resolves: the slow snapshot must not fire
|
||||
// into the superseded registration.
|
||||
const second = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks })
|
||||
resolveSnapshot(
|
||||
batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], {
|
||||
replay: true,
|
||||
seq: 5
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(first.events).toEqual([])
|
||||
expect(second.events).toEqual([])
|
||||
})
|
||||
|
||||
it('restores the snapshot title for a live registration', async () => {
|
||||
const snapshot = batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], {
|
||||
replay: true,
|
||||
seq: 5
|
||||
})
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } }
|
||||
}
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks,
|
||||
restoreTitleOnRegister: true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(events).toEqual([['title', 'restored', 'restored']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Renderer consumer registry for the `pty:sideEffect` channel.
|
||||
*
|
||||
* Why: with main as the side-effect parser for local-daemon/SSH PTYs
|
||||
* (docs/reference/terminal-side-effect-authority.md), the renderer no longer
|
||||
* derives title/bell/agent facts from bytes for those PTYs. This module is
|
||||
* the single channel subscriber; mounted panes and parked-tab watchers
|
||||
* register exactly one fact consumer per PTY (their existing policy
|
||||
* callbacks), so every fact has exactly one policy consumer regardless of
|
||||
* whether the tab is mounted, hidden, or parked. Facts for PTYs without a
|
||||
* registered consumer are dropped — mirroring today's eager-buffer behavior
|
||||
* where pre-mount output produces no attention side effects.
|
||||
*/
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import type {
|
||||
TerminalSideEffectBatch,
|
||||
TerminalSideEffectFact
|
||||
} from '../../../../shared/terminal-side-effect-facts'
|
||||
|
||||
// Why: cached once per session — the blocking read should only ever run on
|
||||
// the pre-hydration startup path, never per pane bind.
|
||||
let persistedAuthorityFlagCache: boolean | null | undefined
|
||||
|
||||
function readPersistedSideEffectAuthorityFlagSync(): boolean | null {
|
||||
if (persistedAuthorityFlagCache === undefined) {
|
||||
try {
|
||||
const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync
|
||||
persistedAuthorityFlagCache =
|
||||
typeof getSync === 'function' ? (getSync()?.terminalMainSideEffectAuthority ?? null) : null
|
||||
} catch {
|
||||
persistedAuthorityFlagCache = null
|
||||
}
|
||||
}
|
||||
return persistedAuthorityFlagCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural authority predicate: main owns side effects for a PTY when its
|
||||
* bytes transit local main (everything except remote-runtime PTYs) and the
|
||||
* kill switch is on. Decided at transport/watcher creation — never per chunk —
|
||||
* so each fact has one consumer with no race.
|
||||
*/
|
||||
export function isMainTerminalSideEffectAuthorityForPty(args: {
|
||||
settings: Pick<GlobalSettings, 'terminalMainSideEffectAuthority'> | null
|
||||
/** Remote-runtime owner environment; null means bytes transit local main. */
|
||||
runtimeEnvironmentId: string | null
|
||||
}): boolean {
|
||||
if (args.runtimeEnvironmentId !== null) {
|
||||
return false
|
||||
}
|
||||
if (args.settings !== null) {
|
||||
return args.settings.terminalMainSideEffectAuthority !== false
|
||||
}
|
||||
// Why: settings hydrate asynchronously, and the authority decision made
|
||||
// here at transport/watcher creation is never revisited. A pane bound
|
||||
// before hydration must honor the persisted kill switch — otherwise a user
|
||||
// who turned main authority off gets startup panes with no byte parsers
|
||||
// and a fact consumer they disabled. Surfaces without the sync read (web
|
||||
// remote clients, tests) keep the default-on behavior.
|
||||
return readPersistedSideEffectAuthorityFlagSync() !== false
|
||||
}
|
||||
|
||||
export type TerminalSideEffectFactConsumerCallbacks = {
|
||||
/** `meta.staleWorkingTitleClear` marks facts derived from main's 3s
|
||||
* stale-title timer — policy must clear title/cache state without
|
||||
* scheduling task-complete notifications or unread attention. */
|
||||
onTitleChange?: (
|
||||
normalizedTitle: string,
|
||||
rawTitle: string,
|
||||
meta?: { staleWorkingTitleClear?: boolean }
|
||||
) => void
|
||||
onBell?: () => void
|
||||
onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void
|
||||
onAgentBecameWorking?: () => void
|
||||
onAgentExited?: () => void
|
||||
/** OSC 133;D — same policy hook the byte-mode commandLifecycle drove
|
||||
* (stale agent-status row drop + interrupt-inference coordination). */
|
||||
onCommandFinished?: (bestEffortExitCode: number | null) => void
|
||||
onPrLink?: (link: TerminalGitHubPRLink) => void
|
||||
/** Command Code output scrape (no hooks): working seeds the status row;
|
||||
* done is settle-checked by the pane policy before completing the turn. */
|
||||
onCommandCodeWorking?: (prompt: string) => void
|
||||
onCommandCodeDone?: (prompt: string) => void
|
||||
/** DECSET 2031 subscribe observed by main's tracker. Registered only by
|
||||
* hidden-delivery-gated consumers (their bytes never arrive); the theme
|
||||
* reply is sent renderer-side — query authority stays with the view. */
|
||||
onMode2031Subscribe?: () => void
|
||||
}
|
||||
|
||||
type ConsumerEntry = {
|
||||
callbacks: TerminalSideEffectFactConsumerCallbacks
|
||||
/** Output sequence of the last live title fact applied. Replay snapshots at
|
||||
* or before this point are stale and must not regress the title state. */
|
||||
lastLiveTitleSeq: number | null
|
||||
}
|
||||
|
||||
const consumersByPtyId = new Map<string, ConsumerEntry>()
|
||||
let channelUnsubscribe: (() => void) | null = null
|
||||
|
||||
function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void {
|
||||
switch (fact.kind) {
|
||||
case 'title':
|
||||
entry.lastLiveTitleSeq = seq
|
||||
entry.callbacks.onTitleChange?.(
|
||||
fact.normalizedTitle,
|
||||
fact.rawTitle,
|
||||
fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined
|
||||
)
|
||||
return
|
||||
case 'bell':
|
||||
entry.callbacks.onBell?.()
|
||||
return
|
||||
case 'agent-working':
|
||||
entry.callbacks.onAgentBecameWorking?.()
|
||||
return
|
||||
case 'agent-idle':
|
||||
entry.callbacks.onAgentBecameIdle?.(
|
||||
fact.title,
|
||||
fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined
|
||||
)
|
||||
return
|
||||
case 'agent-exited':
|
||||
entry.callbacks.onAgentExited?.()
|
||||
return
|
||||
case 'command-finished':
|
||||
entry.callbacks.onCommandFinished?.(fact.exitCode)
|
||||
return
|
||||
case 'pr-link':
|
||||
entry.callbacks.onPrLink?.(fact.link)
|
||||
return
|
||||
case 'command-code-working':
|
||||
entry.callbacks.onCommandCodeWorking?.(fact.prompt)
|
||||
return
|
||||
case 'command-code-done':
|
||||
entry.callbacks.onCommandCodeDone?.(fact.prompt)
|
||||
return
|
||||
case '2031-subscribe':
|
||||
entry.callbacks.onMode2031Subscribe?.()
|
||||
}
|
||||
}
|
||||
|
||||
function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void {
|
||||
if (batch.replay) {
|
||||
// Why: the no-attention-replay rule — (re)attach snapshots restore title
|
||||
// state only; historical bells/completions must never fire again. A replay
|
||||
// older (by output sequence) than the last live title fact is stale.
|
||||
if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) {
|
||||
return
|
||||
}
|
||||
for (const fact of batch.facts) {
|
||||
if (fact.kind === 'title') {
|
||||
entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const fact of batch.facts) {
|
||||
applyLiveFact(entry, fact, batch.seq)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSideEffectBatch(batch: TerminalSideEffectBatch): void {
|
||||
const entry = consumersByPtyId.get(batch.ptyId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
applyBatchToConsumer(entry, batch)
|
||||
}
|
||||
|
||||
function ensureSideEffectChannelSubscription(): void {
|
||||
if (channelUnsubscribe !== null) {
|
||||
return
|
||||
}
|
||||
// Why: optional-chained from globalThis so unit tests (and any non-preload
|
||||
// surface) without window.api degrade to "no channel" instead of throwing.
|
||||
const onSideEffect = (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect
|
||||
if (typeof onSideEffect !== 'function') {
|
||||
return
|
||||
}
|
||||
channelUnsubscribe = onSideEffect(handleSideEffectBatch)
|
||||
}
|
||||
|
||||
export type TerminalSideEffectFactConsumerOptions = {
|
||||
ptyId: string
|
||||
callbacks: TerminalSideEffectFactConsumerCallbacks
|
||||
/** Pull main's title-only replay snapshot on registration. Pane transports
|
||||
* use this in place of deriving titles from eager-buffer byte replay;
|
||||
* parked watchers skip it because the pane's runtime title slot is already
|
||||
* current at park time. */
|
||||
restoreTitleOnRegister?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the single fact consumer for a PTY. A new registration replaces a
|
||||
* stale one for the same PTY (same semantics as the parked watcher registry):
|
||||
* two consumers would double-fire bell/completion policy for the same bytes.
|
||||
*/
|
||||
export function registerTerminalSideEffectFactConsumer(
|
||||
options: TerminalSideEffectFactConsumerOptions
|
||||
): () => void {
|
||||
ensureSideEffectChannelSubscription()
|
||||
const entry: ConsumerEntry = {
|
||||
callbacks: options.callbacks,
|
||||
lastLiveTitleSeq: null
|
||||
}
|
||||
consumersByPtyId.set(options.ptyId, entry)
|
||||
|
||||
if (options.restoreTitleOnRegister) {
|
||||
const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot
|
||||
if (typeof getSnapshot === 'function') {
|
||||
void getSnapshot(options.ptyId)
|
||||
.then((batch) => {
|
||||
// Why: apply only while this registration is still the live
|
||||
// consumer; a slow snapshot must not fire into a replaced one.
|
||||
if (batch && consumersByPtyId.get(options.ptyId) === entry) {
|
||||
applyBatchToConsumer(entry, { ...batch, replay: true })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (consumersByPtyId.get(options.ptyId) === entry) {
|
||||
consumersByPtyId.delete(options.ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: deliver a batch as if it arrived on the channel. */
|
||||
export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffectBatch): void {
|
||||
handleSideEffectBatch(batch)
|
||||
}
|
||||
|
||||
/** Test seam: reset module state between tests. */
|
||||
export function _resetTerminalSideEffectFactConsumersForTest(): void {
|
||||
consumersByPtyId.clear()
|
||||
channelUnsubscribe?.()
|
||||
channelUnsubscribe = null
|
||||
persistedAuthorityFlagCache = undefined
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY
|
||||
// title tracker in main alongside the renderer transport's byte parser. Both
|
||||
// must derive IDENTICAL ordered title/status facts from the same bytes, or
|
||||
// main-side consumers (tui-idle waiters, worktree ps, mobile titles) drift
|
||||
// from what the renderer shows. This harness feeds identical byte fixtures
|
||||
// through the renderer `createPtyOutputProcessor` and through main's
|
||||
// consumption shape (OSC 9999 strip → shared title tracker) and asserts the
|
||||
// event sequences match.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc'
|
||||
import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status'
|
||||
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects'
|
||||
import { createPtyOutputProcessor } from './pty-transport'
|
||||
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
|
||||
|
||||
const ESC = '\x1b'
|
||||
const BEL = '\x07'
|
||||
const ST = `${ESC}\\`
|
||||
|
||||
type TitleFactEvent =
|
||||
| { kind: 'title'; normalized: string; raw: string }
|
||||
| { kind: 'became-working' }
|
||||
| { kind: 'became-idle'; title: string }
|
||||
| { kind: 'agent-exited' }
|
||||
| { kind: 'bell' }
|
||||
|
||||
type TitleFactPath = {
|
||||
events: TitleFactEvent[]
|
||||
feed: (chunk: string) => void
|
||||
}
|
||||
|
||||
function createRendererPath(): TitleFactPath {
|
||||
const events: TitleFactEvent[] = []
|
||||
const processor = createPtyOutputProcessor({
|
||||
onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
|
||||
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
|
||||
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
|
||||
onAgentExited: () => events.push({ kind: 'agent-exited' }),
|
||||
onBell: () => events.push({ kind: 'bell' })
|
||||
})
|
||||
const callbacks = { onData: () => {} }
|
||||
return {
|
||||
events,
|
||||
feed(chunk: string): void {
|
||||
processor.processData(chunk, callbacks)
|
||||
// Why: the renderer defers side effects behind a setTimeout(0) drain to
|
||||
// protect xterm paint. Flush synchronously so both paths observe each
|
||||
// chunk at the same fake-timer instant.
|
||||
processor.flushPendingSideEffects()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMainPath(): TitleFactPath {
|
||||
const events: TitleFactEvent[] = []
|
||||
// Why: mirrors OrcaRuntimeService.onPtyData — the per-PTY OSC 9999
|
||||
// processor strips status payloads before the title tracker sees the chunk.
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
const tracker = createTerminalTitleTracker({
|
||||
onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
|
||||
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
|
||||
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
|
||||
onAgentExited: () => events.push({ kind: 'agent-exited' }),
|
||||
onBell: () => events.push({ kind: 'bell' })
|
||||
})
|
||||
return {
|
||||
events,
|
||||
feed(chunk: string): void {
|
||||
tracker.handleChunk(processAgentStatusChunk(chunk).cleanData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ChunkFeed = { feed: (chunk: string) => void }
|
||||
|
||||
function feedBoth(paths: { renderer: ChunkFeed; main: ChunkFeed }, chunk: string): void {
|
||||
paths.renderer.feed(chunk)
|
||||
paths.main.feed(chunk)
|
||||
}
|
||||
|
||||
describe('main title tracker parity with the renderer transport processor', () => {
|
||||
let paths: { renderer: TitleFactPath; main: TitleFactPath }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
paths = { renderer: createRendererPath(), main: createMainPath() }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => {
|
||||
// One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's
|
||||
// trailing idle title. A last-title reader sees only the idle title and
|
||||
// never observes the working state.
|
||||
const chunk =
|
||||
`${ESC}]0;⠋ π - cwd${BEL}response text\r\n` +
|
||||
`${ESC}]0;⠙ π - cwd${BEL}more text\r\n` +
|
||||
`${ESC}]0;π - cwd${BEL}`
|
||||
feedBoth(paths, chunk)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
const kinds = paths.main.events.map((event) => event.kind)
|
||||
expect(kinds).toContain('became-working')
|
||||
expect(kinds.indexOf('became-working')).toBeLessThan(kinds.indexOf('became-idle'))
|
||||
})
|
||||
|
||||
it('derives identical facts from BEL- and ST-terminated titles', () => {
|
||||
feedBoth(paths, `${ESC}]2;Codex working${ST}body bytes`)
|
||||
feedBoth(paths, `${ESC}]0;Codex done${BEL}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toContainEqual({ kind: 'became-idle', title: 'Codex done' })
|
||||
})
|
||||
|
||||
it('drops the bare cursor-agent native title in both paths', () => {
|
||||
feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`)
|
||||
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
const titles = paths.main.events.filter((event) => event.kind === 'title')
|
||||
expect(titles).toEqual([{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }])
|
||||
})
|
||||
|
||||
it('clears a stale working title after the 3s timeout in both paths', () => {
|
||||
feedBoth(paths, `${ESC}]0;. Claude working${BEL}`)
|
||||
feedBoth(paths, 'output with no title\r\n')
|
||||
|
||||
vi.advanceTimersByTime(3_000)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Claude' })
|
||||
})
|
||||
|
||||
it('keeps the stale-title timer unperturbed by pure OSC 9999 status chunks', () => {
|
||||
feedBoth(paths, `${ESC}]0;Codex working${BEL}`)
|
||||
feedBoth(paths, 'plain output arms the timer\r\n')
|
||||
|
||||
vi.advanceTimersByTime(2_000)
|
||||
// Why: a chunk that is ONLY an Orca status payload strips to empty
|
||||
// cleanData; neither path may restart (or newly arm) the stale probe.
|
||||
feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`)
|
||||
vi.advanceTimersByTime(1_000)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Codex' })
|
||||
})
|
||||
|
||||
it('ignores a title split across chunk boundaries in both paths', () => {
|
||||
feedBoth(paths, `${ESC}]0;split-ti`)
|
||||
feedBoth(paths, `tle${BEL}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([])
|
||||
})
|
||||
|
||||
it('orders a real BEL after the same chunk titles in both paths', () => {
|
||||
feedBoth(paths, `${ESC}]0;⠋ Claude working${BEL}done text${BEL}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events.map((event) => event.kind)).toEqual([
|
||||
'title',
|
||||
'became-working',
|
||||
'bell'
|
||||
])
|
||||
})
|
||||
|
||||
it('never reports an OSC-terminator BEL as a bell, even spanning chunks', () => {
|
||||
feedBoth(paths, `${ESC}]0;par`)
|
||||
feedBoth(paths, `tial title${BEL}`)
|
||||
feedBoth(paths, `${ESC}]2;st-terminated${ST}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events.filter((event) => event.kind === 'bell')).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a BEL after a CAN-cancelled OSC as a real bell in both paths', () => {
|
||||
// ECMA-48 CAN aborts the in-progress OSC; the next BEL is a real bell.
|
||||
feedBoth(paths, `${ESC}]0;truncated`)
|
||||
feedBoth(paths, `\x18${BEL}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([{ kind: 'bell' }])
|
||||
})
|
||||
|
||||
it('keeps bells suppressed inside OSC 9999 status payloads in both paths', () => {
|
||||
feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// Why: slice 3 moves the renderer's OSC 133;D and PR-link byte parsing into
|
||||
// main's tracker for local/SSH PTYs. Both must derive identical fact
|
||||
// sequences from the same chunk boundaries, or flipping the kill switch
|
||||
// changes which commands/links are observed.
|
||||
type LifecycleFactEvent = ['command-finished', number | null] | ['pr-link', string, number]
|
||||
|
||||
type LifecycleFactPath = {
|
||||
events: LifecycleFactEvent[]
|
||||
feed: (chunk: string) => void
|
||||
}
|
||||
|
||||
function createRendererLifecyclePath(): LifecycleFactPath {
|
||||
const events: LifecycleFactEvent[] = []
|
||||
// Why: mirrors pty-connection's dataCallback wiring — the transport
|
||||
// processor strips OSC 9999 before the lifecycle/PR-link byte scans run.
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
const lifecycle = createTerminalCommandLifecycle({
|
||||
onCommandFinished: (exitCode) => events.push(['command-finished', exitCode])
|
||||
})
|
||||
const detectPRLinks = createTerminalGitHubPRLinkDetector()
|
||||
return {
|
||||
events,
|
||||
feed(chunk: string): void {
|
||||
const clean = processAgentStatusChunk(chunk).cleanData
|
||||
lifecycle.handlePtyData(clean)
|
||||
for (const link of detectPRLinks(clean)) {
|
||||
events.push(['pr-link', link.url, link.number])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMainLifecyclePath(): LifecycleFactPath {
|
||||
const events: LifecycleFactEvent[] = []
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
const tracker = createTerminalTitleTracker({
|
||||
onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]),
|
||||
onPrLink: (link) => events.push(['pr-link', link.url, link.number])
|
||||
})
|
||||
return {
|
||||
events,
|
||||
feed(chunk: string): void {
|
||||
tracker.handleChunk(processAgentStatusChunk(chunk).cleanData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('main tracker parity with renderer 133;D and PR-link byte parsers', () => {
|
||||
let paths: { renderer: LifecycleFactPath; main: LifecycleFactPath }
|
||||
|
||||
beforeEach(() => {
|
||||
paths = { renderer: createRendererLifecyclePath(), main: createMainLifecyclePath() }
|
||||
})
|
||||
|
||||
it('derives identical command-finished facts from split OSC 133;D chunks', () => {
|
||||
feedBoth(paths, `output${ESC}]133`)
|
||||
feedBoth(paths, ';D;13')
|
||||
feedBoth(paths, `0${BEL}prompt $ `)
|
||||
feedBoth(paths, `${ESC}]133;D;0${BEL}`)
|
||||
feedBoth(paths, `${ESC}]133;D${ST}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([
|
||||
['command-finished', 130],
|
||||
['command-finished', 0],
|
||||
['command-finished', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('derives identical pr-link facts from split and repeated URLs', () => {
|
||||
feedBoth(paths, 'Created https://github.com/acme/orca/pull/4')
|
||||
feedBoth(paths, '2\r\nAlso https://github.com/acme/orca/pull/43 merged\r\n')
|
||||
feedBoth(paths, 'again https://github.com/acme/orca/pull/42\r\n')
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([
|
||||
['pr-link', 'https://github.com/acme/orca/pull/42', 42],
|
||||
['pr-link', 'https://github.com/acme/orca/pull/43', 43]
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores 133;D and PR URLs inside stripped OSC 9999 payloads in both paths', () => {
|
||||
feedBoth(
|
||||
paths,
|
||||
`${ESC}]9999;{"state":"done","prompt":"https://github.com/acme/orca/pull/9"}${BEL}\r\n`
|
||||
)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// Why: slice 4 moves the Command Code output scrape into main for local/SSH
|
||||
// PTYs. The renderer byte path observes raw transport data; main observes the
|
||||
// OSC 9999-stripped cleanData. Both must derive identical working/done
|
||||
// sequences from the same chunk boundaries, or flipping the kill switch
|
||||
// changes Command Code status rows.
|
||||
type CommandCodeFactEvent = ['working' | 'done', string]
|
||||
|
||||
type CommandCodeFactPath = {
|
||||
events: CommandCodeFactEvent[]
|
||||
feed: (chunk: string) => void
|
||||
}
|
||||
|
||||
function createCommandCodePath(options: { stripStatusPayloads: boolean }): CommandCodeFactPath {
|
||||
const events: CommandCodeFactEvent[] = []
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
const detector = createCommandCodeOutputStatusDetector({
|
||||
startupCommand: null,
|
||||
onWorking: (prompt) => events.push(['working', prompt]),
|
||||
onDone: (prompt) => events.push(['done', prompt])
|
||||
})
|
||||
return {
|
||||
events,
|
||||
feed(chunk: string): void {
|
||||
detector.observe(
|
||||
options.stripStatusPayloads ? processAgentStatusChunk(chunk).cleanData : chunk
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('main Command Code scrape parity with the renderer byte detector', () => {
|
||||
let paths: { renderer: CommandCodeFactPath; main: CommandCodeFactPath }
|
||||
|
||||
beforeEach(() => {
|
||||
paths = {
|
||||
renderer: createCommandCodePath({ stripStatusPayloads: false }),
|
||||
main: createCommandCodePath({ stripStatusPayloads: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('derives identical working facts after the banner arms across chunks', () => {
|
||||
feedBoth(paths, '# Command')
|
||||
feedBoth(paths, ' Code v0.27.3\r\n')
|
||||
feedBoth(paths, '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m')
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([['working', 'Fix the spinner']])
|
||||
})
|
||||
|
||||
it('derives identical done facts for a no-tool turn in both paths', () => {
|
||||
feedBoth(paths, '# Command Code v0.27.3\r\n')
|
||||
feedBoth(paths, '❯ say hi\r\n✻ Thinking...')
|
||||
feedBoth(paths, '\r\n:: Hi!\r\n❯ Ask your question...')
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([
|
||||
['working', 'say hi'],
|
||||
['done', 'say hi']
|
||||
])
|
||||
})
|
||||
|
||||
it('recovers prompt capture from interleaved OSC 9999 payloads (main improvement)', () => {
|
||||
// Deliberate divergence, not drift: the renderer's raw byte path lets an
|
||||
// OSC 9999 payload leak partial text into the scrape window (its ANSI
|
||||
// strip consumes only the ESC] introducer), which breaks the prompt-echo
|
||||
// line match. Main feeds the OSC 9999-stripped cleanData, so the prompt
|
||||
// (and therefore the done settle hint) survives an adjacent payload.
|
||||
const payloadThenPrompt = [
|
||||
'# Command Code v0.27.3\r\n',
|
||||
`${ESC}]9999;{"state":"working","agentType":"command-code"}${BEL}`,
|
||||
'❯ say hi\r\n✻ Thinking...',
|
||||
'\r\n:: Hi!\r\n❯ Ask your question...'
|
||||
]
|
||||
for (const chunk of payloadThenPrompt) {
|
||||
feedBoth(paths, chunk)
|
||||
}
|
||||
|
||||
expect(paths.renderer.events).toEqual([['working', '']])
|
||||
expect(paths.main.events).toEqual([
|
||||
['working', 'say hi'],
|
||||
['done', 'say hi']
|
||||
])
|
||||
})
|
||||
|
||||
it('stays silent without the Command Code banner in both paths', () => {
|
||||
feedBoth(paths, '❯ Fix the spinner\r\nThinking about unrelated shell output...')
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* View-attribute bridge publication (terminal-query-authority.md §View-
|
||||
* attribute bridge): the composed snapshot must mirror xterm ThemeService
|
||||
* resolution (defaults, cursor blend, 256-entry palette), and pushes must
|
||||
* happen once per actual change — not per pane, not per font tweak.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
|
||||
import { applyTerminalAppearance } from './terminal-appearance'
|
||||
import {
|
||||
_resetTerminalViewAttributesPublisherForTest,
|
||||
composeTerminalViewAttributes,
|
||||
publishTerminalViewAttributes
|
||||
} from './terminal-view-attributes-publisher'
|
||||
|
||||
const cursorSettings = {
|
||||
terminalCursorStyle: 'block' as const,
|
||||
terminalCursorBlink: true
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetTerminalViewAttributesPublisherForTest()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('composeTerminalViewAttributes', () => {
|
||||
it('resolves a null theme to the xterm ThemeService defaults', () => {
|
||||
const attrs = composeTerminalViewAttributes(null, 'dark', cursorSettings)
|
||||
expect(attrs.foreground).toEqual([0xff, 0xff, 0xff])
|
||||
expect(attrs.background).toEqual([0x00, 0x00, 0x00])
|
||||
expect(attrs.cursor).toEqual([0xff, 0xff, 0xff])
|
||||
expect(attrs.ansi).toHaveLength(256)
|
||||
// DEFAULT_ANSI_COLORS parity: named 16, color cube, greys.
|
||||
expect(attrs.ansi[0]).toEqual([0x2e, 0x34, 0x36])
|
||||
expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00])
|
||||
expect(attrs.ansi[15]).toEqual([0xee, 0xee, 0xec])
|
||||
expect(attrs.ansi[16]).toEqual([0x00, 0x00, 0x00])
|
||||
expect(attrs.ansi[196]).toEqual([0xff, 0x00, 0x00])
|
||||
expect(attrs.ansi[232]).toEqual([8, 8, 8])
|
||||
expect(attrs.ansi[255]).toEqual([238, 238, 238])
|
||||
expect(attrs.colorSchemeMode).toBe('dark')
|
||||
expect(attrs.cursorStyle).toBe('block')
|
||||
expect(attrs.cursorBlink).toBe(true)
|
||||
})
|
||||
|
||||
it('parses composed theme colors including rgba() opacity forms', () => {
|
||||
const attrs = composeTerminalViewAttributes(
|
||||
{
|
||||
// composeActiveTerminalTheme emits rgba() when terminalBackgroundOpacity
|
||||
// or terminalCursorOpacity apply; the reply drops alpha like xterm's
|
||||
// toColorRGB, except the cursor which blends over the background.
|
||||
background: 'rgba(30, 30, 46, 0.9)',
|
||||
foreground: '#d0d0d0',
|
||||
cursor: 'rgba(255, 0, 0, 0.5)',
|
||||
red: '#ff8800'
|
||||
},
|
||||
'light',
|
||||
{ terminalCursorStyle: 'underline', terminalCursorBlink: false }
|
||||
)
|
||||
expect(attrs.background).toEqual([30, 30, 46])
|
||||
expect(attrs.foreground).toEqual([0xd0, 0xd0, 0xd0])
|
||||
// color.blend parity: a = round(0.5*255)/255; ch = bg + round((fg-bg)*a).
|
||||
expect(attrs.cursor).toEqual([143, 15, 23])
|
||||
expect(attrs.ansi[1]).toEqual([0xff, 0x88, 0x00])
|
||||
expect(attrs.colorSchemeMode).toBe('light')
|
||||
expect(attrs.cursorStyle).toBe('underline')
|
||||
expect(attrs.cursorBlink).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an opaque cursor un-blended and blends short-hex alpha', () => {
|
||||
const attrs = composeTerminalViewAttributes(
|
||||
{ background: '#000000', cursor: '#ff0000' },
|
||||
'dark',
|
||||
cursorSettings
|
||||
)
|
||||
expect(attrs.cursor).toEqual([255, 0, 0])
|
||||
|
||||
const blended = composeTerminalViewAttributes(
|
||||
{ background: '#000000', cursor: '#f00a' },
|
||||
'dark',
|
||||
cursorSettings
|
||||
)
|
||||
// #f00a → alpha 0xaa: 0 + round(255 * (0xaa/0xff)) = 170.
|
||||
expect(blended.cursor).toEqual([170, 0, 0])
|
||||
})
|
||||
|
||||
it('overlays extendedAnsi onto the default 256 palette tail', () => {
|
||||
const attrs = composeTerminalViewAttributes(
|
||||
{ extendedAnsi: ['#102030'] },
|
||||
'dark',
|
||||
cursorSettings
|
||||
)
|
||||
expect(attrs.ansi[16]).toEqual([0x10, 0x20, 0x30])
|
||||
// Untouched tail entries stay on the generated cube.
|
||||
expect(attrs.ansi[17]).toEqual([0x00, 0x00, 0x5f])
|
||||
})
|
||||
|
||||
it('falls back to slot defaults for named colors (hand-edited settings divergence)', () => {
|
||||
// A visible pane resolves named CSS via canvas; the composer cannot, so
|
||||
// hand-edited values fall back — the documented divergence boundary.
|
||||
const attrs = composeTerminalViewAttributes(
|
||||
{ red: 'darkred', foreground: 'hotpink' },
|
||||
'dark',
|
||||
cursorSettings
|
||||
)
|
||||
expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00])
|
||||
expect(attrs.foreground).toEqual([0xff, 0xff, 0xff])
|
||||
})
|
||||
})
|
||||
|
||||
describe('publishTerminalViewAttributes dedupe', () => {
|
||||
it('publishes once per snapshot change, not per call', () => {
|
||||
const send = vi.fn(() => true)
|
||||
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true)
|
||||
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(false)
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A real attribute change (theme flip) publishes again.
|
||||
expect(publishTerminalViewAttributes(null, 'light', cursorSettings, send)).toBe(true)
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not record a failed send, so the next call retries', () => {
|
||||
const failingSend = vi.fn(() => false)
|
||||
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, failingSend)).toBe(false)
|
||||
|
||||
const send = vi.fn(() => true)
|
||||
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true)
|
||||
})
|
||||
|
||||
it('skips silently when the preload bridge is unavailable (web client, tests)', () => {
|
||||
// No window stub: default send must be a safe no-op.
|
||||
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyTerminalAppearance publication', () => {
|
||||
function makePane(id: number): ManagedPane {
|
||||
return {
|
||||
id,
|
||||
terminal: { options: {}, cols: 80, rows: 24 }
|
||||
} as unknown as ManagedPane
|
||||
}
|
||||
|
||||
function makeManager(panes: ManagedPane[]): PaneManager {
|
||||
return {
|
||||
getPanes: () => panes,
|
||||
setPaneLigaturesEnabled: vi.fn(),
|
||||
setPaneStyleOptions: vi.fn()
|
||||
} as unknown as PaneManager
|
||||
}
|
||||
|
||||
function stubPublishBridge(): ReturnType<typeof vi.fn> {
|
||||
const publish = vi.fn<(attributes: TerminalViewAttributes) => void>()
|
||||
vi.stubGlobal('window', { api: { pty: { publishTerminalViewAttributes: publish } } })
|
||||
return publish
|
||||
}
|
||||
|
||||
it('pushes the app-global snapshot once per change, not per pane or per manager', () => {
|
||||
const publish = stubPublishBridge()
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
|
||||
// Two panes in one manager plus a second manager (another tab): the
|
||||
// attributes are app-global, so identical applies publish exactly once.
|
||||
applyTerminalAppearance(
|
||||
makeManager([makePane(1), makePane(2)]),
|
||||
settings,
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
'false',
|
||||
new Map(),
|
||||
new Map()
|
||||
)
|
||||
applyTerminalAppearance(
|
||||
makeManager([makePane(3)]),
|
||||
settings,
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
'false',
|
||||
new Map(),
|
||||
new Map()
|
||||
)
|
||||
expect(publish).toHaveBeenCalledTimes(1)
|
||||
const attributes = publish.mock.calls[0][0] as TerminalViewAttributes
|
||||
expect(attributes.ansi).toHaveLength(256)
|
||||
expect(attributes.cursorStyle).toBe(settings.terminalCursorStyle)
|
||||
|
||||
// Attribute-neutral tweak (font size) must not re-push…
|
||||
applyTerminalAppearance(
|
||||
makeManager([makePane(1)]),
|
||||
{ ...settings, terminalFontSize: settings.terminalFontSize + 2 },
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
'false',
|
||||
new Map(),
|
||||
new Map()
|
||||
)
|
||||
expect(publish).toHaveBeenCalledTimes(1)
|
||||
|
||||
// …while a cursor-style change is a real attribute change.
|
||||
applyTerminalAppearance(
|
||||
makeManager([makePane(1)]),
|
||||
{ ...settings, terminalCursorStyle: 'underline' },
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
'false',
|
||||
new Map(),
|
||||
new Map()
|
||||
)
|
||||
expect(publish).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('publishes the resolved color-scheme mode flip (system dark toggle)', () => {
|
||||
const publish = stubPublishBridge()
|
||||
const settings = { ...getDefaultSettings('/tmp'), theme: 'system' as const }
|
||||
|
||||
applyTerminalAppearance(
|
||||
makeManager([makePane(1)]),
|
||||
settings,
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
'false',
|
||||
new Map(),
|
||||
new Map()
|
||||
)
|
||||
applyTerminalAppearance(
|
||||
makeManager([makePane(1)]),
|
||||
settings,
|
||||
false,
|
||||
new Map(),
|
||||
new Map(),
|
||||
'false',
|
||||
new Map(),
|
||||
new Map()
|
||||
)
|
||||
|
||||
const modes = publish.mock.calls.map(
|
||||
(call) => (call[0] as TerminalViewAttributes).colorSchemeMode
|
||||
)
|
||||
expect(modes).toEqual(['dark', 'light'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute
|
||||
* bridge): renderer→main `pty:terminalViewAttributes` publication. Composes
|
||||
* the reply-relevant slots of the active terminal theme exactly the way
|
||||
* xterm's browser ThemeService resolves an ITheme (defaults, cursor blend,
|
||||
* 256-entry palette), so main's hidden-PTY responder replies byte-identically
|
||||
* to a visible pane's xterm. Deduped module-globally: applyTerminalAppearance
|
||||
* runs per pane manager and on every font/opacity tweak, but the attributes
|
||||
* are app-global, so identical snapshots publish once.
|
||||
*/
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import type {
|
||||
TerminalViewAttributes,
|
||||
TerminalViewRgb
|
||||
} from '../../../../shared/terminal-view-attributes'
|
||||
|
||||
type ParsedCssColor = {
|
||||
rgb: TerminalViewRgb
|
||||
/** 0-255, the precision xterm stores (rgba byte) — blend parity needs it. */
|
||||
alpha: number
|
||||
}
|
||||
|
||||
// ThemeService defaults for the reply-relevant slots (browser/services/
|
||||
// ThemeService.ts): fg #ffffff, bg #000000, cursor #ffffff.
|
||||
const DEFAULT_FOREGROUND: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff }
|
||||
const DEFAULT_BACKGROUND: ParsedCssColor = { rgb: [0x00, 0x00, 0x00], alpha: 0xff }
|
||||
const DEFAULT_CURSOR: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff }
|
||||
|
||||
// xterm's DEFAULT_ANSI_COLORS first 16 entries (browser/Types.ts).
|
||||
const DEFAULT_ANSI_16: readonly string[] = [
|
||||
'#2e3436',
|
||||
'#cc0000',
|
||||
'#4e9a06',
|
||||
'#c4a000',
|
||||
'#3465a4',
|
||||
'#75507b',
|
||||
'#06989a',
|
||||
'#d3d7cf',
|
||||
'#555753',
|
||||
'#ef2929',
|
||||
'#8ae234',
|
||||
'#fce94f',
|
||||
'#729fcf',
|
||||
'#ad7fa8',
|
||||
'#34e2e2',
|
||||
'#eeeeec'
|
||||
]
|
||||
|
||||
const THEME_ANSI_KEYS: readonly (keyof ITheme)[] = [
|
||||
'black',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'blue',
|
||||
'magenta',
|
||||
'cyan',
|
||||
'white',
|
||||
'brightBlack',
|
||||
'brightRed',
|
||||
'brightGreen',
|
||||
'brightYellow',
|
||||
'brightBlue',
|
||||
'brightMagenta',
|
||||
'brightCyan',
|
||||
'brightWhite'
|
||||
]
|
||||
|
||||
function buildDefaultAnsiPalette(): TerminalViewRgb[] {
|
||||
const palette = DEFAULT_ANSI_16.map((hex) => parseThemeColor(hex, DEFAULT_BACKGROUND).rgb)
|
||||
// 16-231: the 6x6x6 color cube, 232-255: greys — same generator as xterm's
|
||||
// DEFAULT_ANSI_COLORS IIFE so untouched extended slots reply identically.
|
||||
const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
|
||||
for (let i = 0; i < 216; i++) {
|
||||
palette.push([v[((i / 36) % 6) | 0], v[((i / 6) % 6) | 0], v[i % 6]])
|
||||
}
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const c = 8 + i * 10
|
||||
palette.push([c, c, c])
|
||||
}
|
||||
return palette
|
||||
}
|
||||
|
||||
const DEFAULT_ANSI_PALETTE: readonly TerminalViewRgb[] = buildDefaultAnsiPalette()
|
||||
|
||||
/** Mirror of xterm's css.toColor fast paths (#rgb[a], #rrggbb[aa], rgb(),
|
||||
* rgba()) — every format first-party inputs produce (builtin themes and the
|
||||
* ghostty import are hex-validated; composeActiveTerminalTheme only adds the
|
||||
* rgba() form this regex accepts). Known divergence boundary: the renderer's
|
||||
* css.toColor also resolves named/modern CSS via a canvas litmus, so a
|
||||
* hand-edited settings value like `background: 'darkslategray'` renders on
|
||||
* a visible pane but falls back to the slot default in the hidden reply. */
|
||||
export function parseCssColor(css: string): ParsedCssColor | null {
|
||||
if (/^#[\da-f]{3,8}$/i.test(css)) {
|
||||
switch (css.length) {
|
||||
case 4:
|
||||
return {
|
||||
rgb: [
|
||||
parseInt(css.slice(1, 2).repeat(2), 16),
|
||||
parseInt(css.slice(2, 3).repeat(2), 16),
|
||||
parseInt(css.slice(3, 4).repeat(2), 16)
|
||||
],
|
||||
alpha: 0xff
|
||||
}
|
||||
case 5:
|
||||
return {
|
||||
rgb: [
|
||||
parseInt(css.slice(1, 2).repeat(2), 16),
|
||||
parseInt(css.slice(2, 3).repeat(2), 16),
|
||||
parseInt(css.slice(3, 4).repeat(2), 16)
|
||||
],
|
||||
alpha: parseInt(css.slice(4, 5).repeat(2), 16)
|
||||
}
|
||||
case 7:
|
||||
return {
|
||||
rgb: [
|
||||
parseInt(css.slice(1, 3), 16),
|
||||
parseInt(css.slice(3, 5), 16),
|
||||
parseInt(css.slice(5, 7), 16)
|
||||
],
|
||||
alpha: 0xff
|
||||
}
|
||||
case 9:
|
||||
return {
|
||||
rgb: [
|
||||
parseInt(css.slice(1, 3), 16),
|
||||
parseInt(css.slice(3, 5), 16),
|
||||
parseInt(css.slice(5, 7), 16)
|
||||
],
|
||||
alpha: parseInt(css.slice(7, 9), 16)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
const rgbaMatch = css.match(
|
||||
/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/
|
||||
)
|
||||
if (rgbaMatch) {
|
||||
return {
|
||||
rgb: [parseInt(rgbaMatch[1], 10), parseInt(rgbaMatch[2], 10), parseInt(rgbaMatch[3], 10)],
|
||||
alpha: Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xff)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseThemeColor(css: string | undefined, fallback: ParsedCssColor): ParsedCssColor {
|
||||
if (css !== undefined) {
|
||||
const parsed = parseCssColor(css)
|
||||
if (parsed) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Mirror of xterm's color.blend: ThemeService blends the cursor color's
|
||||
// alpha over the background at theme-set time (terminalCursorOpacity), and
|
||||
// the OSC 12 reply reports the blended value.
|
||||
function blendOverBackground(background: TerminalViewRgb, color: ParsedCssColor): TerminalViewRgb {
|
||||
if (color.alpha === 0xff) {
|
||||
return color.rgb
|
||||
}
|
||||
const a = color.alpha / 0xff
|
||||
return [
|
||||
background[0] + Math.round((color.rgb[0] - background[0]) * a),
|
||||
background[1] + Math.round((color.rgb[1] - background[1]) * a),
|
||||
background[2] + Math.round((color.rgb[2] - background[2]) * a)
|
||||
]
|
||||
}
|
||||
|
||||
export function composeTerminalViewAttributes(
|
||||
theme: ITheme | null,
|
||||
mode: TerminalColorSchemeMode,
|
||||
settings: Pick<GlobalSettings, 'terminalCursorStyle' | 'terminalCursorBlink'>
|
||||
): TerminalViewAttributes {
|
||||
const foreground = parseThemeColor(theme?.foreground, DEFAULT_FOREGROUND)
|
||||
const background = parseThemeColor(theme?.background, DEFAULT_BACKGROUND)
|
||||
const cursor = parseThemeColor(theme?.cursor, DEFAULT_CURSOR)
|
||||
const ansi: TerminalViewRgb[] = THEME_ANSI_KEYS.map((key, i) => {
|
||||
const value = theme?.[key]
|
||||
return parseThemeColor(typeof value === 'string' ? value : undefined, {
|
||||
rgb: DEFAULT_ANSI_PALETTE[i],
|
||||
alpha: 0xff
|
||||
}).rgb
|
||||
})
|
||||
for (let i = 16; i < DEFAULT_ANSI_PALETTE.length; i++) {
|
||||
const extended = theme?.extendedAnsi?.[i - 16]
|
||||
ansi.push(
|
||||
parseThemeColor(extended, {
|
||||
rgb: DEFAULT_ANSI_PALETTE[i],
|
||||
alpha: 0xff
|
||||
}).rgb
|
||||
)
|
||||
}
|
||||
return {
|
||||
foreground: foreground.rgb,
|
||||
background: background.rgb,
|
||||
cursor: blendOverBackground(background.rgb, cursor),
|
||||
ansi,
|
||||
colorSchemeMode: mode,
|
||||
// Same resolution as the per-pane option writes in applyTerminalAppearance.
|
||||
cursorStyle: settings.terminalCursorStyle ?? 'block',
|
||||
cursorBlink: settings.terminalCursorBlink === true
|
||||
}
|
||||
}
|
||||
|
||||
let lastPublishedSnapshot: string | null = null
|
||||
|
||||
function sendViaPreload(attributes: TerminalViewAttributes): boolean {
|
||||
// Guarded: unit tests and the web client run without the preload bridge
|
||||
// (remote-runtime PTYs are never hidden-gate markable anyway).
|
||||
if (typeof window === 'undefined' || !window.api?.pty?.publishTerminalViewAttributes) {
|
||||
return false
|
||||
}
|
||||
window.api.pty.publishTerminalViewAttributes(attributes)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Publishes the composed app-global attributes, once per actual change:
|
||||
* repeat calls from per-pane appearance applies (and attribute-neutral
|
||||
* tweaks like font size) are deduped against the last published snapshot. */
|
||||
export function publishTerminalViewAttributes(
|
||||
theme: ITheme | null,
|
||||
mode: TerminalColorSchemeMode,
|
||||
settings: Pick<GlobalSettings, 'terminalCursorStyle' | 'terminalCursorBlink'>,
|
||||
send: (attributes: TerminalViewAttributes) => boolean = sendViaPreload
|
||||
): boolean {
|
||||
const attributes = composeTerminalViewAttributes(theme, mode, settings)
|
||||
const serialized = JSON.stringify(attributes)
|
||||
if (serialized === lastPublishedSnapshot) {
|
||||
return false
|
||||
}
|
||||
if (!send(attributes)) {
|
||||
// Not recorded: a later call with a working bridge must still publish.
|
||||
return false
|
||||
}
|
||||
lastPublishedSnapshot = serialized
|
||||
return true
|
||||
}
|
||||
|
||||
/** Test seam: reset the dedupe state between tests. */
|
||||
export function _resetTerminalViewAttributesPublisherForTest(): void {
|
||||
lastPublishedSnapshot = null
|
||||
}
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
syncTerminalScrollIntentSoon
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import {
|
||||
PRIMARY_SELECTION_MAX_LENGTH,
|
||||
@@ -745,6 +746,13 @@ export function useTerminalPaneLifecycle({
|
||||
setCacheTimerStartedAt,
|
||||
syncPanePtyLayoutBinding,
|
||||
clearExitedPanePtyLayoutBinding,
|
||||
// Why: a DECSET 2031 subscribe answered from main's fact channel must
|
||||
// land in the same registries the xterm CSI handler writes — otherwise
|
||||
// theme flips never push CSI 997 and the TUI keeps a stale theme.
|
||||
recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => {
|
||||
paneMode2031Ref.current.set(paneId, true)
|
||||
paneLastThemeModeRef.current.set(paneId, repliedMode)
|
||||
},
|
||||
restoredPtyIdByLeafId: initialLayoutRef.current.ptyIdsByLeafId ?? {}
|
||||
}
|
||||
|
||||
@@ -772,7 +780,19 @@ export function useTerminalPaneLifecycle({
|
||||
const mode2031Disposables = installMode2031Handlers({
|
||||
paneId: pane.id,
|
||||
parser: pane.terminal.parser,
|
||||
onSubscribe: () => pushMode2031ForPane(pane.id),
|
||||
onSubscribe: () => {
|
||||
// Why: for hidden-delivery-gate-managed PTYs main's
|
||||
// '2031-subscribe' fact is the sole responder — bytes reaching
|
||||
// xterm live (foreground, sidecar interest) must not produce a
|
||||
// second reply. The CSI handler still records the subscription.
|
||||
const binding = panePtyBindings.get(pane.id) as
|
||||
| (IDisposable & { isHiddenDeliveryGateManagedPty?: () => boolean })
|
||||
| undefined
|
||||
if (binding?.isHiddenDeliveryGateManagedPty?.()) {
|
||||
return
|
||||
}
|
||||
pushMode2031ForPane(pane.id)
|
||||
},
|
||||
isReplaying: () => isPaneReplaying(replayingPanesRef, pane.id),
|
||||
paneMode2031: paneMode2031Ref.current,
|
||||
paneLastThemeMode: paneLastThemeModeRef.current
|
||||
@@ -1637,6 +1657,19 @@ export function useTerminalPaneLifecycle({
|
||||
disposable.dispose()
|
||||
}
|
||||
imeNativeTextForwarderDisposables.clear()
|
||||
// Why: hidden-view parking starts pane-less byte watchers right after
|
||||
// this unmount; record pane identities before transports detach so the
|
||||
// watchers write the same runtime-title slots the live panes used.
|
||||
captureParkedTerminalPaneCandidates(
|
||||
tabId,
|
||||
worktreeId,
|
||||
manager.getPanes().map((capturedPane) => ({
|
||||
ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null,
|
||||
paneId: capturedPane.id,
|
||||
leafId: capturedPane.leafId,
|
||||
drivesTabTitle: manager.getActivePane()?.id === capturedPane.id
|
||||
}))
|
||||
)
|
||||
for (const transport of paneTransports.values()) {
|
||||
const ptyId = transport.getPtyId()
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Per-tab hidden-view parking for TerminalPaneOverlayLayer.
|
||||
*
|
||||
* Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck
|
||||
* timers, parked-set selection) and the parked byte-watcher reconciliation so
|
||||
* the overlay layer only consumes the final parked tab set when deciding to
|
||||
* render a slot as null. See docs/reference/terminal-hidden-view-parking.md.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
findActivityTerminalPortal,
|
||||
type ActivityTerminalPortalTarget
|
||||
} from '../activity/activity-terminal-portal'
|
||||
import {
|
||||
getTerminalTabColdParkRecheckDelayMs,
|
||||
selectColdParkedTerminalTabs,
|
||||
type TerminalTabColdParkCandidate
|
||||
} from './terminal-hidden-view-parking'
|
||||
import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-parked-tab-watchers'
|
||||
|
||||
type TerminalOverlayTabAssignment = {
|
||||
groupId: string
|
||||
isActiveInGroup: boolean
|
||||
}
|
||||
|
||||
function haveSameTerminalTabIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false
|
||||
}
|
||||
for (const id of left) {
|
||||
if (!right.has(id)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function useTerminalTabColdParking(args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly TerminalTab[]
|
||||
assignments: ReadonlyMap<string, TerminalOverlayTabAssignment>
|
||||
isWorktreeActive: boolean
|
||||
/** Worktree-level park verdict from Terminal.tsx. */
|
||||
coldParkTerminalPanes: boolean
|
||||
/** Hidden-measuring startup probe from Terminal.tsx — the panes must stay
|
||||
* mounted for their first xterm fit, mirroring the worktree-level guard. */
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
activityTerminalPortals: ActivityTerminalPortalTarget[]
|
||||
}): ReadonlySet<string> {
|
||||
const {
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes,
|
||||
shouldMeasureHiddenWorktree,
|
||||
activityTerminalPortals
|
||||
} = args
|
||||
const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore(
|
||||
(state) => state.settings?.terminalHiddenViewParking !== false
|
||||
)
|
||||
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
|
||||
const terminalTabParkingTimersRef = useRef(new Map<string, number>())
|
||||
const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0)
|
||||
const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const timers = terminalTabParkingTimersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set
|
||||
// selection, and one recheck timer per still-pending deadline so React
|
||||
// re-renders exactly when the hysteresis elapses instead of polling.
|
||||
useEffect(() => {
|
||||
const timers = terminalTabParkingTimersRef.current
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
|
||||
const nowMs = Date.now()
|
||||
const overrides = getTerminalParkingPolicyOverrides()
|
||||
const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id))
|
||||
const portalTabIds = new Set(
|
||||
activityTerminalPortals
|
||||
.filter((portal) => portal.worktreeId === worktreeId)
|
||||
.map((portal) => portal.tabId)
|
||||
)
|
||||
for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) {
|
||||
if (!currentTerminalTabIds.has(tabId)) {
|
||||
terminalTabHiddenSinceRef.current.delete(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
|
||||
const assignment = assignments.get(terminalTab.id)
|
||||
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
|
||||
const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id)
|
||||
// Why: hidden-measuring counts as visibility — the startup probe needs
|
||||
// mounted panes, so the hidden clock must not run during it.
|
||||
if (isVisible || hasActivityTerminalPortal || shouldMeasureHiddenWorktree) {
|
||||
terminalTabHiddenSinceRef.current.delete(terminalTab.id)
|
||||
} else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) {
|
||||
terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs)
|
||||
}
|
||||
return {
|
||||
id: terminalTab.id,
|
||||
ptyId: terminalTab.ptyId,
|
||||
pendingActivationSpawn: terminalTab.pendingActivationSpawn,
|
||||
isVisible,
|
||||
hasActivityTerminalPortal,
|
||||
hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null
|
||||
}
|
||||
})
|
||||
|
||||
const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({
|
||||
worktreeId,
|
||||
terminalTabs: candidates,
|
||||
pendingStartupByTabId,
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
// Why: a tab the byte watchers cannot cover (no capture, no layout
|
||||
// snapshot, legacy leaf ids) must never park — it would go silent for
|
||||
// bells/titles/completions, the failure that sank the first attempt.
|
||||
for (const terminalTab of terminalTabs) {
|
||||
if (
|
||||
nextColdParkedTerminalTabIds.has(terminalTab.id) &&
|
||||
!canWatcherCoverParkedTerminalTab(worktreeId, terminalTab)
|
||||
) {
|
||||
nextColdParkedTerminalTabIds.delete(terminalTab.id)
|
||||
}
|
||||
}
|
||||
setColdParkedTerminalTabIds((current) =>
|
||||
haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds)
|
||||
? current
|
||||
: nextColdParkedTerminalTabIds
|
||||
)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
candidate.isVisible ||
|
||||
candidate.hasActivityTerminalPortal ||
|
||||
nextColdParkedTerminalTabIds.has(candidate.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const delayMs = getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
hiddenSinceMs: candidate.hiddenSinceMs,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
if (delayMs !== null && delayMs > 0) {
|
||||
const tabId = candidate.id
|
||||
const timer = window.setTimeout(() => {
|
||||
timers.delete(tabId)
|
||||
setTerminalTabParkingRevision((revision) => revision + 1)
|
||||
}, delayMs)
|
||||
timers.set(tabId, timer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activityTerminalPortals,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
pendingStartupByTabId,
|
||||
shouldMeasureHiddenWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalTabParkingRevision,
|
||||
terminalTabs,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
// Why: the rendered park verdict — worktree-level park (prop from
|
||||
// Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and
|
||||
// the watcher-sync effect must share this exact set so watcher lifecycle
|
||||
// tracks the committed unmounts.
|
||||
const parkedTerminalTabIds = useMemo(() => {
|
||||
const parked = new Set<string>()
|
||||
for (const terminalTab of terminalTabs) {
|
||||
const assignment = assignments.get(terminalTab.id)
|
||||
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
|
||||
const hasActivityTerminalPortal =
|
||||
findActivityTerminalPortal(activityTerminalPortals, {
|
||||
worktreeId,
|
||||
tabId: terminalTab.id
|
||||
}) !== null
|
||||
if (
|
||||
(coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) &&
|
||||
!hasActivityTerminalPortal &&
|
||||
// Why: the hidden-measuring startup probe needs mounted panes; gate
|
||||
// here too so the reveal lands in the same render that starts it.
|
||||
!shouldMeasureHiddenWorktree
|
||||
) {
|
||||
parked.add(terminalTab.id)
|
||||
}
|
||||
}
|
||||
return parked
|
||||
}, [
|
||||
activityTerminalPortals,
|
||||
assignments,
|
||||
coldParkTerminalPanes,
|
||||
coldParkedTerminalTabIds,
|
||||
isWorktreeActive,
|
||||
shouldMeasureHiddenWorktree,
|
||||
terminalTabs,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
// Why: runs in the same effect flush as the commit that parked/revealed the
|
||||
// panes — watcher disposal therefore lands before any PTY data IPC can
|
||||
// reach a freshly remounted pane, and watcher start lands after the parked
|
||||
// pane's unmount capture.
|
||||
useEffect(() => {
|
||||
syncParkedTerminalTabWatchers({
|
||||
worktreeId,
|
||||
tabs: terminalTabs,
|
||||
parkedTabIds: parkedTerminalTabIds
|
||||
})
|
||||
}, [parkedTerminalTabIds, terminalTabs, worktreeId])
|
||||
|
||||
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId])
|
||||
|
||||
return parkedTerminalTabIds
|
||||
}
|
||||
Vendored
+4
@@ -67,6 +67,10 @@ declare global {
|
||||
interface Window {
|
||||
__paneManagers?: Map<string, PaneManager>
|
||||
__onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps
|
||||
__terminalParkingDebug?: {
|
||||
parkDelayMs: number
|
||||
parkedTabIds: () => string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockSubscribeToPtyData = vi.fn()
|
||||
const mockSubscribeToPtyExit = vi.fn()
|
||||
const mockSubscribeTerminal = vi.fn()
|
||||
const mockCallRuntimeRpc = vi.fn()
|
||||
|
||||
const state = {
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: null as string | null,
|
||||
terminalMainSideEffectAuthority: undefined as boolean | undefined
|
||||
},
|
||||
setAgentStatus: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => state
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
subscribeToPtyExit: mockSubscribeToPtyExit
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({
|
||||
subscribeToPtyData: mockSubscribeToPtyData
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: mockCallRuntimeRpc,
|
||||
getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' }))
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/remote-runtime-terminal-multiplexer', () => ({
|
||||
getRemoteRuntimeTerminalMultiplexer: () => ({ subscribeTerminal: mockSubscribeTerminal })
|
||||
}))
|
||||
|
||||
const DONE_STATUS_OSC = '\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07'
|
||||
|
||||
describe('observeExistingAutomationSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.settings = {
|
||||
activeRuntimeEnvironmentId: null,
|
||||
terminalMainSideEffectAuthority: undefined
|
||||
}
|
||||
mockSubscribeToPtyData.mockReturnValue(vi.fn())
|
||||
mockSubscribeToPtyExit.mockReturnValue(vi.fn())
|
||||
mockCallRuntimeRpc.mockReturnValue(new Promise(() => {}))
|
||||
mockSubscribeTerminal.mockResolvedValue({ close: vi.fn() })
|
||||
})
|
||||
|
||||
it('skips the duplicate OSC store write for local PTYs under main authority', async () => {
|
||||
// Why: main already parses OSC 9999 for local/SSH PTYs and routes it to
|
||||
// the store via agentStatus:set; writing here too would race that path.
|
||||
const onAgentStatus = vi.fn()
|
||||
const { observeExistingAutomationSession } = await import('./automation-session-observer')
|
||||
|
||||
await observeExistingAutomationSession({
|
||||
ptyId: 'pty-local-1',
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
runId: 'run-1',
|
||||
onData: vi.fn(),
|
||||
onAgentStatus,
|
||||
onExit: vi.fn()
|
||||
})
|
||||
|
||||
const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
|
||||
handleData(DONE_STATUS_OSC)
|
||||
|
||||
expect(state.setAgentStatus).not.toHaveBeenCalled()
|
||||
expect(onAgentStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the legacy OSC store write when the kill switch is off', async () => {
|
||||
state.settings.terminalMainSideEffectAuthority = false
|
||||
const onAgentStatus = vi.fn()
|
||||
const { observeExistingAutomationSession } = await import('./automation-session-observer')
|
||||
|
||||
await observeExistingAutomationSession({
|
||||
ptyId: 'pty-local-1',
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
runId: 'run-1',
|
||||
onData: vi.fn(),
|
||||
onAgentStatus,
|
||||
onExit: vi.fn()
|
||||
})
|
||||
|
||||
const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
|
||||
handleData(DONE_STATUS_OSC)
|
||||
|
||||
expect(state.setAgentStatus).toHaveBeenCalledWith(
|
||||
'tab-1:leaf-1',
|
||||
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }),
|
||||
undefined
|
||||
)
|
||||
expect(onAgentStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the OSC store write for remote-runtime PTYs (bytes never transit local main)', async () => {
|
||||
const onAgentStatus = vi.fn()
|
||||
const { observeExistingAutomationSession } = await import('./automation-session-observer')
|
||||
|
||||
await observeExistingAutomationSession({
|
||||
ptyId: 'remote:env-1@@terminal-9',
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
runId: 'run-1',
|
||||
onData: vi.fn(),
|
||||
onAgentStatus,
|
||||
onExit: vi.fn()
|
||||
})
|
||||
|
||||
expect(mockSubscribeTerminal).toHaveBeenCalledTimes(1)
|
||||
const callbacks = mockSubscribeTerminal.mock.calls[0]?.[0]?.callbacks as {
|
||||
onData: (data: string) => void
|
||||
}
|
||||
callbacks.onData(DONE_STATUS_OSC)
|
||||
|
||||
expect(state.setAgentStatus).toHaveBeenCalledWith(
|
||||
'tab-1:leaf-1',
|
||||
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }),
|
||||
undefined
|
||||
)
|
||||
expect(onAgentStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useAppStore } from '@/store'
|
||||
import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc'
|
||||
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
|
||||
import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
|
||||
|
||||
export async function observeExistingAutomationSession(args: {
|
||||
ptyId: string
|
||||
@@ -20,12 +21,25 @@ export async function observeExistingAutomationSession(args: {
|
||||
onExit: (code: number) => void
|
||||
}): Promise<() => void> {
|
||||
const { ptyId, paneKey, runId, onData, onExit } = args
|
||||
// Why: for local/SSH PTYs main already parses OSC 9999 and routes it
|
||||
// through the hook server (agentStatus:set → store); writing here too
|
||||
// would race/duplicate that path. Remote-runtime bytes never transit local
|
||||
// main, and the kill switch restores the legacy write. The onAgentStatus
|
||||
// callback always fires — automation completion tracking stays here.
|
||||
const mainOwnsAgentStatusWrites =
|
||||
!isRemoteRuntimePtyId(ptyId) &&
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: useAppStore.getState().settings,
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
const processAgentStatus = createAgentStatusOscProcessor()
|
||||
const handleData = (data: string): void => {
|
||||
onData(data)
|
||||
const processed = processAgentStatus(data)
|
||||
for (const payload of processed.payloads) {
|
||||
useAppStore.getState().setAgentStatus(paneKey, payload, undefined)
|
||||
if (!mainOwnsAgentStatusWrites) {
|
||||
useAppStore.getState().setAgentStatus(paneKey, payload, undefined)
|
||||
}
|
||||
args.onAgentStatus(payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Why: the parsing core moved to shared so main's terminal side-effect
|
||||
// tracker can emit pr-link facts (terminal-side-effect-authority.md, slice 3).
|
||||
// Re-exported here so renderer consumers keep their '@/lib' import path.
|
||||
// normalizeGitHubLinkQuery stays renderer-side: its too-large guard is link-
|
||||
// picker input policy, not parsing.
|
||||
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '../../../shared/github-links'
|
||||
|
||||
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
|
||||
|
||||
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
|
||||
|
||||
export type RepoSlug = {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
export * from '../../../shared/github-links'
|
||||
|
||||
export type GitHubLinkQuery = {
|
||||
query: string
|
||||
@@ -13,97 +15,6 @@ export type GitHubLinkQuery = {
|
||||
tooLarge?: boolean
|
||||
}
|
||||
|
||||
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
|
||||
if (!slug?.owner || !slug.repo) {
|
||||
return null
|
||||
}
|
||||
return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
|
||||
}
|
||||
|
||||
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
|
||||
return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, ''))
|
||||
}
|
||||
|
||||
function parseGitHubItemNumber(value: string): number | null {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a GitHub issue/PR reference from plain input.
|
||||
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
|
||||
*/
|
||||
export function parseGitHubIssueOrPRNumber(input: string): number | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
|
||||
if (/^\d+$/.test(numeric)) {
|
||||
return parseGitHubItemNumber(numeric)
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = matchGitHubItemPath(url)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseGitHubItemNumber(match[4])
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
|
||||
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
|
||||
*/
|
||||
export function parseGitHubIssueOrPRLink(input: string): {
|
||||
slug: RepoSlug
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
} | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = matchGitHubItemPath(url)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const number = parseGitHubItemNumber(match[4])
|
||||
if (number === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
slug: { owner: match[1], repo: match[2] },
|
||||
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
|
||||
number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes link-picker input so both raw issue/PR numbers and full GitHub
|
||||
* URLs resolve to a usable query + direct-number lookup.
|
||||
|
||||
@@ -37,7 +37,11 @@ function expectStablePaneSpawn(): string {
|
||||
const state = {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null },
|
||||
settings: {
|
||||
agentCmdOverrides: {},
|
||||
activeRuntimeEnvironmentId: null as string | null,
|
||||
terminalMainSideEffectAuthority: undefined as boolean | undefined
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
@@ -108,7 +112,11 @@ describe('launchAgentBackgroundSession', () => {
|
||||
)
|
||||
state.activeRepoId = 'repo-1'
|
||||
state.activeWorktreeId = 'wt-1'
|
||||
state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null }
|
||||
state.settings = {
|
||||
agentCmdOverrides: {},
|
||||
activeRuntimeEnvironmentId: null,
|
||||
terminalMainSideEffectAuthority: undefined
|
||||
}
|
||||
state.projects = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
@@ -306,7 +314,10 @@ describe('launchAgentBackgroundSession', () => {
|
||||
expect(mockSpawn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('parses agent status from hidden PTY output', async () => {
|
||||
it('parses agent status from hidden PTY output when the kill switch is off', async () => {
|
||||
// Why: with main side-effect authority disabled, this sidecar is the only
|
||||
// OSC 9999 → store path for hidden local sessions.
|
||||
state.settings.terminalMainSideEffectAuthority = false
|
||||
const onAgentStatus = vi.fn()
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
@@ -334,6 +345,29 @@ describe('launchAgentBackgroundSession', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('skips the duplicate OSC store write under main side-effect authority', async () => {
|
||||
// Why: main already routes OSC 9999 through the hook server to the store
|
||||
// (agentStatus:set); a second write here would race the authoritative
|
||||
// path. The automation onAgentStatus callback must still fire.
|
||||
const onAgentStatus = vi.fn()
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
await launchAgentBackgroundSession({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'run the automation',
|
||||
onAgentStatus
|
||||
})
|
||||
|
||||
const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
|
||||
dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07')
|
||||
|
||||
expect(state.setAgentStatus).not.toHaveBeenCalled()
|
||||
expect(onAgentStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' })
|
||||
)
|
||||
})
|
||||
|
||||
it('seeds a working status for Command Code prompt launches', async () => {
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
@@ -497,7 +531,8 @@ describe('launchAgentBackgroundSession', () => {
|
||||
state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
|
||||
state.settings = {
|
||||
agentCmdOverrides: { codex: "codex --prefill 'draft from override'" },
|
||||
activeRuntimeEnvironmentId: null
|
||||
activeRuntimeEnvironmentId: null,
|
||||
terminalMainSideEffectAuthority: undefined
|
||||
}
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
@@ -558,7 +593,11 @@ describe('launchAgentBackgroundSession', () => {
|
||||
})
|
||||
|
||||
it('creates background sessions on the active runtime environment', async () => {
|
||||
state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' }
|
||||
state.settings = {
|
||||
agentCmdOverrides: {},
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
terminalMainSideEffectAuthority: undefined
|
||||
}
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
const result = await launchAgentBackgroundSession({
|
||||
|
||||
@@ -37,6 +37,7 @@ import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc'
|
||||
import type { RuntimeTerminalCreate } from '../../../shared/runtime-types'
|
||||
import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
|
||||
import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
|
||||
|
||||
export async function launchAgentBackgroundSession(
|
||||
args: LaunchAgentBackgroundSessionArgs
|
||||
@@ -257,6 +258,16 @@ export async function launchAgentBackgroundSession(
|
||||
useAppStore.getState().clearAgentLaunchConfig(paneKey)
|
||||
onExit?.(ptyId, code)
|
||||
}
|
||||
// Why: for local/SSH PTYs main already parses OSC 9999 and routes it through
|
||||
// the hook server (agentStatus:set → store), so a second store write here
|
||||
// would race/duplicate the authoritative path. Remote-runtime bytes never
|
||||
// transit local main; the kill switch restores the legacy write. The
|
||||
// onAgentStatus callback always fires — automation completion tracking is
|
||||
// this sidecar's own responsibility, not a store side effect.
|
||||
const mainOwnsAgentStatusWrites = isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: store.settings,
|
||||
runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null
|
||||
})
|
||||
const processAgentStatus = createAgentStatusOscProcessor()
|
||||
const handleData = (data: string): void => {
|
||||
data = sshStartupDelivery.handleData(data)
|
||||
@@ -264,9 +275,11 @@ export async function launchAgentBackgroundSession(
|
||||
sshStartupDelivery.schedule(ptyId)
|
||||
const processed = processAgentStatus(data)
|
||||
for (const payload of processed.payloads) {
|
||||
useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, {
|
||||
launchToken
|
||||
})
|
||||
if (!mainOwnsAgentStatusWrites) {
|
||||
useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, {
|
||||
launchToken
|
||||
})
|
||||
}
|
||||
onAgentStatus?.(payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,6 +807,31 @@ describe('pane terminal output scheduler', () => {
|
||||
expect(terminals[2].write).toHaveBeenCalledWith('pane-2')
|
||||
})
|
||||
|
||||
it('drains active foreground backlog before older background terminal backlog', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const backgroundA = createTerminal()
|
||||
const backgroundB = createTerminal()
|
||||
const active = createTerminal()
|
||||
|
||||
writeTerminalOutput(backgroundA, 'background-a', { foreground: false })
|
||||
writeTerminalOutput(backgroundB, 'background-b', { foreground: false })
|
||||
writeTerminalOutput(active, 'active', {
|
||||
foreground: true,
|
||||
latencySensitive: false
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(0)
|
||||
|
||||
expect(active.write).toHaveBeenCalledWith('active', expect.any(Function))
|
||||
expect(active.write.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
backgroundA.write.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(active.write.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
backgroundB.write.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('rotates terminals with remaining backlog behind untouched queued terminals', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
|
||||
@@ -735,6 +735,25 @@ function writeForegroundTerminalChunkWithIntent(
|
||||
}
|
||||
|
||||
function takeNextDrainableEntry(): QueueEntry | null {
|
||||
let largeBacklogEntry: QueueEntry | null = null
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
if (!isEntryDrainable(entry)) {
|
||||
continue
|
||||
}
|
||||
// Why: active/foreground output should be chosen first, not just widen the
|
||||
// drain budget while older background terminals keep their insertion order.
|
||||
if (entry.highPriority) {
|
||||
queuedByTerminal.delete(entry.terminal)
|
||||
return entry
|
||||
}
|
||||
if (!largeBacklogEntry && entry.queuedChars > LARGE_BACKLOG_CHARS) {
|
||||
largeBacklogEntry = entry
|
||||
}
|
||||
}
|
||||
if (largeBacklogEntry) {
|
||||
queuedByTerminal.delete(largeBacklogEntry.terminal)
|
||||
return largeBacklogEntry
|
||||
}
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
if (!isEntryDrainable(entry)) {
|
||||
continue
|
||||
|
||||
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildWindowsPtyCompatibilityOptions,
|
||||
isLocalNativeWindowsConpty,
|
||||
isLocalNativeWindowsPty
|
||||
isLocalNativeWindowsPty,
|
||||
resolveWindowsShellOverride
|
||||
} from './windows-pty-compatibility'
|
||||
|
||||
function writeTerminal(terminal: Terminal, data: string): Promise<void> {
|
||||
@@ -164,6 +165,39 @@ describe('buildWindowsPtyCompatibilityOptions', () => {
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('classifies a global-WSL default shell as non-native, matching main', () => {
|
||||
// Why: main folds the global terminalWindowsShell into its spawn
|
||||
// classification (isNativeWindowsLocalPtySpawn). Without the fold the
|
||||
// renderer would call a tab with no override native-ConPTY while main
|
||||
// never marks it.
|
||||
const windowsUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
expect(
|
||||
isLocalNativeWindowsPty({
|
||||
userAgent: windowsUserAgent,
|
||||
connectionId: null,
|
||||
cwd: 'C:\\repo',
|
||||
shellOverride: resolveWindowsShellOverride(undefined, 'wsl.exe')
|
||||
})
|
||||
).toBe(false)
|
||||
// A tab-level override beats the global setting, both directions.
|
||||
expect(
|
||||
isLocalNativeWindowsPty({
|
||||
userAgent: windowsUserAgent,
|
||||
connectionId: null,
|
||||
cwd: 'C:\\repo',
|
||||
shellOverride: resolveWindowsShellOverride('powershell.exe', 'wsl.exe')
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isLocalNativeWindowsPty({
|
||||
userAgent: windowsUserAgent,
|
||||
connectionId: null,
|
||||
cwd: 'C:\\repo',
|
||||
shellOverride: resolveWindowsShellOverride('wsl.exe', 'powershell.exe')
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes the same local native Windows predicate for related renderer workarounds', () => {
|
||||
expect(
|
||||
isLocalNativeWindowsPty({
|
||||
|
||||
@@ -62,6 +62,17 @@ export function buildWindowsPtyCompatibilityOptions(
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirror of main's effectiveShellOverride fold (pty.ts spawn handlers): a
|
||||
* tab-level shell override wins, else the global Windows shell setting
|
||||
* applies — so renderer and main classify a global-WSL default identically
|
||||
* (the main-side twin is isNativeWindowsLocalPtySpawn). */
|
||||
export function resolveWindowsShellOverride(
|
||||
tabShellOverride: string | null | undefined,
|
||||
globalWindowsShell: string | null | undefined
|
||||
): string | undefined {
|
||||
return tabShellOverride ?? globalWindowsShell ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw client-side heuristic for a native-Windows ConPTY pane (Windows UA, no SSH
|
||||
* connection, non-WSL cwd/shell). Necessary but not sufficient: it cannot tell a
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
|
||||
|
||||
type RuntimeEnvironmentSubscriptionHandle = {
|
||||
@@ -70,10 +71,12 @@ type RemoteRuntimeMultiplexedTerminalState = {
|
||||
streamId: number
|
||||
terminal: string
|
||||
callbacks: RemoteRuntimeMultiplexedTerminalCallbacks
|
||||
acknowledgeOutput: boolean
|
||||
heldAckBytes: number
|
||||
snapshotChunks: Uint8Array<ArrayBufferLike>[]
|
||||
snapshotBytes: number
|
||||
snapshotOverflowed: boolean
|
||||
snapshotTarget: 'initial' | 'request'
|
||||
snapshotTarget: 'initial' | 'request' | 'recovery'
|
||||
snapshotInfo: RemoteRuntimeSnapshotInfo | null
|
||||
initialSnapshotReceived: boolean
|
||||
pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null
|
||||
@@ -109,6 +112,73 @@ const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000
|
||||
const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE =
|
||||
'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.'
|
||||
|
||||
type E2eRemoteTerminalMultiplexAckGateSnapshot = {
|
||||
heldTerminalCount: number
|
||||
heldStreamCount: number
|
||||
heldAckChars: number
|
||||
releasedAckChars: number
|
||||
}
|
||||
|
||||
type E2eRemoteTerminalMultiplexAckGateApi = {
|
||||
hold: (terminals: string[]) => void
|
||||
release: () => void
|
||||
snapshot: () => E2eRemoteTerminalMultiplexAckGateSnapshot
|
||||
}
|
||||
|
||||
type E2eRemoteTerminalMultiplexAckGateWindow = Window & {
|
||||
__remoteTerminalMultiplexAckGate?: E2eRemoteTerminalMultiplexAckGateApi
|
||||
}
|
||||
|
||||
const e2eHeldRemoteAckTerminals = new Set<string>()
|
||||
let e2eReleasedRemoteAckChars = 0
|
||||
|
||||
function shouldHoldE2eRemoteTerminalAck(terminal: string): boolean {
|
||||
return e2eConfig.exposeStore && e2eHeldRemoteAckTerminals.has(terminal)
|
||||
}
|
||||
|
||||
function getE2eRemoteAckSnapshot(): E2eRemoteTerminalMultiplexAckGateSnapshot {
|
||||
let heldStreamCount = 0
|
||||
let heldAckChars = 0
|
||||
for (const multiplexer of multiplexers.values()) {
|
||||
for (const stream of multiplexer.getStreamsForE2e()) {
|
||||
if (stream.heldAckBytes > 0) {
|
||||
heldStreamCount += 1
|
||||
heldAckChars += stream.heldAckBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
heldTerminalCount: e2eHeldRemoteAckTerminals.size,
|
||||
heldStreamCount,
|
||||
heldAckChars,
|
||||
releasedAckChars: e2eReleasedRemoteAckChars
|
||||
}
|
||||
}
|
||||
|
||||
function releaseE2eRemoteTerminalAcks(): void {
|
||||
for (const multiplexer of multiplexers.values()) {
|
||||
e2eReleasedRemoteAckChars += multiplexer.releaseHeldAcksForE2e()
|
||||
}
|
||||
e2eHeldRemoteAckTerminals.clear()
|
||||
}
|
||||
|
||||
function exposeE2eRemoteTerminalMultiplexAckGate(): void {
|
||||
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
const target = window as E2eRemoteTerminalMultiplexAckGateWindow
|
||||
target.__remoteTerminalMultiplexAckGate ??= {
|
||||
hold: (terminals) => {
|
||||
releaseE2eRemoteTerminalAcks()
|
||||
for (const terminal of terminals) {
|
||||
e2eHeldRemoteAckTerminals.add(terminal)
|
||||
}
|
||||
},
|
||||
release: releaseE2eRemoteTerminalAcks,
|
||||
snapshot: getE2eRemoteAckSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteRuntimeTerminalMultiplexer {
|
||||
private readonly streams = new Map<number, RemoteRuntimeMultiplexedTerminalState>()
|
||||
private subscription: RuntimeEnvironmentSubscriptionHandle | null = null
|
||||
@@ -138,6 +208,8 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
streamId,
|
||||
terminal: args.terminal,
|
||||
callbacks: args.callbacks,
|
||||
acknowledgeOutput: args.client.type === 'desktop',
|
||||
heldAckBytes: 0,
|
||||
snapshotChunks: [],
|
||||
snapshotBytes: 0,
|
||||
snapshotOverflowed: false,
|
||||
@@ -181,7 +253,8 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
streamId,
|
||||
terminal: args.terminal,
|
||||
client: args.client,
|
||||
viewport: args.viewport
|
||||
viewport: args.viewport,
|
||||
capabilities: args.client.type === 'desktop' ? { ackOutput: 1 } : undefined
|
||||
})
|
||||
)
|
||||
if (!sent) {
|
||||
@@ -329,10 +402,20 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Output) {
|
||||
const data = decodeTerminalStreamText(frame.payload)
|
||||
stream.callbacks.onData(data, {
|
||||
seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined,
|
||||
rawLength: data.length
|
||||
})
|
||||
try {
|
||||
stream.callbacks.onData(data, {
|
||||
seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined,
|
||||
rawLength: data.length
|
||||
})
|
||||
} finally {
|
||||
if (stream.acknowledgeOutput) {
|
||||
if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) {
|
||||
stream.heldAckBytes += frame.payload.byteLength
|
||||
} else {
|
||||
this.acknowledgeOutput(stream, frame.payload.byteLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
|
||||
@@ -343,7 +426,9 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
typeof requestId === 'number' ||
|
||||
(stream.initialSnapshotReceived && stream.pendingSnapshotRequest)
|
||||
? 'request'
|
||||
: 'initial'
|
||||
: stream.initialSnapshotReceived
|
||||
? 'recovery'
|
||||
: 'initial'
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
|
||||
@@ -386,6 +471,12 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
clearPendingSnapshotRequest(stream)
|
||||
} else if (target === 'initial') {
|
||||
stream.callbacks.onSnapshot(data ?? '')
|
||||
} else if (target === 'recovery') {
|
||||
// Why: a server-pushed recovery snapshot replaces terminal state
|
||||
// mid-session; clear the screen and scrollback before applying it.
|
||||
// An empty snapshot is still applied so stale dropped output does
|
||||
// not linger on a terminal the model says is blank.
|
||||
stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`)
|
||||
}
|
||||
} else if (matchesPendingRequest) {
|
||||
pendingRequest.resolve(null)
|
||||
@@ -458,6 +549,33 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
return id
|
||||
}
|
||||
|
||||
private acknowledgeOutput(stream: RemoteRuntimeMultiplexedTerminalState, bytes: number): boolean {
|
||||
return this.sendFrame(
|
||||
stream.streamId,
|
||||
TerminalStreamOpcode.Ack,
|
||||
encodeTerminalStreamJson({ bytes })
|
||||
)
|
||||
}
|
||||
|
||||
getStreamsForE2e(): Iterable<RemoteRuntimeMultiplexedTerminalState> {
|
||||
return this.streams.values()
|
||||
}
|
||||
|
||||
releaseHeldAcksForE2e(): number {
|
||||
let released = 0
|
||||
for (const stream of this.streams.values()) {
|
||||
if (stream.heldAckBytes <= 0) {
|
||||
continue
|
||||
}
|
||||
const bytes = stream.heldAckBytes
|
||||
stream.heldAckBytes = 0
|
||||
if (this.acknowledgeOutput(stream, bytes)) {
|
||||
released += bytes
|
||||
}
|
||||
}
|
||||
return released
|
||||
}
|
||||
|
||||
private sendFrame(
|
||||
streamId: number,
|
||||
opcode: TerminalStreamOpcode,
|
||||
@@ -540,6 +658,7 @@ function releaseRemoteRuntimeTerminalMultiplexer(
|
||||
export function getRemoteRuntimeTerminalMultiplexer(
|
||||
environmentId: string
|
||||
): RemoteRuntimeTerminalMultiplexer {
|
||||
exposeE2eRemoteTerminalMultiplexAckGate()
|
||||
let multiplexer = multiplexers.get(environmentId)
|
||||
if (!multiplexer) {
|
||||
multiplexer = new RemoteRuntimeTerminalMultiplexer(
|
||||
@@ -557,6 +676,8 @@ export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number {
|
||||
|
||||
export function resetRemoteRuntimeTerminalMultiplexersForTests(): void {
|
||||
multiplexers.clear()
|
||||
e2eHeldRemoteAckTerminals.clear()
|
||||
e2eReleasedRemoteAckChars = 0
|
||||
}
|
||||
|
||||
function concatBytes(chunks: Uint8Array<ArrayBufferLike>[]): Uint8Array<ArrayBufferLike> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
import {
|
||||
@@ -98,8 +99,12 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0])
|
||||
expect(subscribeFrame?.opcode).toBe(TerminalStreamOpcode.Subscribe)
|
||||
const subscribePayload =
|
||||
subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload)
|
||||
subscribeFrame &&
|
||||
decodeTerminalStreamJson<{ streamId: number; capabilities?: { ackOutput?: 1 } }>(
|
||||
subscribeFrame.payload
|
||||
)
|
||||
expect(subscribePayload?.streamId).toEqual(expect.any(Number))
|
||||
expect(subscribePayload?.capabilities).toEqual({ ackOutput: 1 })
|
||||
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
@@ -111,6 +116,12 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
)
|
||||
|
||||
expect(watcher).toHaveBeenCalledWith('live')
|
||||
const ackFrame = sendBinary.mock.calls
|
||||
.slice(1)
|
||||
.map((call) => decodeTerminalStreamFrame(call[0]))
|
||||
.find((frame) => frame?.opcode === TerminalStreamOpcode.Ack)
|
||||
expect(ackFrame?.streamId).toBe(subscribePayload!.streamId)
|
||||
expect(ackFrame && decodeTerminalStreamJson(ackFrame.payload)).toEqual({ bytes: 4 })
|
||||
expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(1)
|
||||
dispose()
|
||||
expect(unsubscribe).toHaveBeenCalled()
|
||||
@@ -189,3 +200,222 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('remote runtime terminal multiplex ACK gate', () => {
|
||||
const runtimeSubscribe = vi.fn()
|
||||
const sendBinary = vi.fn()
|
||||
const unsubscribe = vi.fn()
|
||||
let callbacks: {
|
||||
onResponse: (response: unknown) => void
|
||||
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
onError?: (error: { message: string }) => void
|
||||
onClose?: () => void
|
||||
} | null = null
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
callbacks = null
|
||||
runtimeSubscribe.mockImplementation(async (_args: unknown, nextCallbacks: typeof callbacks) => {
|
||||
callbacks = nextCallbacks
|
||||
queueMicrotask(() =>
|
||||
callbacks?.onResponse({
|
||||
ok: true,
|
||||
result: { type: 'ready' }
|
||||
})
|
||||
)
|
||||
return { unsubscribe, sendBinary }
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
e2e: {
|
||||
getConfig: () => ({ exposeStore: true })
|
||||
},
|
||||
runtimeEnvironments: {
|
||||
subscribe: runtimeSubscribe
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('holds and releases ACKs for selected remote terminal streams only', async () => {
|
||||
const { getRemoteRuntimeTerminalMultiplexer, resetRemoteRuntimeTerminalMultiplexersForTests } =
|
||||
await import('./remote-runtime-terminal-multiplexer')
|
||||
resetRemoteRuntimeTerminalMultiplexersForTests()
|
||||
|
||||
const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-ack-gate')
|
||||
const heldTerminal = await multiplexer.subscribeTerminal({
|
||||
terminal: 'terminal-held',
|
||||
client: { id: 'desktop-held', type: 'desktop' },
|
||||
callbacks: {
|
||||
onData: vi.fn(),
|
||||
onSnapshot: vi.fn()
|
||||
}
|
||||
})
|
||||
const liveTerminal = await multiplexer.subscribeTerminal({
|
||||
terminal: 'terminal-live',
|
||||
client: { id: 'desktop-live', type: 'desktop' },
|
||||
callbacks: {
|
||||
onData: vi.fn(),
|
||||
onSnapshot: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(sendBinary).toHaveBeenCalledTimes(2))
|
||||
const heldStreamId = heldTerminal.streamId
|
||||
const liveStreamId = liveTerminal.streamId
|
||||
const gate = (
|
||||
window as typeof window & {
|
||||
__remoteTerminalMultiplexAckGate?: {
|
||||
hold: (terminals: string[]) => void
|
||||
release: () => void
|
||||
snapshot: () => {
|
||||
heldTerminalCount: number
|
||||
heldStreamCount: number
|
||||
heldAckChars: number
|
||||
releasedAckChars: number
|
||||
}
|
||||
}
|
||||
}
|
||||
).__remoteTerminalMultiplexAckGate
|
||||
expect(gate).toBeDefined()
|
||||
gate?.hold(['terminal-held'])
|
||||
sendBinary.mockClear()
|
||||
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
streamId: heldStreamId,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamText('held-output')
|
||||
})
|
||||
)
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
streamId: liveStreamId,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamText('live-output')
|
||||
})
|
||||
)
|
||||
|
||||
const immediateAckFrames = sendBinary.mock.calls
|
||||
.map((call) => decodeTerminalStreamFrame(call[0]))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack)
|
||||
expect(immediateAckFrames).toHaveLength(1)
|
||||
expect(immediateAckFrames[0]?.streamId).toBe(liveStreamId)
|
||||
expect(gate?.snapshot()).toMatchObject({
|
||||
heldTerminalCount: 1,
|
||||
heldStreamCount: 1,
|
||||
heldAckChars: 'held-output'.length
|
||||
})
|
||||
|
||||
gate?.release()
|
||||
const allAckFrames = sendBinary.mock.calls
|
||||
.map((call) => decodeTerminalStreamFrame(call[0]))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack)
|
||||
const releasedAck = allAckFrames.find((frame) => frame?.streamId === heldStreamId)
|
||||
expect(releasedAck && decodeTerminalStreamJson(releasedAck.payload)).toEqual({
|
||||
bytes: 'held-output'.length
|
||||
})
|
||||
expect(gate?.snapshot()).toMatchObject({
|
||||
heldTerminalCount: 0,
|
||||
heldStreamCount: 0,
|
||||
heldAckChars: 0,
|
||||
releasedAckChars: 'held-output'.length
|
||||
})
|
||||
|
||||
heldTerminal.close()
|
||||
liveTerminal.close()
|
||||
})
|
||||
|
||||
it('applies mid-session recovery snapshots without re-subscribing', async () => {
|
||||
const { getRemoteRuntimeTerminalMultiplexer } =
|
||||
await import('./remote-runtime-terminal-multiplexer')
|
||||
resetRemoteRuntimeTerminalMultiplexersForTests()
|
||||
|
||||
const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-recovery')
|
||||
const onSnapshot = vi.fn()
|
||||
const onSubscribed = vi.fn()
|
||||
const stream = await multiplexer.subscribeTerminal({
|
||||
terminal: 'terminal-recovery',
|
||||
client: { id: 'desktop-recovery', type: 'desktop' },
|
||||
callbacks: {
|
||||
onData: vi.fn(),
|
||||
onSnapshot,
|
||||
onSubscribed
|
||||
}
|
||||
})
|
||||
await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled())
|
||||
const streamId = stream.streamId
|
||||
|
||||
const injectSnapshot = (info: Record<string, unknown>, text: string): void => {
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotStart,
|
||||
streamId,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson(info)
|
||||
})
|
||||
)
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotChunk,
|
||||
streamId,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamText(text)
|
||||
})
|
||||
)
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotEnd,
|
||||
streamId,
|
||||
seq: 3,
|
||||
payload: new Uint8Array(0)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state')
|
||||
expect(onSnapshot).toHaveBeenCalledWith('initial state')
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
|
||||
injectSnapshot(
|
||||
{
|
||||
kind: 'scrollback',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
reason: 'ack-pending-overflow',
|
||||
truncated: false
|
||||
},
|
||||
'recovered state'
|
||||
)
|
||||
// Why: an unsolicited recovery snapshot replaces terminal state, so it
|
||||
// clears screen and scrollback first and must not replay the subscribe
|
||||
// lifecycle.
|
||||
expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`)
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Why: an empty recovery snapshot means the model terminal is blank, so
|
||||
// the client must still clear stale dropped output.
|
||||
injectSnapshot(
|
||||
{
|
||||
kind: 'scrollback',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
reason: 'ack-pending-overflow',
|
||||
truncated: false
|
||||
},
|
||||
''
|
||||
)
|
||||
expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H')
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
|
||||
stream.close()
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user