diff --git a/config/scripts/check-terminal-perf-report-budgets.mjs b/config/scripts/check-terminal-perf-report-budgets.mjs index 693822373f0..dd7185189d8 100644 --- a/config/scripts/check-terminal-perf-report-budgets.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.mjs @@ -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`) } diff --git a/config/scripts/check-terminal-perf-report-budgets.test.mjs b/config/scripts/check-terminal-perf-report-budgets.test.mjs index d1fd378b498..e6129975151 100644 --- a/config/scripts/check-terminal-perf-report-budgets.test.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.test.mjs @@ -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') diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs new file mode 100644 index 00000000000..984b33bb485 --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -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=]... --output ' + ) + } + 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( + `` + ) + parts.push(`${escapeHtml(title)}`) + // 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( + `` + ) + parts.push( + `${value % 1 === 0 ? value : value.toFixed(1)}` + ) + } + // X labels + revisions.forEach((revision, index) => { + parts.push( + `${escapeHtml(revision.label)}` + ) + }) + // 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(``) + for (const point of points) { + const x = xFor(point.index) + const y = yFor(point.value) + parts.push(``) + parts.push( + `${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}` + ) + } + } + parts.push('') + + const legend = metrics + .map((metric) => { + const color = SERIES_COLORS[metric.key] ?? '#475569' + return `${escapeHtml(metric.label)}` + }) + .join('') + return `
${parts.join('')}
${legend} ms — lower is better
` +} + +function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) { + if (baseline == null || latest == null) { + return '—' + } + 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 `${escapeHtml(pctLabel)} (${escapeHtml(diffLabel)})` +} + +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) => `${value == null ? '—' : escapeHtml(format(value))}`) + .join('') + const baseline = values.find((value) => value != null) + const latest = [...values].reverse().find((value) => value != null) + metricRows.push( + `${escapeHtml(metric.label)}${cells}${deltaCell(baseline, latest, { + zeroBudget: metric.key === 'rendererDroppedBacklogs' + })}` + ) + } + if (metricRows.length === 0) { + return '' + } + const headers = revisions.map((revision) => `${escapeHtml(revision.label)}`).join('') + return `
+

${escapeHtml(title)} ${escapeHtml(scenario)}

+ +${headers} +${metricRows.join('')} +
MetricΔ first → last
+
` +} + +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(`
+
${escapeHtml(scenarioTitle(scenario, lastRow))}
+
${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}
+
typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)
+
`) + } + if (cards.length === 0) { + return '' + } + return `

Baseline vs latest

${cards.join('')}
` +} + +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 ? 'Pass' : 'Fail' + const failureList = + failures.length === 0 + ? '' + : `` + return `

Budget status — ${escapeHtml(latestRevision.label)}

+

${latestRevision.rows.length} scenario rows checked: ${status}

${failureList}
` +} + +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 + ? ' (failed assertions at this revision; metrics still recorded)' + : '' + return `
  • ${escapeHtml(revision.label)} — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}
  • ` + }) + .join('') + return `
      ${items}
    ` +} + +function renderRawDetails(revisions) { + return revisions + .map((revision) => { + const rows = revision.rows + .map( + (row) => + `${escapeHtml(row.scenario)}${row.panes ?? '—'}${escapeHtml(formatMs(row.medianMs))}${escapeHtml(formatMs(row.worstMs))}${escapeHtml(formatMs(row.scrollMs))}${escapeHtml(formatMs(row.restoreMs))}${escapeHtml(formatMs(row.revisitMs))}${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}${row.rendererDroppedBacklogs ?? '—'}` + ) + .join('') + return `
    Raw rows — ${escapeHtml(revision.label)} + + +${rows}
    ScenarioPanesMedianWorstScrollRestoreRevisitRenderer peakHidden skippedDrops
    ` + }) + .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 ` + + + + +Terminal Performance Over Time + + + +

    Terminal Performance Over Time

    +

    Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.

    +${renderInputsMeta(revisions)} +${renderHeadline(revisions, matrix)} +${charts ? `

    Trends across revisions

    ${charts}
    ` : ''} +

    Metric detail by scenario

    ${tables}
    +${renderBudgets(revisions.at(-1))} +

    Raw data

    ${renderRawDetails(revisions)}
    + + +` +} + +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) + } +} diff --git a/config/scripts/generate-terminal-perf-html-report.test.mjs b/config/scripts/generate-terminal-perf-html-report.test.mjs new file mode 100644 index 00000000000..a804bb6639d --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.test.mjs @@ -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('') + 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('—') + }) + + 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') + }) +}) diff --git a/config/scripts/run-terminal-scale-perf-report-gate.mjs b/config/scripts/run-terminal-scale-perf-report-gate.mjs index eef60041da1..59b1b443fa9 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.mjs @@ -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) { diff --git a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs index 929e2d5b9ba..e4d7be8861e 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs @@ -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({ diff --git a/config/scripts/summarize-terminal-perf-report.mjs b/config/scripts/summarize-terminal-perf-report.mjs index 6d2adea54f9..2e4f77732b3 100644 --- a/config/scripts/summarize-terminal-perf-report.mjs +++ b/config/scripts/summarize-terminal-perf-report.mjs @@ -25,6 +25,7 @@ function printMarkdownTable(rows) { ['Frames', 'frames'], ['Median', 'median'], ['Worst', 'worst'], + ['Revisit', 'revisit'], ['Scroll', 'scroll'], ['Restore', 'restore'], ['Max Drift', 'maxTimerDrift'], diff --git a/config/scripts/terminal-perf-report-rows.mjs b/config/scripts/terminal-perf-report-rows.mjs new file mode 100644 index 00000000000..c1f562f3b9e --- /dev/null +++ b/config/scripts/terminal-perf-report-rows.mjs @@ -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('"', '"') +} diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md new file mode 100644 index 00000000000..eeaf17877ea --- /dev/null +++ b/docs/reference/terminal-hidden-view-parking.md @@ -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. diff --git a/docs/reference/terminal-model-view-contract.md b/docs/reference/terminal-model-view-contract.md new file mode 100644 index 00000000000..79fb55575f3 --- /dev/null +++ b/docs/reference/terminal-model-view-contract.md @@ -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. diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md new file mode 100644 index 00000000000..e05b5768836 --- /dev/null +++ b/docs/reference/terminal-query-authority.md @@ -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. diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md new file mode 100644 index 00000000000..852a607d5bf --- /dev/null +++ b/docs/reference/terminal-side-effect-authority.md @@ -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. diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md index a9c3165e7cf..e3e7a125988 100644 --- a/docs/terminal-main-owned-state.md +++ b/docs/terminal-main-owned-state.md @@ -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. diff --git a/package.json b/package.json index 82c0f8493c6..61778db13ea 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index e32415aa9a3..63b6bc4ab3e 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -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 }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f4d717f1999..c30d385747d 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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() + private sleepRestoreSessionIds = new Set() private activeSessionIds = new Set() private dirtySessionVersions = new Map() // 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 { - // 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 { 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. diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 1a7dd6c5abe..48ea3f2e0f9 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -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 }) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index ed9e9345d87..af233186712 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -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 +// 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 { + /** 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 { + 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 { 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((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') diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 0edcdb91ba2..66194b8a054 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -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 diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index fb5b961cc98..c34b58743f8 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -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) diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts new file mode 100644 index 00000000000..b54e12344ba --- /dev/null +++ b/src/main/daemon/terminal-mouse-mode-mirror.ts @@ -0,0 +1,104 @@ +import type { TerminalModes } from './types' + +type MouseTrackingMode = NonNullable + +// 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) + } +} diff --git a/src/main/daemon/terminal-osc-cwd-title-scanner.ts b/src/main/daemon/terminal-osc-cwd-title-scanner.ts new file mode 100644 index 00000000000..6d1261a5a46 --- /dev/null +++ b/src/main/daemon/terminal-osc-cwd-title-scanner.ts @@ -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 + } + } +} diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts new file mode 100644 index 00000000000..551ffddbe21 --- /dev/null +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -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 + +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 = { + 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() + const specialOverrides = new Map() + + 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() + } + } +} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 868afa81610..0a882e9786a 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -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 diff --git a/src/main/index.ts b/src/main/index.ts index 8bc44bdc36d..0a3113acf49 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(), diff --git a/src/main/ipc/pty-hidden-delivery-gate.test.ts b/src/main/ipc/pty-hidden-delivery-gate.test.ts new file mode 100644 index 00000000000..c85c4e2c8e0 --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.test.ts @@ -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) + }) +}) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts new file mode 100644 index 00000000000..b0269e600d2 --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -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() +// 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() +// 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() + +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() +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d357b7ec42d..964fabf07d0 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -187,6 +187,11 @@ import { rebindLocalProviderListeners, unregisterSshPtyProvider } from './pty' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + isHiddenRendererPty +} from './pty-hidden-delivery-gate' +import { OrcaRuntimeService } from '../runtime/orca-runtime' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import { encodePowerShellCommand, @@ -305,6 +310,9 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() + // Why: hidden-delivery gate state is module-level by design (PTY-keyed, + // not window-keyed); tests must not leak hidden bits across cases. + _resetHiddenRendererPtyDeliveryGateForTest() // Why: mirror real Electron — ipcMain.handle throws on a duplicate channel // unless removeHandler cleared it first. This catches a re-registration @@ -508,11 +516,12 @@ describe('registerPtyHandlers', () => { const spawn = vi.fn(async (options: { sessionId?: string }) => ({ id: options.sessionId ?? 'daemon-pty' })) + const write = vi.fn() let dataHandler: ((payload: { id: string; data: string }) => void) | null = null let exitHandler: ((payload: { id: string; code: number }) => void) | null = null setLocalPtyProvider({ spawn, - write: vi.fn(), + write, resize: vi.fn(), kill: vi.fn(), shutdown: vi.fn(), @@ -541,6 +550,7 @@ describe('registerPtyHandlers', () => { } as never) return { spawn, + write, emitData: (id: string, data: string) => dataHandler?.({ id, data }), emitExit: (id: string, code = 0) => exitHandler?.({ id, code }) } @@ -607,6 +617,32 @@ describe('registerPtyHandlers', () => { ) => void } + function getPtySetHiddenRendererPtyListener(): ( + event: unknown, + args: { id: string; hidden: boolean } + ) => void { + const hiddenCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setHiddenRendererPty' + ) + if (!hiddenCall) { + throw new Error('missing pty:setHiddenRendererPty listener') + } + return hiddenCall[1] as (event: unknown, args: { id: string; hidden: boolean }) => void + } + + function getPtySetDeliveryInterestListener(): ( + event: unknown, + args: { id: string; interested: boolean } + ) => void { + const interestCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setPtyDeliveryInterest' + ) + if (!interestCall) { + throw new Error('missing pty:setPtyDeliveryInterest listener') + } + return interestCall[1] as (event: unknown, args: { id: string; interested: boolean }) => void + } + /** Helper: trigger pty:spawn and return the env passed to node-pty. */ async function spawnAndGetEnv( argsEnv?: Record, @@ -1581,6 +1617,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -1725,6 +1762,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3170,6 +3208,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn(() => 13), @@ -3229,6 +3268,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3870,6 +3910,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn() } @@ -4095,6 +4136,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_wrong'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4146,6 +4188,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4185,6 +4228,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -4632,6 +4676,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4703,6 +4748,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -4789,6 +4835,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -4886,6 +4933,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4994,6 +5042,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5087,6 +5136,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5188,6 +5238,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5246,6 +5297,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5293,6 +5345,7 @@ describe('registerPtyHandlers', () => { it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_trusted'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5320,6 +5373,7 @@ describe('registerPtyHandlers', () => { }) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_wsl'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -7319,6 +7373,711 @@ describe('registerPtyHandlers', () => { } }) + describe('hidden renderer delivery gate', () => { + it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'hidden output') + vi.advanceTimersByTime(50) + + // Model ingestion still ran — only renderer delivery was dropped. + expect(runtime.onPtyData).toHaveBeenCalledWith( + result.id, + 'hidden output', + expect.any(Number) + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data chunk is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + + // Subsequent gated chunks drop silently — the marker is one-shot. + daemon.emitData(result.id, 'more hidden output') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1, + hiddenDeliveryDroppedChars: 'hidden output'.length + 'more hidden output'.length, + hiddenDeliveryDroppedChunks: 2, + pendingPtyCount: 0, + rendererInFlightChars: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the interactive bypass gated for hidden PTYs', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + const setHidden = getPtySetHiddenRendererPtyListener() + + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) + setHidden(null, { id: spawnResult.id, hidden: true }) + mainWindow.webContents.send.mockClear() + + // A keystroke-sized redraw would take the immediate path when visible. + mockProc.emitData('\x1b[20;2Hredraw') + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses the gate while renderer delivery interest is registered', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setInterest(null, { id: spawnResult.id, interested: true }) + mockProc.emitData('sidecar bytes') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'sidecar bytes' + }) + + setInterest(null, { id: spawnResult.id, interested: false }) + mainWindow.webContents.send.mockClear() + mockProc.emitData('gated bytes') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])('keeps delivery when the %s kill switch is off', async (_name, settings) => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('still delivered') + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'still delivered' + }) + } finally { + vi.useRealTimers() + } + }) + + it('drops queued pending data when a PTY is marked hidden', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + mockProc.emitData('queued before hidden') + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + setHidden(null, { id: spawnResult.id, hidden: true }) + + // The queued bytes are model-owned; only the restore marker goes out. + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('re-emits the restore marker on unhide and resumes delivery', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a renderer reload can replace the view that latched + // restore-needed; unhide repeats the marker so the live view heals. + setHidden(null, { id: spawnResult.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + + mockProc.emitData('visible again') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible again' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not emit an unhide marker when nothing was dropped', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('clears gate state on PTY exit', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + + setHidden(null, { id: spawnResult.id, hidden: true }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1 + }) + + mockProc.emitExit(0) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a hidden remount so reveal still restores', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a hidden remount (tab move, parking handoff) re-marks the PTY + // without an unhide in between. The fresh view never saw the first + // marker, so re-marking must NOT erase the drop memory. + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a renderer reload while clearing hidden/interest state', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + // Why daemon provider: it survives renderer reloads (the scenario + // under test) and keeps the LocalPtyProvider orphan-kill handler off + // this webContents, so 'did-finish-load' maps to the gate reset only. + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'dropped while hidden') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Renderer reload: hidden marks die with the old renderer, but the + // dropped bytes were never restored — memory must survive. + reloadHandlers[0]() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0 + }) + + // The reloaded pane's first sync re-marks hidden, then reveals. + setHidden(null, { id: result.id, hidden: true }) + setHidden(null, { id: result.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'unhide', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + + it('clears leaked delivery interest on renderer reload so the gate re-engages', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + // A sidecar holds interest, so hidden bytes still flow. + setInterest(null, { id: result.id, interested: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'sidecar bytes') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith( + 'pty:data', + expect.objectContaining({ id: result.id, data: 'sidecar bytes' }) + ) + + // Why: the renderer reload killed the sidecar's ref count without a + // release IPC — the leaked hold must not force-feed the PTY forever. + reloadHandlers[0]() + mainWindow.webContents.send.mockClear() + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'gated after reload') + vi.advanceTimersByTime(50) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + }) + + describe('hidden-at-spawn mark (initiallyHidden)', () => { + // terminal-query-authority.md §races: the renderer declares hidden-at- + // spawn so main marks the PTY before its first byte — the spawn-time + // query window where neither side replied (the non-codex DA1 loss) is + // closed by the gate + responder owning queries from byte one. + function createRuntimeMock() { + return { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + } + + it('marks a daemon PTY hidden before spawn resolves so byte zero is gated', async () => { + vi.useFakeTimers() + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const spawnGate = makeDeferred() + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => { + await spawnGate.promise + return { id: options.sessionId ?? 'daemon-pty' } + }) + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) as Promise<{ id: string }> + // Let the handler run up to the awaited provider.spawn. + await Promise.resolve() + mainWindow.webContents.send.mockClear() + + // Daemon PTYs can emit prompt bytes before spawn() resolves — the + // pre-spawn mark must already gate them. + expect(isHiddenRendererPty('daemon-session')).toBe(true) + daemon.emitData('daemon-session', 'pre-spawn prompt\x1b[c') + vi.advanceTimersByTime(50) + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'daemon-session', + 'pre-spawn prompt\x1b[c', + expect.any(Number) + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'daemon-session', + reason: 'hidden-drop', + markerSeq: 42 + }) + + spawnGate.resolve() + const result = await spawnPromise + expect(isHiddenRendererPty(result.id)).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('clears the pre-spawn hidden mark when the spawn fails', async () => { + const daemon = installObservableDaemonTestProvider() + daemon.spawn.mockRejectedValue(new Error('spawn exploded')) + registerPtyHandlers(mainWindow as never) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) + ).rejects.toThrow('spawn exploded') + + // A later visible attach reusing this session id must not start gated. + expect(isHiddenRendererPty('daemon-session')).toBe(false) + }) + + it('marks local PTYs hidden after spawn, before their first data task', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + initiallyHidden: true + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(true) + mockProc.emitData('first chunk') + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps spawns without the flag delivering to the renderer (visible unchanged)', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(false) + mockProc.emitData('visible output') + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('answers DA1 from the model on the first chunk of a hidden-at-spawn PTY', async () => { + // End-to-end through a REAL runtime: spawn-marked → first chunk dropped + // → runtime emulator parses the query → reply written to the provider + // input path (the renderer never saw the bytes; main is the answerer). + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService({ + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true + }) + } as never) + + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + })) as { id: string } + + daemon.emitData(result.id, '\x1b[c') + // Settle the per-PTY emulator writeChain (and the reply it forwards). + await runtime.serializeMainTerminalBuffer(result.id) + + expect(daemon.write).toHaveBeenCalledWith(result.id, '\x1b[?1;2c') + }) + }) + + it('caps pending renderer delivery per PTY with oldest-drop and one restore marker', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + // 3 MB in one starved pending entry: the scrollback-scaled cap (2 MB at + // default settings) drops the buffered bytes to O(1) memory. One + // out-of-band restore marker fires; the droppedOutput sentinel then + // routes the pane through the main-owned snapshot repaint. + mockProc.emitData('x'.repeat(1024 * 1024) + 'y'.repeat(2 * 1024 * 1024)) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'pending-cap' + }) + + // A second overflow before the entry drains must not re-mark. + mockProc.emitData('z'.repeat(64 * 1024)) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: '', + droppedOutput: true + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])( + 'keeps the pending cap active without a restore marker when the %s kill switch is off', + async (_name, settings) => { + // Why: the scrollback-scaled pending cap ships independently of the gate + // (#7150) — the droppedOutput sentinel repaints the pane from the + // main-owned snapshot even with the model/view kill switches off. Only the + // gate's out-of-band pty:modelRestoreNeeded marker is switch-scoped. + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + mockProc.emitData('x'.repeat(3 * 1024 * 1024)) + + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:modelRestoreNeeded', + expect.anything() + ) + + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: '', + droppedOutput: true + }) + } finally { + vi.useRealTimers() + } + } + ) + it('batches stale PTY output after the interactive window expires', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -7599,6 +8358,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), @@ -7863,6 +8623,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -7903,6 +8664,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -7911,16 +8673,23 @@ describe('registerPtyHandlers', () => { spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never, runtime as never) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + // Why both: a reload fires the hidden-delivery gate reset AND the orphan + // cleanup; invoke every registered listener like a real did-finish-load. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + expect(didFinishLoadHandlers.length).toBeGreaterThan(0) + const didFinishLoad = (): void => { + for (const handler of didFinishLoadHandlers) { + handler() + } + } await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) // The first load after spawn only advances generation. The second one sees // this PTY as belonging to a prior page load and kills it as orphaned. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( killSpy.mock.invocationCallOrder[0] @@ -7949,10 +8718,12 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(firstWindow as never) - const didFinishLoad = firstWindow.webContents.on.mock.calls.find( + // Two listeners on the first (LocalPtyProvider) window: the renderer-gate + // reset and the orphan cleanup. + const firstWindowLoadHandlers = firstWindow.webContents.on.mock.calls.filter( ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + ) + expect(firstWindowLoadHandlers).toHaveLength(2) setLocalPtyProvider({ spawn: vi.fn(), @@ -7967,13 +8738,20 @@ describe('registerPtyHandlers', () => { } as never) registerPtyHandlers(secondWindow as never) - expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( - 'did-finish-load', - didFinishLoad - ) + // Every first-window load listener was detached from its webContents. + for (const [, handler] of firstWindowLoadHandlers) { + expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( + 'did-finish-load', + handler + ) + } + // The non-Local provider keeps orphan cleanup off the second window — + // only the renderer-gate reset listener remains. expect( - secondWindow.webContents.on.mock.calls.some(([eventName]) => eventName === 'did-finish-load') - ).toBe(false) + secondWindow.webContents.on.mock.calls.filter( + ([eventName]) => eventName === 'did-finish-load' + ) + ).toHaveLength(1) }) it('clears PTY state even when kill reports the process is already gone', async () => { @@ -8249,14 +9027,63 @@ describe('registerPtyHandlers', () => { expect(runtime.serializeHiddenOutputRecoveryBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 50_000 }) + // Why pendingDeliveryStartSeq === seq: the pending renderer-delivery + // queue is empty, so the renderer's post-restore duplicate window is + // empty too — low-seq live chunks (fresh seq domain) must not be + // dropped against the snapshot baseline. expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, cwd: '/projects/restored', seq: 42, + pendingDeliveryStartSeq: 42, source: 'headless' }) }) + + it('reports where the undelivered pending backlog starts alongside the snapshot', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preAllocateHandleForPty: vi.fn(() => null), + getPtyOutputSequence: vi.fn(() => 2_472), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot\r\n', + cols: 100, + rows: 30, + seq: 2_472, + source: 'headless' + }) + } + try { + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + + // Starved pending entry: bytes ingested up to seq 2_472 but not yet + // flushed to the renderer — they can still arrive after the snapshot. + mockProc.emitData('frame-bytes') + + const result = (await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: spawnResult.id + })) as { pendingDeliveryStartSeq?: number } + + expect(result.pendingDeliveryStartSeq).toBe(2_472 - 'frame-bytes'.length) + } finally { + vi.useRealTimers() + } + }) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index d134373bdcd..22c3a09c390 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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() + // 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() const rendererInFlightCharsByPty = new Map() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | 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) diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 540079a510e..ac33bb2931b 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -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]) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 712f49f6d43..ad17e785383 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -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) => { const sanitizedArgs = sanitizeRendererSettingsUpdate(args) // Why: Floating Workspace grants are trusted only when written by the diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 0b148bf6ada..cd96765a62c 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -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 diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index a5523e8a496..bfa193816e6 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 + } + ).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 + } + ).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 } + ).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') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d63cea1f0b4..d4c09bee5bb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 >() + // 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() + // 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() // 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() // 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() + // 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) | 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(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) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 364940bd5cd..828bae3e019 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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() + let ackTotalInFlightBytes = 0 let resolveMultiplex = (): void => {} const multiplexClosed = new Promise((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 => { + 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(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) diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index abc9ab20372..9cdc88a021b 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -9,6 +9,9 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types' function stubRuntime(overrides: Partial = {}): 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 } diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index a19507a4a38..648ed629cc0 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -18,6 +18,9 @@ import { function stubRuntime(overrides: Partial = {}): 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[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map 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(() => {})), + 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[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map 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(() => {})), + 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() + 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() + 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[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map 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(() => {})), + 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[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map 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(() => {})), + 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[] = [] @@ -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[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map 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((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(() => {})) + }) + 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[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map 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((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(() => {})) + }) + 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 { diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index 2df3dbc7173..05c58e8098e 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -15,6 +15,9 @@ import { function stubRuntime(overrides: Partial = {}): 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 } diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 76aa0b34bb0..00a56c7d90d 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -15,6 +15,9 @@ import { function stubRuntime(overrides: Partial = {}): 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[] = [] const cleanups = new Map 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 => 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[] = [] + const cleanups = new Map 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(() => {})), + 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 => 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() + } + }) }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 5f50c32845e..2d7579cce48 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -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[] = [] + const binaryFrames: Uint8Array[] = [] + const onError = vi.fn() + const subscription = await subscribeRemoteRuntimeRequest( + pairing, + 'terminal.multiplex', + {}, + 15_000, + { + onResponse: (response) => responses.push(response as Record), + 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) diff --git a/src/main/runtime/terminal-model-query-authority.test.ts b/src/main/runtime/terminal-model-query-authority.test.ts new file mode 100644 index 00000000000..3217a98022e --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.test.ts @@ -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 = {}): 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) + }) +}) diff --git a/src/main/runtime/terminal-model-query-authority.ts b/src/main/runtime/terminal-model-query-authority.ts new file mode 100644 index 00000000000..e1e0f9c28ef --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.ts @@ -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() + +// 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() + +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() +} diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts new file mode 100644 index 00000000000..b6988e06731 --- /dev/null +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -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 { + 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 { + 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;', 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([]) + }) +}) diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts new file mode 100644 index 00000000000..ee55581bfe9 --- /dev/null +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -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() + +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() +} diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index d1bef968a5a..5f9a41e6c1f 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -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 + } + 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 + } + 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', () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b078db39760..0c7b2b2fcef 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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, diff --git a/src/main/synthetic-title-frame-routing.test.ts b/src/main/synthetic-title-frame-routing.test.ts new file mode 100644 index 00000000000..b4bbf35ecd2 --- /dev/null +++ b/src/main/synthetic-title-frame-routing.test.ts @@ -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) + }) +}) diff --git a/src/main/synthetic-title-frame-routing.ts b/src/main/synthetic-title-frame-routing.ts new file mode 100644 index 00000000000..3bb10a030fa --- /dev/null +++ b/src/main/synthetic-title-frame-routing.ts @@ -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 | null | undefined +): boolean { + return settings?.terminalMainSideEffectAuthority === false +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index df766b4733e..506187fff88 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise @@ -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 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 onExit: (callback: (data: { id: string; code: number }) => void) => () => void onSerializeBufferRequest: ( callback: (data: { @@ -1894,6 +1929,10 @@ export type PreloadApi = { telemetryAcknowledgeBanner: () => Promise settings: { get: () => Promise + /** 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) => Promise listFonts: () => Promise previewGhosttyImport: () => Promise diff --git a/src/preload/e2e-config.ts b/src/preload/e2e-config.ts index 079bdceb181..f1cdf668ce3 100644 --- a/src/preload/e2e-config.ts +++ b/src/preload/e2e-config.ts @@ -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 }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 1545dae63bf..89fb548acce 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => 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 => @@ -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 => + 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 => 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): Promise => ipcRenderer.invoke('settings:set', args), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 697d0dd45f5..b7596376516 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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. diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 8a17d67af80..0b222e12525 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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([ type TerminalStoreSnapshot = ReturnType +function haveSameWorktreeIds(left: ReadonlySet, right: ReadonlySet): 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()) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) + const terminalWorktreeHiddenSinceRef = useRef(new Map()) + const terminalWorktreeParkingTimersRef = useRef(new Map()) 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()) - const [, setBackgroundMountRevision] = useState(0) + const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) + const [terminalParkingRevision, setTerminalParkingRevision] = useState(0) + const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( + () => 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() + 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 ( ) @@ -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 (
    diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 0e68af9126f..ec3d65aaf74 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -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 ( | 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) + ) +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts new file mode 100644 index 00000000000..bd492d5194c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -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 + clearRuntimePaneTitle: ReturnType + updateTabTitle: ReturnType + markWorktreeUnread: ReturnType + markTerminalTabUnread: ReturnType + markTerminalPaneUnread: ReturnType + setCacheTimerStartedAt: ReturnType + observeTerminalGitHubPullRequestLink: ReturnType +} + +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 = {} + ): Promise<{ dispose: () => void; sendInput: ReturnType }> { + 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 { + 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 { + 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' ? '' : 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 } } + ).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 } } + ).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) + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts new file mode 100644 index 00000000000..38c72bd70c6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -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 + +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 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 | null = null + let agentTaskCompleteTimer: ReturnType | 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 +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts new file mode 100644 index 00000000000..da804026de6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts @@ -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()))) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 090ef959038..6cfa8bca0f5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index c13f3b8009e..76fc87ee15d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -99,6 +99,8 @@ type StoreState = { terminalWindowsShell?: string terminalWindowsWslDistro?: string | null localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } + terminalMainSideEffectAuthority?: boolean + terminalHiddenDeliveryGate?: boolean notifications?: { enabled?: boolean agentTaskComplete?: boolean @@ -678,7 +680,14 @@ describe('connectPanePty', () => { projects: [], sshConnectionStates: new Map(), cacheTimerByKey: {}, - settings: { promptCacheTimerEnabled: true, experimentalTerminalAttention: true }, + // Why: terminalMainSideEffectAuthority false pins the legacy renderer + // byte-parser wiring this suite asserts on (onTitleChange/onBell on the + // transport). The authority-on fact-consumer mode has its own tests. + settings: { + promptCacheTimerEnabled: true, + experimentalTerminalAttention: true, + terminalMainSideEffectAuthority: false + }, codexRestartNoticeByPtyId: {}, deferredSshReconnectTargets: [], deferredSshSessionIdsByTabId: {}, @@ -740,6 +749,8 @@ describe('connectPanePty', () => { hasChildProcesses: vi.fn().mockResolvedValue(false), write: vi.fn(), writeAccepted: vi.fn().mockResolvedValue(true), + setHiddenRendererPty: vi.fn(), + setPtyDeliveryInterest: vi.fn(), ackColdRestore: vi.fn(), onClearBufferRequest: vi.fn(() => vi.fn()), onSerializeBufferRequest: vi.fn(() => vi.fn()), @@ -5563,18 +5574,81 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - it('keeps non-visible local PTY bytes on the live xterm path for release', async () => { - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout + // Why: Phase 6 deleted the hidden-skip eligibility grammar. With the kill + // switch off, EVERY hidden chunk — plain, control-heavy, rich glyphs, + // synchronized frames, embedded queries — rides the bounded background + // scheduler queue and parses in xterm; nothing is content-scanned per chunk. + it('queues hidden PTY bytes on the background scheduler without per-chunk scanning', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() try { + const hiddenChunks = [ + 'plain hidden text\r\n', + '\x1b[2J\x1b[Hcontrol redraw\r\n', + '\x1b[2J\x1b[H╭ table 😀 ╮\r\n', + '\x1b[?2026h| Sam Syntax | 😀 |\r\n\x1b[?2026l', + '\x1b[?2026h\x1b[6n' + ] + for (const chunk of hiddenChunks) { + capturedDataCallback.current?.(chunk) + } + + // Background path defers writes; nothing is written synchronously. + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + // The drain may coalesce queued chunks into one write — assert content. + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + for (const chunk of hiddenChunks) { + expect(written).toContain(chunk) + } + // No model restore is latched for bounded hidden output. + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + describe('hidden-delivery gate', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + } + + function getSetHiddenRendererPtyMock(): ReturnType { + return window.api.pty.setHiddenRendererPty as unknown as ReturnType + } + + async function connectHiddenPane(deps: ReturnType): Promise<{ + transport: MockTransport + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + binding: { syncProcessTracking: () => void; dispose: () => void } + }> { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } transport.connect.mockImplementation( async ({ callbacks }: { callbacks: ConnectCallbacks }) => { capturedDataCallback.current = callbacks.onData ?? null @@ -5582,101 +5656,553 @@ describe('connectPanePty', () => { } ) transportFactoryQueue.push(transport) - const pane = createPane(1) const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + return { transport, pane, dataCallback: capturedDataCallback.current!, binding } + } + + it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'model snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 }) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) - expect(capturedDataCallback.current).not.toBeNull() - capturedDataCallback.current?.('hello\r\n') - expect(pane.terminal.write).not.toHaveBeenCalledWith('hello\r\n') + // Why: with the skip grammar gone, gated drops latch the restore via + // main's out-of-band marker, not a renderer-side content scan. + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) - for (const fn of pendingTimeouts) { - fn() + // Reveal rides the visible-resume backlog recovery hook. + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + // The unhide IPC must precede the snapshot request (seq-guard contract). + const unhideOrder = setHiddenRendererPty.mock.invocationCallOrder.at(-1)! + const snapshotOrder = getMainBufferSnapshot.mock.invocationCallOrder[0]! + expect(unhideOrder).toBeLessThan(snapshotOrder) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('model snapshot'), + expect.any(Function) + ) + }) + + it('clears the hidden bit on visibility flips through syncProcessTracking', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + + // Hiding again re-marks through the same lifecycle hook. + ;(deps.isVisibleRef as { current: boolean }).current = false + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + }) + + it('marks hidden codex panes immediately — no startup renderer-query window remains', async () => { + enableMainAuthority() + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: Phase 6 deleted the 10s codex window — codex startups gate like + // any hidden pane and the main responder answers their startup probes. + dataCallback('startup probe output\r\n') + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // The fact stays the sole 2031 responder for gate-managed PTYs. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 8, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + }) + + it('latches model restore from the out-of-band marker and restores on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'dropped bytes snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + // Why: the marker subscription is keyed by the live PTY id — the byte + // path latches it on the first hidden chunk, like the hidden mark. + dataCallback('pre-drop output\r\n', { seq: 16, rawLength: 17 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + // Main dropped gated bytes and signalled it out-of-band. + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('dropped bytes snapshot'), + expect.any(Function) + ) + }) + + it('answers each 2031-subscribe fact exactly once, before any hidden mark exists', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Simulate the transport's spawn completion so the pane registers its + // side-effect fact consumer (the mock transport never calls onPtySpawn). + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: no pty:data has flowed, so no hidden mark was sent — the fact + // can outrun the mark (codex post-startup-window race) and must still + // reply: ownership is structural, never mark-dependent. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: a visible gated pane still answers via the fact — the lifecycle + // suppresses the xterm CSI reply for gate-managed panes, so this stays + // the only reply for the new subscribe. + ;(deps.isVisibleRef as { current: boolean }).current = true + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 24, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(2) + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[?997;1n') + }) + + it('registers the fact-answered 2031 subscription for later theme flips', async () => { + enableMainAuthority() + const recordPaneMode2031Subscription = vi.fn() + const deps = createDeps({ + isVisibleRef: { current: false }, + recordPaneMode2031Subscription + }) + const { transport } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + // Why: without the registry write, applyTerminalAppearance's + // maybePushMode2031Flip never pushes CSI 997 after a theme change and + // the revealed TUI keeps a stale theme. + expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark') + }) + + it('reports the gate-managed predicate on the binding for the xterm 2031 observer', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { binding } = await connectHiddenPane(deps) + const bindingWithPredicate = binding as typeof binding & { + isHiddenDeliveryGateManagedPty: () => boolean + } + expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true) + }) + + it('declares hidden-at-spawn on connect for hidden panes', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Why: waiting for the first dataCallback sync left a spawn-time query + // window where neither side replied (the spawn-time DA1 loss). The flag + // lets main mark the PTY hidden before its first byte. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('keeps visible spawns undeclared (visible spawn unchanged)', async () => { + enableMainAuthority() + const deps = createDeps() + const { transport } = await connectHiddenPane(deps) + expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') + }) + + it('declares hidden-at-spawn for hidden codex panes too', async () => { + enableMainAuthority() + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport } = await connectHiddenPane(deps) + // Why: the 10s codex startup window is deleted — codex spawns are + // main-owned from byte zero, with the model responder answering their + // startup probes (including ConPTY's blocking DA1; the main-side pin is + // pty.test.ts 'answers DA1 from the model on the first chunk of a + // hidden-at-spawn PTY'). + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as StoreState['settings'] + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport, dataCallback, binding } = await connectHiddenPane(deps) + // Why: the lifecycle's xterm CSI observer consults this predicate — + // kill switch off must keep the legacy xterm reply path. + expect( + ( + binding as typeof binding & { isHiddenDeliveryGateManagedPty: () => boolean } + ).isHiddenDeliveryGateManagedPty() + ).toBe(false) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).not.toHaveBeenCalled() + + // Why: gate off keeps the byte-scan responder authoritative — the fact + // must not produce a second reply for the same subscribe. + const factsHandler = await import('./terminal-side-effect-facts-handler') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it('clears a marked-hidden PTY on dispose so a remount is never gated', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + + binding.dispose() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + }) + + it('never treats a live chunk that strips to empty as a restore marker', async () => { + // Why: a chunk that is purely OSC 9999 reaches the data callback as '' + // (transport stripping) — only the out-of-band pty:modelRestoreNeeded + // channel may trigger a snapshot restore, or visible panes would be + // spuriously cleared and repainted mid-session. + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + dataCallback('', { seq: 32, rawLength: 24 }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + }) + + it('fetches a fresh snapshot when a marker lands while a restore is in flight', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockResolvedValue({ data: 'fresh snapshot\r\n', cols: 100, rows: 30, seq: 96 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'pending-cap', markerSeq: 64 }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Second drop while the first snapshot is still being serialized — the + // in-flight snapshot may predate it, so a fresh one must follow. + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'pending-cap', markerSeq: 80 }) + firstSnapshot.resolve({ data: 'stale snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + }) + + describe('post-restore backlog reconciliation', () => { + async function restoreVisiblePaneToBaseline(): Promise<{ + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + getMainBufferSnapshot: ReturnType + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'restored snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('restored snapshot'), + expect.any(Function) + ) + pane.terminal.write.mockClear() + return { pane, dataCallback, getMainBufferSnapshot } } - expect(pane.terminal.write).toHaveBeenCalledWith('hello\r\n') - } finally { - globalThis.setTimeout = originalSetTimeout - } - }) + function writtenData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + } - it('keeps visually rich hidden PTY bytes on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' + it('drops backlog chunks the restored snapshot already covers', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() + + // Whole chunk at or before the baseline seq: duplicate, never written. + dataCallback('OLD-DUPLICATE', { seq: 60, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('OLD-DUPLICATE') + + // Contiguous post-baseline chunk flows through normally. + dataCallback('NEW', { seq: 67, rawLength: 3 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('NEW') + }) + + it('slices a partial overlap when raw and clean lengths match', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() + + // start seq 61 < baseline 64 < end seq 67 — only the last 3 chars are new. + dataCallback('ABCDEF', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(8) + + const written = writtenData(pane) + expect(written).toContain('DEF') + expect(written).not.toContain('ABC') + }) + + it('forces a fresh snapshot for an overlap whose offsets cannot be mapped', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'second snapshot\r\n', + cols: 100, + rows: 30, + seq: 80 + }) + + // rawLength (6) !== data.length (4): renderer-side OSC stripping makes + // the slice offset unmappable — restore from a fresh snapshot instead. + dataCallback('ABCD', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('ABCD') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('second snapshot') + }) + + it('detects a seq gap after restore and forces another restore', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'gap-heal snapshot\r\n', + cols: 100, + rows: 30, + seq: 120 + }) + + // Why: a chunk starting past the continuity point (start seq 87 > + // expected 64) means main trimmed bytes after the one-shot overflow + // marker was consumed — only the model snapshot can heal the gap. + dataCallback('AFTER-GAP', { seq: 96, rawLength: 9 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('AFTER-GAP') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('gap-heal snapshot') + }) + + it('writes genuinely-new live output whose seq sits below an empty-backlog baseline', async () => { + // E2E twin (terminal-hidden-tui-visual-restore "keeps newer live + // output correct"): main's snapshot seq is a cumulative PTY counter + // (shell init + prompt echo + hidden frame), while a synthetic live + // chunk meters only its own frames — far below the baseline. With an + // empty pending queue main can never re-deliver seqs at or below the + // snapshot, so the chunk must write, never silently drop. + enableMainAuthority() + const isVisibleRef = { current: true } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + // Visible prompt echo metered in main's cumulative seq domain. + dataCallback('$ node frame-script.mjs\r\n', { seq: 2_315, rawLength: 25 }) + // Pane hides mid-stream; main drops the hidden frame and marks restore. + isVisibleRef.current = false + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 2_472 + }) + // Reveal: the snapshot covers everything ingested; pending queue empty + // (pendingDeliveryStartSeq === seq). + getMainBufferSnapshot.mockResolvedValue({ + data: 'LOW_RISK_RESTORE_FRAME_40\r\n', + cols: 100, + rows: 30, + seq: 2_472, + pendingDeliveryStartSeq: 2_472 + }) + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_40') + pane.terminal.write.mockClear() + + // Newer live frame injected with a seq domain unrelated to main's + // counter (e2e __terminalPtyDataInjection twin). + dataCallback('LOW_RISK_RESTORE_FRAME_41\r\n', { seq: 315, rawLength: 27 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_41') + + // The retired baseline keeps subsequent low-seq live chunks flowing. + dataCallback('progress=041\r\n', { seq: 329, rawLength: 14 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('progress=041') + }) + + it('keeps suppressing backlog duplicates inside the reported pending window', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'windowed snapshot\r\n', + cols: 100, + rows: 30, + seq: 96, + pendingDeliveryStartSeq: 80 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 96 + }) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('windowed snapshot') + pane.terminal.write.mockClear() + + // Inside the pending window (80, 96]: a draining backlog duplicate. + dataCallback('IN-WINDOW-DUP-16', { seq: 96, rawLength: 16 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('IN-WINDOW-DUP-16') + + // Past the baseline: genuinely-new live output still flows. + dataCallback('PAST-BASELINE', { seq: 109, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('PAST-BASELINE') + + // Below the pending window (≤ 80): main can never re-send these seqs, + // so this is a foreign seq domain — written, never silently dropped. + dataCallback('BELOW-WINDOW', { seq: 60, rawLength: 12 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('BELOW-WINDOW') + }) }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const hiddenTuiChunk = '\x1b[2J\x1b[H╭ table 😀 ╮\r\n' - capturedDataCallback.current?.(hiddenTuiChunk) - - expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenTuiChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(hiddenTuiChunk) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('keeps split hidden synchronized output frames on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const startChunk = '\x1b[?2026h' - const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' - const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' - - capturedDataCallback.current?.(startChunk) - capturedDataCallback.current?.(plainRowChunk) - capturedDataCallback.current?.(endChunk) - - expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(`${startChunk}${plainRowChunk}${endChunk}`) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } }) it('schedules WebGL atlas recovery after hidden synchronized output parses', async () => { @@ -6233,7 +6759,7 @@ describe('connectPanePty', () => { binding.dispose() }) - it('side-channel answers mode 2031 when hidden Codex output is snapshot-backed', async () => { + it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -6249,17 +6775,10 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) - const paneMode2031Ref = { current: new Map() } - const paneLastThemeModeRef = { current: new Map() } const binding = connectPanePty( pane as never, manager as never, - createDeps({ - isVisibleRef: { current: false }, - paneMode2031Ref, - paneLastThemeModeRef, - startup: { command: 'codex' } - }) as never + createDeps({ isVisibleRef: { current: false } }) as never ) await flushAsyncTicks(6) @@ -6268,10 +6787,8 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[?2031h') vi.advanceTimersByTime(50) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n') - expect(paneMode2031Ref.current.get(1)).toBe(true) - expect(paneLastThemeModeRef.current.get(1)).toBe('light') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?2031h') + expect(transport.sendInput).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h') } finally { vi.useRealTimers() } @@ -7097,17 +7614,21 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('writes ordinary hidden remote runtime output live instead of restoring a snapshot', async () => { + it('restores overflowed hidden remote runtime output from its serialized snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null } = { current: null } + // Why: with the skip grammar gone, the model restore for remote-runtime + // PTYs is latched by background-queue overflow, not per-chunk scanning. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible remote output\r\n' transport.serializeBuffer = vi.fn().mockResolvedValue({ - data: 'remote snapshot\r\n', + data: 'remote snapshot with hidden remote output\r\n', cols: 120, rows: 40, - seq: 40, + seq: hidden.length + live.length, source: 'headless' }) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { @@ -7125,26 +7646,27 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' - const live = 'visible remote output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) ;(deps.isVisibleRef as { current: boolean }).current = true capturedDataCallback.current?.(live, { - seq: 40 + live.length, + seq: hidden.length + live.length, rawLength: live.length }) await flushAsyncTicks(20) expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote snapshot with hidden remote output'), + expect.any(Function) + ) disposable.dispose() }) - it('keeps inactive split-pane hidden output live instead of deferring snapshot restore', async () => { + it('defers inactive split-pane plain hidden output restore until the pane returns', async () => { const { resetHiddenOutputRestoreSchedulerForTests } = await import('./hidden-output-restore-scheduler') let disposable: { dispose: () => void } | null = null @@ -7178,7 +7700,9 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflowing the background queue is what latches the model + // restore now — the per-chunk skip grammar is gone. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -7196,9 +7720,12 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 30)) await flushAsyncTicks(20) - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('inactive snapshot'), + expect.any(Function) + ) } finally { disposable?.dispose() resetHiddenOutputRestoreSchedulerForTests() @@ -7239,7 +7766,8 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -7263,7 +7791,7 @@ describe('connectPanePty', () => { } }) - it('does not retry remote snapshots for ordinary hidden runtime output', async () => { + it('retries null remote snapshots for overflowed hidden runtime output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { @@ -7288,7 +7816,8 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const firstLive = 'first visible output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -7299,7 +7828,7 @@ describe('connectPanePty', () => { }) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() + expect(transport.serializeBuffer).toHaveBeenCalledTimes(1) expect(pane.terminal.write).not.toHaveBeenCalledWith( expect.stringContaining('Orca skipped hidden terminal output'), expect.any(Function) @@ -7312,11 +7841,17 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 80)) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(firstLive, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote recovered snapshot'), + expect.any(Function) + ) disposable.dispose() }) + // Why: pins the entire switch-off hidden fallback chain — hidden bytes ride + // the background queue, the 2MB lossy cap drops the backlog and latches the + // restore, and reveal repaints from the model snapshot. it('restores hidden backlog overflow from the main terminal snapshot on foreground output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -8519,6 +9054,73 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('replays rich headless snapshots as the future hidden TUI view source', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const richSnapshot = [ + '\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' + ].join('') + const visibleTrigger = 'visible-trigger\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: richSnapshot, + cols: 96, + rows: 18, + seq: hidden.length + visibleTrigger.length, + source: 'headless' + }) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleTrigger, { + seq: hidden.length + visibleTrigger.length, + rawLength: visibleTrigger.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.resize).toHaveBeenCalledWith(96, 18) + expect(pane.terminal.write).toHaveBeenCalledWith(richSnapshot, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(visibleTrigger, expect.any(Function)) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(deps.replayingPanesRef.current.size).toBe(0) + disposable.dispose() + }) + it('refreshes visible rows after replaying a hidden TUI snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -10035,6 +10637,452 @@ describe('connectPanePty', () => { expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() }) + // ─── Main side-effect authority (terminal-side-effect-authority.md) ──── + // + // With the kill switch on (the default), local/SSH transports must not + // register title/bell/agent byte parsers; the pane's policy callbacks are + // registered as the PTY's single pty:sideEffect fact consumer instead. + describe('with main side-effect authority on', () => { + const SIDE_EFFECT_PARSER_CALLBACKS = [ + 'onTitleChange', + 'onBell', + 'onAgentBecameIdle', + 'onAgentBecameWorking', + 'onAgentExited' + ] as const + + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + it('omits byte-parser callbacks from the local transport options', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createdTransportOptions[0]).toBeDefined() + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeUndefined() + } + // The lifecycle callbacks stay on the transport — only side-effect + // parsing moves to the fact consumer. + expect(createdTransportOptions[0]?.onPtySpawn).toBeTypeOf('function') + expect(createdTransportOptions[0]?.onPtyExit).toBeTypeOf('function') + }) + + it('keeps byte-parser callbacks on remote-runtime transports', async () => { + enableMainAuthority() + enableActiveRuntimeEnvironment() + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + }) + + it('consumes pty:sideEffect facts with the live-path policy after spawn', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + connectPanePty(pane as never, manager as never, deps as never) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-1') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-1', + seq: 10, + facts: [ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'bell' } + ] + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex working') + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + expect(deps.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(deps.dispatchNotification).not.toHaveBeenCalled() + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'terminal-bell', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + ) + }) + + it('stops consuming facts after the pane binding is disposed', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-2') + + binding.dispose() + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-2', + seq: 1, + facts: [{ kind: 'bell' }] + }) + + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalTabUnread).not.toHaveBeenCalled() + }) + + it('schedules the completion notification for genuine working→idle facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-genuine') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 2, + facts: [ + { kind: 'title', normalizedTitle: '* Codex done', rawTitle: '* Codex done' }, + { kind: 'agent-idle', title: '* Codex done' } + ] + }) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('clears state without completion attention for stale-derived facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-stale') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + // Main's unthrottled 3s stale-title rewrite for a merely-paused agent. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 2, + facts: [ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ] + }) + + // The cleared title still lands; the cache timer is cleared. + expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'Codex') + expect(deps.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + makePaneKey('tab-1', LEAF_1), + null + ) + // But no task-complete notification or unread attention is scheduled. + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS * 2) + expect(deps.dispatchNotification).not.toHaveBeenCalled() + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + + it('drops the agent status from a command-finished fact like the byte path did', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-133') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-133', + seq: 1, + facts: [{ kind: 'command-finished', exitCode: 130 }] + }) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() + }) + + it('routes pr-link facts to the worktree PR observer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-pr') + + const link = { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-pr', + seq: 1, + facts: [{ kind: 'pr-link', link }] + }) + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith('wt-1', link) + }) + + it('does not byte-scan PR links or OSC 133 — facts are the only consumer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-authority-bytes' + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') + capturedDataCallback.current?.('\x1b]133;D;130\x07prompt $ ') + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('seeds and settles Command Code status from command-code facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'say hi' }] + }) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'say hi', + agentType: 'command-code' + }) + + // Why: the done fact is a hint — the settle timer stays in the pane + // policy because it must consult the live status row before completing. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'say hi' }] + }) + vi.advanceTimersByTime(1499) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'working' }) + vi.advanceTimersByTime(1) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + prompt: 'say hi', + agentType: 'command-code' + }) + }) + + it('keeps Command Code working when a working fact lands before the done settles', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc-repaint') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(1000) + // An active repaint within the settle window cancels the pending done. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 3, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'Run a slow command', + agentType: 'command-code' + }) + }) + + it('does not byte-scan Command Code output — facts are the only consumer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-authority-cc-bytes' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup: { command: 'command-code --trust' } }) as never + ) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('# Command Code v0.27.2\r\n') + capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + }) + + it('honors the persisted kill switch for panes bound before settings hydrate', async () => { + // Pre-hydration: the store has no settings yet, but the user persisted + // the kill switch off. The pane must register byte parsers, not a fact + // consumer — and hydration must not produce a second consumer. + mockStoreState.settings = null + ;(window.api as unknown as Record).settings = { + getSync: vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + } + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-prehydration') + + // No fact consumer registered: channel batches are dropped. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-prehydration', + seq: 1, + facts: [{ kind: 'bell' }] + }) + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + + // Hydration lands with the switch still off: byte parsing stays the + // single consumer — one BEL marks unread exactly once. + mockStoreState.settings = { terminalMainSideEffectAuthority: false } + notifyStoreSubscribers() + const onBell = createdTransportOptions[0]?.onBell as () => void + onBell() + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + }) + }) + it('lets concurrent agent-complete notifications win over terminal bell notifications', async () => { const { connectPanePty } = await import('./pty-connection') const { useNotificationDispatch } = await vi.importActual( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 9b81928d483..661362275b0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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>() 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() +// 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, 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 -): boolean { - const notifications = state.settings?.notifications - return notifications?.enabled !== false && notifications?.agentTaskComplete !== false -} - -function isTerminalAttentionEnabledFromState( - state: ReturnType -): 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 -): 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 ): 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() diff --git a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts index af06e1b4a26..3625a8e6007 100644 --- a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts +++ b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts new file mode 100644 index 00000000000..a695b77d82f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts @@ -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() + +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() +} diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts new file mode 100644 index 00000000000..e7de6c7b84f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts @@ -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 + 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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 859ac32cd7b..c816a2133c4 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts new file mode 100644 index 00000000000..289192d13d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts @@ -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 + 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts new file mode 100644 index 00000000000..5f3f95cc1a3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts @@ -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 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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index f1e3b1b67b8..c39f46edf62 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -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 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 preserve?: () => void detach?: () => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index bb4afcc5534..c29517e59ed 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -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 { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 474ee267832..c99e7a51c67 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -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 | null = null let sideEffectDrainTimer: ReturnType | 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() diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 863d4cc7725..b7e088ff4aa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -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): 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)') diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 86e2999dae3..c7c2f852a00 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts index 3fb103efeac..69b6d923423 100644 --- a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts @@ -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() } diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts new file mode 100644 index 00000000000..8123b83a9e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts @@ -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 | 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 +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts new file mode 100644 index 00000000000..40ecab2f5a5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts new file mode 100644 index 00000000000..1421e8ac53d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -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 + +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> + // 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> + 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 { + const coldParkedIds = new Set() + 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> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + 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> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + 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 + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts new file mode 100644 index 00000000000..21cac2cd0c3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -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 +} + +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 +} + +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 + } + > + runtimePaneTitlesByTabId: Record> + clearRuntimePaneTitle: ReturnType +} + +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 +}): 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 } + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts new file mode 100644 index 00000000000..4f9bf0f77cc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -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 + +type ParkedPaneFallbackState = { + terminalLayoutsByTabId: ReturnType['terminalLayoutsByTabId'] + runtimePaneTitlesByTabId: ReturnType['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 void>() + const paneIdByPtyId = new Map() + 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 +}): 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) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts new file mode 100644 index 00000000000..d6dd76e817a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -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() + +// 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 + disposersByPtyId: Map void> +} + +export const parkedWatchersByTabId = new Map() + +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): 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) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts new file mode 100644 index 00000000000..40e226cf5df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts new file mode 100644 index 00000000000..1717b170bb9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts new file mode 100644 index 00000000000..84a91c2e7c4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -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 { + 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((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']]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts new file mode 100644 index 00000000000..6fffdbea1a4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -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 | 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() +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 +} diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts new file mode 100644 index 00000000000..ae5fa99b63f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -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([]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts new file mode 100644 index 00000000000..ea7613ea354 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts @@ -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 { + 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']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts new file mode 100644 index 00000000000..05970b77725 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts @@ -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 +): 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, + 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 +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index c5d995abd03..e8470d398f9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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 ( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts new file mode 100644 index 00000000000..190a5fd5a27 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -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, right: ReadonlySet): 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 + 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 { + 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()) + const terminalTabParkingTimersRef = useRef(new Map()) + const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0) + const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( + () => 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() + 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 +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 31c51256a88..cbf24b7c1a6 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -67,6 +67,10 @@ declare global { interface Window { __paneManagers?: Map __onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps + __terminalParkingDebug?: { + parkDelayMs: number + parkedTabIds: () => string[] + } } } diff --git a/src/renderer/src/lib/automation-session-observer.test.ts b/src/renderer/src/lib/automation-session-observer.test.ts new file mode 100644 index 00000000000..6c8efbf81cf --- /dev/null +++ b/src/renderer/src/lib/automation-session-observer.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index c80ae9156e8..9976468ed69 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -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) } } diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index 0f1fc4f25a6..3ef7119d32f 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -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. diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index f42f886fde5..624b7491ff5 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -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({ diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 389c6972cf1..4840a3f335c 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -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) } } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index aa7d2433ab9..e22c0c737fe 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -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() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 9e1ecceb9ed..b7d53312f8e 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index 8e32f7d7246..bd8573af5ad 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -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 { @@ -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({ diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 0f35968c21d..549defbb3a5 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -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 diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index bd2f13fc6d2..f447d4e2637 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -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[] 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() +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() 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 { + 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[]): Uint8Array { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 0d0a59212f0..85b21b22821 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -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) => 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, 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() + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 0e6f30ddefc..0d334a09732 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -49,6 +49,10 @@ import { restorePtyDataHandlersAfterFailedShutdown, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' +// Why: import the store-free registry, not terminal-parked-tab-watchers — +// that module imports @/store, and a slice importing it would re-enter store +// creation before this slice finishes evaluating. +import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry' import { normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId @@ -2036,6 +2040,11 @@ export const createTerminalSlice: StateCreator // Removing the data handlers first ensures the final flush is a no-op. if (expectedRuntimePtyIds.length === 0) { unregisterPtyDataHandlers(shutdownPtyIds) + // Why: parked-tab byte watchers observe the same flush through dispatcher + // sidecars, which the call above does not touch — dispose them now or a + // just-slept/deleted worktree still gets unread marks and delayed + // bell/completion OS notifications from its teardown bytes. + disposeParkedTerminalWatchersForPtyIds(shutdownPtyIds) } // Why (ordering invariant — DESIGN_DOC §3.3.c): on sleep, capture every diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index fbeee1fb7da..72da032aa0c 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -20,7 +20,7 @@ import type { WorktreeMeta, WorkspaceKey } from '../../../../shared/types' -import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector' +import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { PendingWorktreeCreation, WorktreeCreationPhase diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 1da70b789e5..899f4afefb6 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -498,6 +498,9 @@ function createWebPreloadApi(): Partial { }, settings: { get: async () => getRuntimeBackedStoredSettings(), + // Why: localStorage-backed settings are synchronous in the web client, + // so the pre-hydration kill-switch read works the same as desktop. + getSync: () => getStoredSettings(), set: async (updates) => { if (updates.activeRuntimeEnvironmentId === null) { disconnectActiveRuntimeEnvironment() @@ -2543,6 +2546,11 @@ function createPtyApi(): NonNullable['pty']> { ackData: () => {}, setActiveRendererPty: () => {}, setRendererPtyVisible: () => {}, + setHiddenRendererPty: () => {}, + setPtyDeliveryInterest: () => {}, + // Why no-op: remote-runtime PTYs are never hidden-gate markable, so the + // web client has no main-side responder to feed. + publishTerminalViewAttributes: () => {}, hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), @@ -2550,6 +2558,10 @@ function createPtyApi(): NonNullable['pty']> { listSessions: () => Promise.resolve([]), hasPty: () => Promise.resolve(null), getMainBufferSnapshot: () => Promise.resolve(null), + // Why: remote-runtime PTYs never transit local main, so the web client has + // no side-effect facts source; renderer byte parsing stays authoritative. + onSideEffect: () => noopUnsubscribe, + getSideEffectSnapshot: () => Promise.resolve(null), getRendererDeliveryDebugSnapshot: () => Promise.resolve({ pendingPtyCount: 0, @@ -2564,11 +2576,17 @@ function createPtyApi(): NonNullable['pty']> { peakMaxPendingCharsByPty: 0, peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0 + ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0 }), resetRendererDeliveryDebug: () => Promise.resolve(), onData: () => noopUnsubscribe, onReplay: () => noopUnsubscribe, + onModelRestoreNeeded: () => noopUnsubscribe, onExit: () => noopUnsubscribe, onSerializeBufferRequest: () => noopUnsubscribe, onClearBufferRequest: () => noopUnsubscribe, diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 196d6eadb39..7ff722ca9d6 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -1,23 +1,27 @@ /** - * Shared agent detection utilities — used by both the main process (stats - * collection) and the renderer (activity indicators, unread badges). + * Compatibility barrel for shared terminal agent-title detection. * - * Why shared: the main process needs the same OSC title extraction and agent - * status detection for stat tracking that the renderer uses for UI indicators. - * Duplicating this logic would risk drift between the two detection paths. + * Why shared: main and renderer both consume OSC titles for facts, stats, and + * UI state. Keep existing imports stable while the implementation stays split + * into focused modules that satisfy max-lines. */ -import { - AGY_AGENT_NAME_RE, - DROID_AGENT_NAME_RE, - HERMES_AGENT_NAME_RE, - titleHasAgentName, - titleHasAnyLegacyAgentName -} from './agent-name-token-match' -import { - getPiCompatibleSyntheticAgentLabel, - getPiCompatibleSyntheticAgentStatus -} from './pi-compatible-synthetic-title' +export type { AgentStatus } from './agent-title-core' +export { + isClaudeManagementTitle, + isCursorNativeAgentTitle, + isGeminiTerminalTitle, + isPiTerminalTitle, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE +} from './agent-title-core' +export { getAgentLabel, isClaudeAgent } from './agent-title-identity' +export { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + normalizeTerminalTitle +} from './agent-title-status' // Re-export so existing `agent-detection` importers keep working. export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' @@ -27,470 +31,3 @@ export { MAX_OSC_TITLE_CHARS } from './osc-title-extraction' export { isShellProcess } from './shell-process-detection' - -export type AgentStatus = 'working' | 'permission' | 'idle' - -const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) -const CLAUDE_MANAGEMENT_TITLE_RE = - /^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i - -const GEMINI_WORKING = '\u2726' // ✦ -const GEMINI_SILENT_WORKING = '\u23F2' // ⏲ -const GEMINI_IDLE = '\u25C7' // ◇ -const GEMINI_PERMISSION = '\u270B' // ✋ - -// Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles -// like "Codex done", "OpenCode ready", "Aider idle" to AgentStatus 'idle'. -// `as const` so consumers receive literal-union types. -const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const - -// Why: working keywords used inside `detectAgentStatusFromTitle` to map -// titles like "Codex working", "Aider thinking", "OpenCode running" to -// AgentStatus 'working'. Shared with `clearWorkingIndicators` so both stay -// in lock-step when stripping working indicators from stale titles. -const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const - -// Why: match STRONG_IDLE_KEYWORDS only when not adjacent to characters that -// would make the "keyword" part of a larger token. Plain `\b` alone is -// insufficient because `-` is a non-word character in JS regex, so `\bready\b` -// still matches inside "is-ready-cap" (a `\b` boundary falls between `-` and -// `r`). -// -// Lookarounds are intentionally ASYMMETRIC: -// - LEFT: reject `[\w./\\-]` so path fragments like `~/codex/ready`, -// Windows `C:\codex\ready`, and `codex.ready` cannot mint a strong idle -// signal by having the agent name sit earlier in the same path and the -// keyword land right after a path separator. Orca is a cross-platform -// Electron app, so Windows path separators must be handled too. -// - RIGHT: reject only `[\w\-]` so legitimate sentence-style titles like -// "Codex done." / "Aider idle." / "OpenCode ready!" still match — path -// separators after the keyword are not a false-positive vector in -// practice and blocking them would regress trailing-punctuation titles. -// -// Also rejects hyphenated compounds ("is-ready-cap", "re-done") and plain -// substring false positives ("already"/"redone"/"idleness"). -export const STRONG_IDLE_KEYWORDS_RE = new RegExp( - `(?= 0x2800 && codePoint <= 0x28ff) { - return true - } - } - return false -} - -function containsAgentName(title: string): boolean { - return ( - titleHasAnyLegacyAgentName(title) || - AGY_AGENT_NAME_RE.test(title) || - DROID_AGENT_NAME_RE.test(title) || - HERMES_AGENT_NAME_RE.test(title) - ) -} - -function containsAny(title: string, words: readonly string[]): boolean { - const lower = title.toLowerCase() - return words.some((word) => lower.includes(word)) -} - -/** - * Strip working-status indicators from a title so that - * `detectAgentStatusFromTitle` will no longer return 'working'. - * Used to clear stale titles when an agent exits without resetting its title. - */ -export function clearWorkingIndicators(title: string): string { - let cleaned = title - - // Gemini working symbols - cleaned = cleaned.replace(GEMINI_WORKING, '') - cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') - - // Braille spinner characters (U+2800–U+28FF) - // eslint-disable-next-line no-control-regex -- intentional unicode range - cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '') - - // Claude Code ". " working prefix - if (cleaned.startsWith('. ')) { - cleaned = cleaned.slice(2) - } - - // Strip working keywords that detectAgentStatusFromTitle would pick up - // when the title also contains an agent name. - if (containsAgentName(cleaned)) { - cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') - } - - // Collapse whitespace after removals - cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() - - return cleaned || title -} - -/** - * Tracks agent status transitions from terminal title changes. - * Fires `onBecameIdle` when an agent transitions from working to idle/permission, - * like haunt's attention flag — the key trigger for unread notifications. - */ -export function createAgentStatusTracker( - onBecameIdle: (title: string) => void, - onBecameWorking?: () => void, - onAgentExited?: () => void -): { - handleTitle: (title: string) => void - /** Clear accumulated status so a stale working→idle transition cannot fire - * after the owning transport is torn down. */ - reset: () => void -} { - let lastStatus: AgentStatus | null = null - - return { - handleTitle(title: string): void { - const newStatus = detectAgentStatusFromTitle(title) - if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { - onBecameIdle(title) - } - if (lastStatus !== 'working' && newStatus === 'working') { - onBecameWorking?.() - } - // Why: when the title reverts to a plain shell prompt (e.g., "bash", "zsh"), - // detectAgentStatusFromTitle returns null. If we were idle or in a permission - // prompt, this means the user exited the agent — clear session-tied state - // (like the prompt-cache countdown). We intentionally do NOT fire this when - // lastStatus is 'working', because active agents can briefly flash shell - // titles during internal operations without actually exiting. - if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { - lastStatus = null - onAgentExited?.() - } - if (newStatus !== null) { - lastStatus = newStatus - } - }, - reset(): void { - lastStatus = null - } - } -} - -/** - * Normalize high-churn agent titles into stable display labels before storing - * them in app state. Gemini CLI can emit per-keystroke title updates, which - * otherwise causes broad rerenders and visible flashing. - */ -export function normalizeTerminalTitle(title: string): string { - if (!title) { - return title - } - - if (isGeminiTerminalTitle(title)) { - const status = detectAgentStatusFromTitle(title) - if (status === 'permission') { - return `${GEMINI_PERMISSION} Gemini CLI` - } - if (status === 'working') { - return `${GEMINI_WORKING} Gemini CLI` - } - if (status === 'idle') { - return `${GEMINI_IDLE} Gemini CLI` - } - } - - // Why: Pi's titlebar extension animates every 80ms with different braille - // frames. Collapsing those frames into one stable label avoids renderer - // churn while preserving the working/idle transition Orca keys off. - if (isPiAgentTitle(title)) { - const status = detectAgentStatusFromTitle(title) - if (status === 'working') { - return '\u280b Pi' - } - if (status === 'idle') { - return 'Pi' - } - } - - return title -} - -/** - * Returns true when the terminal title matches Claude Code's title conventions. - * Used to scope prompt-cache-timer behavior to Claude sessions only — other - * agents have different (or no) caching semantics. - */ -export function isClaudeAgent(title: string): boolean { - if (!title || isClaudeManagementTitle(title)) { - return false - } - const lower = title.toLowerCase() - - // Why: Claude Code titles are prefixed with status indicators (✳, ". ", "* ", - // braille spinners) followed by the task description. The task text can - // legitimately mention other agents, so Claude-specific prefixes must win. - if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { - return true - } - // Why: ". " (working) and "* " (idle) are Claude Code title conventions. - // Other supported agents do not use them, and rejecting titles that mention - // another agent in the task text caused false negatives for real Claude tabs. - if (title.startsWith('. ') || title.startsWith('* ')) { - return true - } - if (containsBrailleSpinner(title)) { - // Why: named non-Claude agents can carry braille spinners too; Claude-only - // prompt-cache paths must not fire for those explicit agent titles. - return !lower.includes('cursor') && !lower.includes('openclaude') - } - // Why: permission/action-required Claude titles can omit the usual prefixes. - // Token-match so cwd/worktree titles like "claude-scratch" do not become - // Claude tabs, while task text that merely mentions Claude still stays out. - const trimmedTitle = title.trimStart() - if ( - trimmedTitle.toLowerCase().startsWith('claude') && - titleHasAgentName(trimmedTitle, 'claude') - ) { - return true - } - - return false -} - -export function isClaudeManagementTitle(title: string): boolean { - return CLAUDE_MANAGEMENT_TITLE_RE.test(title) -} - -export function getAgentLabel(title: string): string | null { - if (isClaudeManagementTitle(title)) { - return null - } - // Why: Claude Code title text is often the task title. If that task mentions - // another CLI, the Claude-specific prefix is the identity signal, not the words. - if ( - title.startsWith(`${CLAUDE_IDLE} `) || - title === CLAUDE_IDLE || - title.startsWith('. ') || - title.startsWith('* ') - ) { - return 'Claude Code' - } - if (isGeminiTerminalTitle(title)) { - return 'Gemini CLI' - } - // Why: Pi-compatible synthetic titles can carry braille spinners, which the - // generic agent-title heuristics would otherwise claim first. - const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) - if (piCompatibleSyntheticAgentLabel) { - return piCompatibleSyntheticAgentLabel - } - // Why: Pi working titles include a braille spinner prefix, which would be - // mistaken for Claude Code if we checked `isClaudeAgent` first. - if (isPiAgentTitle(title)) { - return 'Pi' - } - // Why: Codex/OpenCode/Aider can also use braille spinner prefixes while - // working. Prefer explicit name matches before Claude's generic spinner - // heuristic so mixed-agent hovercards stay truthful. Token-match (not - // substring) so cwd/worktree titles like "opencode-blinker" don't mint a - // false agent identity. - if (titleHasAgentName(title, 'codex')) { - return 'Codex' - } - if (titleHasAgentName(title, 'openclaude')) { - return 'OpenClaude' - } - if (titleHasAgentName(title, 'copilot')) { - return 'GitHub Copilot' - } - if (titleHasAgentName(title, 'grok')) { - return 'Grok' - } - if (titleHasAgentName(title, 'devin')) { - return 'Devin' - } - if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { - return 'Antigravity' - } - if (titleHasAgentName(title, 'opencode')) { - return 'OpenCode' - } - if (titleHasAgentName(title, 'mimo')) { - return 'MiMo Code' - } - if (titleHasAgentName(title, 'aider')) { - return 'Aider' - } - // Why: the cursor-agent native title is the literal string "Cursor Agent" - // (verified against the 2026.04.17 release) — Orca synthesizes the same - // label from hook events so the braille-spinner + agent-name path lights - // up working/permission/idle transitions in the renderer. Match before - // `isClaudeAgent` because Claude's generic braille heuristic would - // otherwise claim every "⠋ Cursor Agent" frame as Claude. Token-match so a - // cwd like "~/cursor-rules" can't masquerade as a Cursor agent. - if (titleHasAgentName(title, 'cursor')) { - return 'Cursor' - } - // Why: synthesized "⠋ Droid" working title needs to be matched before Claude's braille heuristic. - // Token matching avoids labeling ordinary Android terminal titles as Droid. - if (DROID_AGENT_NAME_RE.test(title)) { - return 'Droid' - } - // Why: synthesized "⠋ Hermes" working titles need to be matched before - // Claude's generic braille-spinner heuristic. - if (HERMES_AGENT_NAME_RE.test(title)) { - return 'Hermes' - } - if (isClaudeAgent(title)) { - return 'Claude Code' - } - - return null -} - -// Why: cursor-agent's native OSC title is the literal string "Cursor Agent" -// across the entire turn — it carries zero working/idle information. Orca -// synthesizes its own titles ("⠋ Cursor Agent" for working, "Cursor - -// action required" for permission) from cursor's hook events; the bare -// native title must be a no-op so cursor's per-turn re-emissions cannot -// stomp the synthesized state back to idle. -const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' - -export function detectAgentStatusFromTitle(title: string): AgentStatus | null { - if (!title) { - return null - } - if (isClaudeManagementTitle(title)) { - return null - } - // Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's - // native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor - - // action required") is either an Orca-synthesized working/permission title - // or a tighter match worth classifying. - if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { - return null - } - - // Gemini CLI symbols are the most specific and should take precedence. - if (title.includes(GEMINI_PERMISSION)) { - return 'permission' - } - if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { - return 'working' - } - if (title.includes(GEMINI_IDLE)) { - return 'idle' - } - - // Why: resolve synthetic Pi/OMP permission/idle labels before the broader - // Pi and braille-spinner checks below. - const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) - if (piCompatibleSyntheticAgentStatus) { - return piCompatibleSyntheticAgentStatus - } - - // Claude Code uses ✳ prefix for idle — must check before braille/agent-name - // because the title text is the task description, not "Claude Code". - if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { - return 'idle' - } - - if (isPiTerminalTitle(title)) { - return 'idle' - } - - if (containsBrailleSpinner(title)) { - return 'working' - } - - const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) - const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) - const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) - const hasLegacyAgentName = titleHasAnyLegacyAgentName(title) - if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) { - if (containsAny(title, ['action required', 'permission', 'waiting'])) { - return 'permission' - } - // Why: hyphen/word-char-aware boundary match (not plain substring, and - // stricter than `\b` — which treats `-` as a boundary) so titles like - // "~/codex already built" do not classify as idle via the substring - // "already" ⊃ "ready". See STRONG_IDLE_KEYWORDS_RE comment. - if (STRONG_IDLE_KEYWORDS_RE.test(title)) { - return 'idle' - } - // Why: hyphen/word-char-aware boundary match (not plain substring, and - // stricter than `\b`) so titles like "~/codex reworking diff" or - // "is-thinking-cap" do not classify as working via the substrings - // "reworking" ⊃ "working" or the `-`-adjacent "thinking" in - // "is-thinking-cap". Mirrors STRONG_IDLE_KEYWORDS_RE for symmetry; a - // false 'working' is worse than a false 'idle' because it drives - // active-agent UI (spinners, counts). - if (STRONG_WORKING_KEYWORDS_RE.test(title)) { - return 'working' - } - - // Claude Code title prefixes: ". " = working, "* " = idle - if (title.startsWith('. ')) { - return 'working' - } - if (title.startsWith('* ')) { - return 'idle' - } - - // Why: Factory Droid can publish native titles like "Factory Droid needs - // input" while an Execute tool is still sleeping. Droid's hook events are - // authoritative; don't turn a name-only native title into a completion. - if (hasDroidAgentName && !hasLegacyAgentName) { - return null - } - - return 'idle' - } - - return null -} diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts new file mode 100644 index 00000000000..295a3a89e8c --- /dev/null +++ b/src/shared/agent-title-core.ts @@ -0,0 +1,104 @@ +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAgentName, + titleHasAnyLegacyAgentName +} from './agent-name-token-match' + +export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName } + +export type AgentStatus = 'working' | 'permission' | 'idle' + +export const CLAUDE_IDLE = '\u2733' // ✳ +const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?` +export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp( + String.raw`^\s*(?:"${CLAUDE_COMMAND_RE}"|'${CLAUDE_COMMAND_RE}'|${CLAUDE_COMMAND_RE})\s+agents\s*$`, + 'i' +) + +export const GEMINI_WORKING = '\u2726' // ✦ +export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲ +export const GEMINI_IDLE = '\u25c7' // ◇ +export const GEMINI_PERMISSION = '\u270b' // ✋ + +const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const +const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const + +// Why: plain `\b` matches inside hyphenated tokens and cwd paths such as +// "~/codex/ready"; the left side also blocks path separators for Windows/Unix. +export const STRONG_IDLE_KEYWORDS_RE = new RegExp( + `(?= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +export function containsLegacyAgentName(title: string): boolean { + return titleHasAnyLegacyAgentName(title) +} + +export function containsAgentName(title: string): boolean { + return ( + containsLegacyAgentName(title) || + AGY_AGENT_NAME_RE.test(title) || + DROID_AGENT_NAME_RE.test(title) || + HERMES_AGENT_NAME_RE.test(title) + ) +} + +export function containsAny(title: string, words: readonly string[]): boolean { + const lower = title.toLowerCase() + return words.some((word) => lower.includes(word)) +} + +export function isClaudeManagementTitle(title: string): boolean { + return CLAUDE_MANAGEMENT_TITLE_RE.test(title) +} + +export function isCursorNativeAgentTitle(title: string): boolean { + return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER +} diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts new file mode 100644 index 00000000000..89dbded12f8 --- /dev/null +++ b/src/shared/agent-title-identity.ts @@ -0,0 +1,111 @@ +import { + AGY_AGENT_NAME_RE, + CLAUDE_IDLE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + containsBrailleSpinner, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiAgentTitle, + titleHasAgentName +} from './agent-title-core' +import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' + +/** + * Returns true when the terminal title matches Claude Code's title conventions. + * Used to scope prompt-cache-timer behavior to Claude sessions only. + */ +export function isClaudeAgent(title: string): boolean { + if (!title || isClaudeManagementTitle(title)) { + return false + } + const lower = title.toLowerCase() + + // Why: Claude title prefixes are stronger than task text, which can mention + // other agents without changing the owning CLI. + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return true + } + if (title.startsWith('. ') || title.startsWith('* ')) { + return true + } + if (containsBrailleSpinner(title)) { + return !lower.includes('cursor') && !lower.includes('openclaude') + } + + const trimmedTitle = title.trimStart() + return ( + trimmedTitle.toLowerCase().startsWith('claude') && titleHasAgentName(trimmedTitle, 'claude') + ) +} + +export function getAgentLabel(title: string): string | null { + if (isClaudeManagementTitle(title)) { + return null + } + // Why: Claude task titles can mention another CLI; the prefix is the identity + // signal, not arbitrary task text. + if ( + title.startsWith(`${CLAUDE_IDLE} `) || + title === CLAUDE_IDLE || + title.startsWith('. ') || + title.startsWith('* ') + ) { + return 'Claude Code' + } + if (isGeminiTerminalTitle(title)) { + return 'Gemini CLI' + } + // Why: Pi-compatible synthetic titles can carry braille spinners, which the + // generic agent-title heuristics would otherwise claim first. + const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) + if (piCompatibleSyntheticAgentLabel) { + return piCompatibleSyntheticAgentLabel + } + if (isPiAgentTitle(title)) { + return 'Pi' + } + + if (titleHasAgentName(title, 'codex')) { + return 'Codex' + } + if (titleHasAgentName(title, 'openclaude')) { + return 'OpenClaude' + } + if (titleHasAgentName(title, 'copilot')) { + return 'GitHub Copilot' + } + if (titleHasAgentName(title, 'grok')) { + return 'Grok' + } + if (titleHasAgentName(title, 'devin')) { + return 'Devin' + } + if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { + return 'Antigravity' + } + if (titleHasAgentName(title, 'opencode')) { + return 'OpenCode' + } + if (titleHasAgentName(title, 'mimo')) { + return 'MiMo Code' + } + if (titleHasAgentName(title, 'aider')) { + return 'Aider' + } + // Why: match explicit names before Claude's generic braille heuristic. + if (titleHasAgentName(title, 'cursor')) { + return 'Cursor' + } + if (DROID_AGENT_NAME_RE.test(title)) { + return 'Droid' + } + if (HERMES_AGENT_NAME_RE.test(title)) { + return 'Hermes' + } + if (isClaudeAgent(title)) { + return 'Claude Code' + } + + return null +} diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts new file mode 100644 index 00000000000..23a74051bd8 --- /dev/null +++ b/src/shared/agent-title-status.ts @@ -0,0 +1,194 @@ +import { + AGY_AGENT_NAME_RE, + BRAILLE_SPINNER_RE, + CLAUDE_IDLE, + CURSOR_NATIVE_TITLE_LOWER, + DROID_AGENT_NAME_RE, + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING, + HERMES_AGENT_NAME_RE, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE_GLOBAL, + containsAgentName, + containsAny, + containsBrailleSpinner, + containsLegacyAgentName, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiAgentTitle, + isPiTerminalTitle +} from './agent-title-core' +import type { AgentStatus } from './agent-title-core' +import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' + +/** + * Strip working-status indicators so stale exit titles stop reporting working. + */ +export function clearWorkingIndicators(title: string): string { + let cleaned = title + + cleaned = cleaned.replace(GEMINI_WORKING, '') + cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') + cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '') + if (cleaned.startsWith('. ')) { + cleaned = cleaned.slice(2) + } + if (containsAgentName(cleaned)) { + cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') + } + + cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() + return cleaned || title +} + +/** + * Tracks agent status transitions from terminal title changes. + */ +export function createAgentStatusTracker( + onBecameIdle: (title: string) => void, + onBecameWorking?: () => void, + onAgentExited?: () => void, + initialTitle?: string +): { + handleTitle: (title: string) => void + seedTitle: (title: string) => void + reset: () => void +} { + // Why: trackers restored mid-session need a last-known status without firing + // callbacks, or a hidden working agent can miss its later idle transition. + let lastStatus: AgentStatus | null = + initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : null + + return { + handleTitle(title: string): void { + const newStatus = detectAgentStatusFromTitle(title) + if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { + onBecameIdle(title) + } + if (lastStatus !== 'working' && newStatus === 'working') { + onBecameWorking?.() + } + // Why: reverting to a plain shell prompt after idle/permission means the + // agent exited; while working it can just be a transient internal title. + if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { + lastStatus = null + onAgentExited?.() + } + if (newStatus !== null) { + lastStatus = newStatus + } + }, + seedTitle(title: string): void { + lastStatus = detectAgentStatusFromTitle(title) + }, + reset(): void { + lastStatus = null + } + } +} + +/** + * Normalize high-churn agent titles into stable display labels before storage. + */ +export function normalizeTerminalTitle(title: string): string { + if (!title) { + return title + } + + if (isGeminiTerminalTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'permission') { + return `${GEMINI_PERMISSION} Gemini CLI` + } + if (status === 'working') { + return `${GEMINI_WORKING} Gemini CLI` + } + if (status === 'idle') { + return `${GEMINI_IDLE} Gemini CLI` + } + } + + // Why: Pi animates every 80ms; collapse frames while preserving status. + if (isPiAgentTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'working') { + return '\u280b Pi' + } + if (status === 'idle') { + return 'Pi' + } + } + + return title +} + +export function detectAgentStatusFromTitle(title: string): AgentStatus | null { + if (!title || isClaudeManagementTitle(title)) { + return null + } + if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { + return null + } + + if (title.includes(GEMINI_PERMISSION)) { + return 'permission' + } + if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { + return 'working' + } + if (title.includes(GEMINI_IDLE)) { + return 'idle' + } + + // Why: resolve synthetic Pi/OMP permission/idle labels before the broader + // Pi and braille-spinner checks below. + const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) + if (piCompatibleSyntheticAgentStatus) { + return piCompatibleSyntheticAgentStatus + } + + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return 'idle' + } + if (isPiTerminalTitle(title)) { + return 'idle' + } + if (containsBrailleSpinner(title)) { + return 'working' + } + + const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) + const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) + const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) + const hasLegacyAgentName = containsLegacyAgentName(title) + if (!hasLegacyAgentName && !hasDroidAgentName && !hasHermesAgentName && !hasAgyAgentName) { + return null + } + if (containsAny(title, ['action required', 'permission', 'waiting'])) { + return 'permission' + } + // Why: boundary-aware regexes avoid cwd/path and substring false positives. + if (STRONG_IDLE_KEYWORDS_RE.test(title)) { + return 'idle' + } + if (STRONG_WORKING_KEYWORDS_RE.test(title)) { + return 'working' + } + if (title.startsWith('. ')) { + return 'working' + } + if (title.startsWith('* ')) { + return 'idle' + } + + // Why: Droid hook events are authoritative; native name-only titles should + // not turn a still-sleeping execute tool into completion. + if (hasDroidAgentName && !hasLegacyAgentName) { + return null + } + + return 'idle' +} diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/command-code-output-status.test.ts rename to src/shared/command-code-output-status.test.ts diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.ts b/src/shared/command-code-output-status.ts similarity index 95% rename from src/renderer/src/components/terminal-pane/command-code-output-status.ts rename to src/shared/command-code-output-status.ts index 97e5f00db8b..8bba8a7472b 100644 --- a/src/renderer/src/components/terminal-pane/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -1,3 +1,11 @@ +/** + * Command Code TUI output scrape — that CLI lacks hooks, so working/done + * agent-status rows are seeded from its rendered status words and idle + * composer. Shared because main runs this per-PTY under side-effect authority + * (emitting command-code facts) while the renderer keeps the byte path for + * remote-runtime PTYs and the kill switch + * (docs/reference/terminal-side-effect-authority.md). + */ import { cleanCommandCodePromptCandidate, isCommandCodeIdlePromptCandidate diff --git a/src/renderer/src/components/terminal-pane/command-code-prompt-text.ts b/src/shared/command-code-prompt-text.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/command-code-prompt-text.ts rename to src/shared/command-code-prompt-text.ts diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 67ac5e65450..004bc38a01f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -304,6 +304,10 @@ export function getDefaultSettings(homedir: string): GlobalSettings { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, + terminalHiddenViewParking: true, + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true, defaultTuiAgent: null, disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], claudeAgentTeamsDefaultDisabledMigrated: true, diff --git a/src/shared/e2e-config.ts b/src/shared/e2e-config.ts index 54da72a45f0..e8f34d6bf51 100644 --- a/src/shared/e2e-config.ts +++ b/src/shared/e2e-config.ts @@ -3,23 +3,34 @@ export type E2EConfig = { headless: boolean exposeStore: boolean userDataDir: string | null + /** Test-only override (ORCA_E2E_TERMINAL_PARKING_DELAY_MS) shrinking the + * terminal hidden-view parking delays. null means use production timing. */ + terminalParkingDelayMs: number | null } type E2EConfigInput = { headless?: boolean exposeStore?: boolean userDataDir?: string | null + terminalParkingDelayMs?: number | null } export function createE2EConfig(input: E2EConfigInput): E2EConfig { const userDataDir = input.userDataDir?.trim() || null const headless = Boolean(input.headless) const exposeStore = Boolean(input.exposeStore) + const terminalParkingDelayMs = + typeof input.terminalParkingDelayMs === 'number' && + Number.isFinite(input.terminalParkingDelayMs) && + input.terminalParkingDelayMs > 0 + ? input.terminalParkingDelayMs + : null return { enabled: headless || exposeStore || userDataDir !== null, headless, exposeStore, - userDataDir + userDataDir, + terminalParkingDelayMs } } diff --git a/src/shared/github-links.ts b/src/shared/github-links.ts new file mode 100644 index 00000000000..0d55bbacdee --- /dev/null +++ b/src/shared/github-links.ts @@ -0,0 +1,100 @@ +// Why shared: main's terminal side-effect tracker emits pr-link facts +// (terminal-side-effect-authority.md, slice 3) and needs the same GitHub URL +// parsing core the renderer link picker uses. +const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i + +export type RepoSlug = { + owner: string + repo: string +} + +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 + } +} diff --git a/src/shared/osc-title-scan-tail.test.ts b/src/shared/osc-title-scan-tail.test.ts new file mode 100644 index 00000000000..f6afef56f48 --- /dev/null +++ b/src/shared/osc-title-scan-tail.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { extractOscTitleScanTail } from './osc-title-scan-tail' + +describe('extractOscTitleScanTail', () => { + it('keeps incomplete OSC title candidates only', () => { + expect(extractOscTitleScanTail('\x1b]0;Codex work')).toBe('\x1b]0;Codex work') + expect(extractOscTitleScanTail('\x1b]2;Codex working\x1b')).toBe('\x1b]2;Codex working\x1b') + expect(extractOscTitleScanTail('\x1b]')).toBe('\x1b]') + expect(extractOscTitleScanTail('\x1b]1')).toBe('\x1b]1') + }) + + it('does not carry non-title OSC payloads into the title scanner', () => { + expect(extractOscTitleScanTail('\x1b]133;D;13')).toBe('') + expect(extractOscTitleScanTail('\x1b]7;file://host/tmp')).toBe('') + expect(extractOscTitleScanTail('\x1b]133;D;0\x07\x1b')).toBe('\x1b') + }) +}) diff --git a/src/shared/osc-title-scan-tail.ts b/src/shared/osc-title-scan-tail.ts index e37bed4007c..62e49cf9236 100644 --- a/src/shared/osc-title-scan-tail.ts +++ b/src/shared/osc-title-scan-tail.ts @@ -1,18 +1,29 @@ const OSC_TITLE_SCAN_TAIL_LIMIT = 4096 const OSC_TITLE_PREFIX_LENGTH = 4 +const OSC_TITLE_CODES = new Set(['0', '1', '2']) export function extractOscTitleScanTail(input: string): string { const lastOsc = input.lastIndexOf('\x1b]') if (lastOsc !== -1) { const suffix = input.slice(lastOsc) if (!suffix.includes('\x07') && !suffix.includes('\x1b\\')) { - return trimOscTitleScanTail(suffix) + return extractIncompleteTitleOscTail(suffix) } return input.endsWith('\x1b') ? '\x1b' : '' } return input.endsWith('\x1b') ? '\x1b' : '' } +function extractIncompleteTitleOscTail(suffix: string): string { + const parameterEnd = suffix.indexOf(';', 2) + if (parameterEnd === -1) { + const partialParameter = suffix.slice(2) + return ['', '0', '1', '2'].includes(partialParameter) ? trimOscTitleScanTail(suffix) : '' + } + const parameter = suffix.slice(2, parameterEnd) + return OSC_TITLE_CODES.has(parameter) ? trimOscTitleScanTail(suffix) : '' +} + function trimOscTitleScanTail(value: string): string { if (value.length <= OSC_TITLE_SCAN_TAIL_LIMIT) { return value diff --git a/src/shared/pty-model-restore-marker.ts b/src/shared/pty-model-restore-marker.ts new file mode 100644 index 00000000000..6328ed6abc2 --- /dev/null +++ b/src/shared/pty-model-restore-marker.ts @@ -0,0 +1,19 @@ +/** + * Out-of-band `pty:modelRestoreNeeded` (main → renderer) payload. + * + * Why a dedicated channel instead of an in-band sentinel chunk: an empty + * `pty:data` chunk is indistinguishable from a real chunk whose bytes were + * entirely stripped by renderer-side OSC-9999 cleaning, so an in-band marker + * could spuriously trigger full snapshot restores on visible panes. The + * marker is delivery machinery, not PTY data — remote-runtime transports + * never see it. + */ +export type PtyModelRestoreReason = 'hidden-drop' | 'unhide' | 'pending-cap' + +export type PtyModelRestoreNeededEvent = { + id: string + reason: PtyModelRestoreReason + /** Main's PTY output sequence at emit time — everything at or before this + * point is only recoverable from the model snapshot. */ + markerSeq?: number +} diff --git a/src/renderer/src/components/terminal-pane/bell-detector.test.ts b/src/shared/terminal-bell-detector.test.ts similarity index 93% rename from src/renderer/src/components/terminal-pane/bell-detector.test.ts rename to src/shared/terminal-bell-detector.test.ts index bd48ba98381..8f1ce7b0bfc 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.test.ts +++ b/src/shared/terminal-bell-detector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createBellDetector } from './bell-detector' +import { createBellDetector } from './terminal-bell-detector' describe('createBellDetector', () => { it('skips ANSI chunks without losing later real bells', () => { diff --git a/src/renderer/src/components/terminal-pane/bell-detector.ts b/src/shared/terminal-bell-detector.ts similarity index 82% rename from src/renderer/src/components/terminal-pane/bell-detector.ts rename to src/shared/terminal-bell-detector.ts index 1afdb51e28c..cce08a7dbd0 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.ts +++ b/src/shared/terminal-bell-detector.ts @@ -2,6 +2,10 @@ * Stateful BEL detector that correctly ignores BEL (0x07) bytes * occurring inside OSC escape sequences. * + * Shared between the renderer transport processor and main's per-PTY + * side-effect tracker (docs/reference/terminal-side-effect-authority.md): + * bell semantics must not drift between the two parsing authorities. + * * Why stateful: PTY data arrives in arbitrary chunks, so an OSC sequence * may span multiple calls. The detector tracks in-progress escape state * across invocations so a BEL used as an OSC terminator is never @@ -17,7 +21,10 @@ * that ended mid-escape does not leak into the next stream. */ export type BellDetector = { - chunkContainsBell(data: string): boolean + /** `hints.containsOscIntroducer` lets a caller that already scanned for + * `\x1b]` (the title-extraction gate) share the result instead of paying + * a second includes() pass per chunk on the hot path. */ + chunkContainsBell(data: string, hints?: { containsOscIntroducer?: boolean }): boolean reset(): void } @@ -27,11 +34,11 @@ export function createBellDetector(): BellDetector { let pendingOscEscape = false return { - chunkContainsBell(data: string): boolean { + chunkContainsBell(data: string, hints: { containsOscIntroducer?: boolean } = {}): boolean { if (!inOsc && !pendingEscape && !data.includes('\x07')) { // Why: CSI/plain chunks with no BEL and no OSC start cannot affect // bell state; avoid walking every byte of normal terminal output. - if (!data.includes('\x1b]')) { + if (!(hints.containsOscIntroducer ?? data.includes('\x1b]'))) { pendingEscape = data.endsWith('\x1b') return false } diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.test.ts b/src/shared/terminal-github-pr-link-detector.test.ts similarity index 100% rename from src/renderer/src/lib/terminal-github-pr-link-detector.test.ts rename to src/shared/terminal-github-pr-link-detector.test.ts diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.ts b/src/shared/terminal-github-pr-link-detector.ts similarity index 91% rename from src/renderer/src/lib/terminal-github-pr-link-detector.ts rename to src/shared/terminal-github-pr-link-detector.ts index bd3cedbcc5b..ee7d92e60c6 100644 --- a/src/renderer/src/lib/terminal-github-pr-link-detector.ts +++ b/src/shared/terminal-github-pr-link-detector.ts @@ -1,3 +1,12 @@ +/** + * Chunk-boundary-safe GitHub PR URL scan over PTY output. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the + * renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off + * path. Both paths must share the carry/dedupe semantics or links split across + * chunks would resolve differently per authority mode. + */ import type { RepoSlug } from './github-links' import { parseGitHubIssueOrPRLink } from './github-links' diff --git a/src/shared/terminal-osc133-command-finished.ts b/src/shared/terminal-osc133-command-finished.ts new file mode 100644 index 00000000000..fc6c9ae7694 --- /dev/null +++ b/src/shared/terminal-osc133-command-finished.ts @@ -0,0 +1,102 @@ +/** + * Chunk-boundary-safe OSC 133;D (command finished) scanner. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `command-finished` facts from its per-PTY tracker for local/SSH PTYs, while + * the renderer keeps byte-parsing for remote-runtime PTYs and the + * kill-switch-off path. The carry semantics (split prefixes, BEL/ST + * terminators, best-effort exit codes) must be identical in both. + */ + +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 type Osc133CommandFinishedScanner = { + /** Feed one raw PTY chunk; fires once per complete OSC 133;D sequence. */ + scan: (data: string) => void + /** Drop the cross-chunk carry (transport teardown / parser reset). */ + reset: () => void +} + +export function createOsc133CommandFinishedScanner( + onCommandFinished: (bestEffortExitCode: number | null) => void +): Osc133CommandFinishedScanner { + let carry = '' + + const handleOsc133 = (payload: string): void => { + const [sequence, exitCode] = payload.split(';') + if (sequence === 'D') { + onCommandFinished(parseBestEffortExitCode(exitCode)) + } + } + + const scan = (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 { + scan, + reset() { + carry = '' + } + } +} diff --git a/src/shared/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts new file mode 100644 index 00000000000..821d2ca5e42 --- /dev/null +++ b/src/shared/terminal-output-side-effects.test.ts @@ -0,0 +1,178 @@ +// Why: slice 3 of terminal-side-effect-authority.md adds OSC 133;D +// command-finished and GitHub pr-link scanning to the shared tracker so main +// emits those facts for local/SSH PTYs. These tests pin the chunk-boundary +// carry, exit-code best-effort, dedupe, and synthetic-frame isolation rules. +import { describe, expect, it } from 'vitest' +import { + createTerminalTitleTracker, + type TerminalTitleTrackerCallbacks +} from './terminal-output-side-effects' + +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +type RecordedEvent = + | ['title', string] + | ['bell'] + | ['finished', number | null] + | ['pr', string, number] + | ['2031-subscribe'] + +function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): { + events: RecordedEvent[] + tracker: ReturnType +} { + const events: RecordedEvent[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => events.push(['title', normalized]), + onBell: () => events.push(['bell']), + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url, link.number]), + onMode2031Subscribe: () => events.push(['2031-subscribe']), + ...overrides + }) + return { events, tracker } +} + +describe('createTerminalTitleTracker command-finished facts', () => { + it('emits command-finished with best-effort exit codes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`before${ESC}]133;A${BEL}prompt${ESC}]133;B${BEL}`) + tracker.handleChunk(`${ESC}]133;C${BEL}running${ESC}]133;D;0${BEL}`) + tracker.handleChunk(`${ESC}]133;D;130${BEL}`) + tracker.handleChunk(`${ESC}]133;D;not-a-number${BEL}`) + tracker.handleChunk(`${ESC}]133;D${BEL}`) + + expect(events).toEqual([ + ['finished', 0], + ['finished', 130], + ['finished', null], + ['finished', null] + ]) + }) + + it('detects OSC 133;D split across chunk boundaries (BEL and ST terminated)', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`chunk${ESC}]133`) + tracker.handleChunk(';D;1') + expect(events).toEqual([]) + tracker.handleChunk(`30${BEL}rest`) + tracker.handleChunk(`${ESC}]133;D;7${ST}`) + + expect(events).toEqual([ + ['finished', 130], + ['finished', 7] + ]) + }) + + it('orders chunk facts titles → command-finished → bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}]0;zsh${BEL}${ESC}]133;D;0${BEL}done${BEL}`) + + expect(events).toEqual([['title', 'zsh'], ['finished', 0], ['bell']]) + }) +}) + +describe('createTerminalTitleTracker pr-link facts', () => { + it('emits one fact per PR URL including multiple links in one chunk', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk( + 'see https://github.com/acme/orca/pull/42 and https://github.com/acme/orca/pull/43 \r\n' + ) + + expect(events).toEqual([ + ['pr', 'https://github.com/acme/orca/pull/42', 42], + ['pr', 'https://github.com/acme/orca/pull/43', 43] + ]) + }) + + it('waits for a boundary when a URL splits across chunks and dedupes repeats', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk('PR: https://github.com/acme/orca/pull/4') + expect(events).toEqual([]) + tracker.handleChunk('2\r\n') + tracker.handleChunk('again https://github.com/acme/orca/pull/42\r\n') + + expect(events).toEqual([['pr', 'https://github.com/acme/orca/pull/42', 42]]) + }) + + it('skips the 133/URL scans entirely when no consumer is registered', () => { + // Mirrors headless serve: no pty:sideEffect consumer means no callbacks, + // so the scanners must not be created (no carry state, no scan cost). + const titles: string[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => titles.push(normalized) + }) + + tracker.handleChunk(`${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n`) + + expect(titles).toEqual([]) + }) +}) + +describe('createTerminalTitleTracker 2031-subscribe facts', () => { + it('emits a fact per chunk containing a DECSET 2031 subscribe, before the bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031hready${BEL}`) + + expect(events).toEqual([['2031-subscribe'], ['bell']]) + }) + + it('detects a subscribe split across chunk boundaries', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?20`) + tracker.handleChunk('31h') + + expect(events).toEqual([['2031-subscribe']]) + }) + + it('ignores DECSET 2031 unsubscribes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031l`) + + expect(events).toEqual([]) + }) + + it('skips the 2031 scan entirely when no consumer is registered', () => { + const { events, tracker } = createRecordingTracker({ onMode2031Subscribe: undefined }) + + tracker.handleChunk(`${ESC}[?2031h`) + + expect(events).toEqual([]) + }) +}) + +describe('createTerminalTitleTracker synthetic-frame isolation', () => { + it('never feeds synthetic frames to the 133/PR scanners', () => { + const { events, tracker } = createRecordingTracker() + + tracker.applySyntheticTitleFrame( + `${ESC}]0;⠋ Cursor Agent${BEL}${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n` + ) + + expect(events).toEqual([['title', '⠋ Cursor Agent']]) + }) + + it('keeps a split 133 carry intact across an interleaved synthetic frame', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`out${ESC}]133;D;`) + // An 80ms spinner tick lands between the two halves of the real OSC. + tracker.applySyntheticTitleFrame(`${ESC}]0;⠋ Cursor Agent${BEL}`) + tracker.handleChunk(`130${BEL}`) + + expect(events).toEqual([ + ['title', '⠋ Cursor Agent'], + ['finished', 130] + ]) + }) +}) diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts new file mode 100644 index 00000000000..fc6a61d381a --- /dev/null +++ b/src/shared/terminal-output-side-effects.ts @@ -0,0 +1,293 @@ +/** + * Shared per-PTY terminal title side-effect tracking — the parser core behind + * both the renderer transport (`createPtyOutputProcessor`) and main's + * per-PTY tracker in `OrcaRuntimeService.onPtyData`. + * + * Why shared: docs/reference/terminal-side-effect-authority.md makes main the + * side-effect parser for every PTY whose bytes transit local main. Title + * semantics (all-titles ordering, cursor-agent literal drop, normalization, + * stale-working-title clearing) must not drift between the two paths. + */ + +import { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + extractAllOscTitles, + isCursorNativeAgentTitle, + normalizeTerminalTitle +} from './agent-detection' +import { createBellDetector } from './terminal-bell-detector' +import { scanMode2031Sequences } from './terminal-color-scheme-protocol' +import { + createTerminalGitHubPRLinkDetector, + type TerminalGitHubPRLink +} from './terminal-github-pr-link-detector' +import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-finished' + +/** Ms of title-less output after a working title before it is cleared. */ +export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000 + +// Braille spinner frame glyphs (U+2800–U+28FF) — the decorative animation +// class agents rotate through while working. Mirrors the range +// clearWorkingIndicators strips in agent-detection.ts. +// eslint-disable-next-line no-control-regex -- intentional unicode range +const BRAILLE_SPINNER_RE = /[\u2800-\u28FF]/g + +/** + * Strip decorative braille spinner frame glyphs for change comparisons. + * Two working titles that differ only by the animation frame (e.g. + * "⠋ Cursor Agent" vs "⠙ Cursor Agent") compare equal after stripping — + * the gate consumers use to avoid fan-out churn on spinner ticks. + */ +export function stripBrailleSpinnerGlyphs(title: string): string { + return title.replace(BRAILLE_SPINNER_RE, '').trim() +} + +/** Provenance for title/idle facts. `staleWorkingTitleClear` marks facts + * synthesized by the 3s stale-working-title timer rather than observed + * bytes — consumers must not treat them as genuine task completions. */ +export type TerminalTitleFactMeta = { + staleWorkingTitleClear?: boolean +} + +export type TerminalTitleTrackerCallbacks = { + /** + * Fired once per observed OSC title, in byte order — including the + * synthesized cleared title when the stale-working timer fires. + */ + onTitle?: (normalizedTitle: string, rawTitle: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameIdle?: (title: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void + /** + * Fired once per chunk containing a real BEL (OSC-aware, escape state kept + * across chunks), after the chunk's title facts — the renderer drain order. + */ + onBell?: () => void + /** + * Fired per complete OSC 133;D (chunk-boundary-safe) with the sequence's + * best-effort exit code — mirrors the renderer terminal-command-lifecycle + * semantics so the fact path drops stale agent rows exactly like byte mode. + */ + onCommandFinished?: (bestEffortExitCode: number | null) => void + /** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, + * deduplicated per tracker like the renderer detector). */ + onPrLink?: (link: TerminalGitHubPRLink) => void + /** + * Fired per chunk containing a DECSET 2031 subscribe (chunk-boundary-safe). + * Lets hidden-delivery-gated renderer views answer the color-scheme query + * without byte access; the reply itself stays with the view. + */ + onMode2031Subscribe?: () => void +} + +export type TerminalTitleTracker = { + /** Feed one raw PTY chunk; titles are applied synchronously in byte order. */ + handleChunk: (data: string, options?: { titleScanData?: string }) => void + /** + * Apply a main-fabricated OSC title/BEL frame (agent hook spinner frames). + * Parsed statelessly — never through the chunk bell detector — so a + * synthetic tick landing between two real chunks that split an OSC cannot + * corrupt the cross-chunk escape state into phantom or swallowed bells. + */ + applySyntheticTitleFrame: (frame: string) => void + /** + * Seed the last-known title for a tracker created mid-session (app relaunch + * with persisted/snapshot titles). No-ops once any title has been observed + * or seeded — live state always wins. Fires no callbacks. + */ + seedInitialTitle: (rawTitle: string) => void + /** Last title surfaced through onTitle, after normalization. */ + getLastNormalizedTitle: () => string | null + /** Cancel the stale-title timer and clear accumulated tracker state. */ + dispose: () => void +} + +export function createTerminalTitleTracker( + callbacks: TerminalTitleTrackerCallbacks, + options: { initialTitle?: string } = {} +): TerminalTitleTracker { + const { + onTitle, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onBell, + onCommandFinished, + onPrLink, + onMode2031Subscribe + } = callbacks + const bellDetector = onBell ? createBellDetector() : null + // Why: created only when a consumer exists (like the bell detector) so + // headless serve never pays the per-chunk 133/URL scans. + const commandFinishedScanner = onCommandFinished + ? createOsc133CommandFinishedScanner(onCommandFinished) + : null + const prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null + // Why: a DECSET 2031 subscribe can be split across PTY chunks; carry a + // bounded tail between chunks so split sequences still match. + let mode2031ScanTail = '' + // Why: seed both the emitted-title memory (stale-title probe) and the agent + // tracker so a mid-session tracker behaves as if it had observed the pane's + // last live title — parity with the renderer processor's seeding. + let lastEmittedTitle: string | null = + options.initialTitle !== undefined ? normalizeTerminalTitle(options.initialTitle) : null + let staleTitleTimer: ReturnType | null = null + // Why: set while the stale timer's cleared title flows through the agent + // tracker so the resulting idle callback carries timer provenance — the + // renderer must not turn a stale clear into a task-complete notification. + let applyingStaleWorkingTitleClear = false + const agentTracker = + onAgentBecameIdle || onAgentBecameWorking || onAgentExited + ? createAgentStatusTracker( + (title) => { + onAgentBecameIdle?.( + title, + applyingStaleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + }, + onAgentBecameWorking, + onAgentExited, + options.initialTitle + ) + : null + + function clearStaleTitleTimer(): void { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + } + + function applyObservedTitle(rawTitle: string): void { + // Why: cursor-agent re-emits its bare native title many times per turn + // while still working; letting it through would stomp Orca's synthesized + // "⠋ Cursor Agent" spinner state back to agentless within a second. + if (isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + onTitle?.(lastEmittedTitle, rawTitle) + agentTracker?.handleTitle(rawTitle) + } + + function handleChunk(data: string, options: { titleScanData?: string } = {}): void { + const titleScanData = options.titleScanData ?? data + // Why: this is main's per-chunk hot path — scan for the OSC introducer + // once and share the result with the bell detector's fast-path gate. + const containsOscIntroducer = data.includes('\x1b]') + // Why: the bell detector must consume EVERY chunk so OSC sequences that + // span chunk boundaries keep their escape state, even when the chunk has + // no title. The fact itself is surfaced after the chunk's titles, the + // renderer drain's order (payloads → titles → bell). + const containsBell = bellDetector + ? bellDetector.chunkContainsBell(data, { containsOscIntroducer }) + : false + // Why: feed EVERY OSC title in the chunk in byte order, never just the + // last one. node-pty plus the main-process batch window commonly coalesce + // multiple title updates into a single payload; a last-title reader drops + // intra-chunk working→idle transitions (issue #1083). + const titles = titleScanData.includes('\x1b]') ? extractAllOscTitles(titleScanData) : [] + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + } else if ( + // Why: agents that exit without resetting their title leave a stale + // working spinner behind. Any title-less output while the last title + // classifies as working restarts a 3s timer that rewrites the title to + // its cleared form — the renderer transport's stale-title semantics. + data.length > 0 && + lastEmittedTitle !== null && + detectAgentStatusFromTitle(lastEmittedTitle) === 'working' + ) { + clearStaleTitleTimer() + staleTitleTimer = setTimeout(() => { + staleTitleTimer = null + if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + const cleared = clearWorkingIndicators(lastEmittedTitle) + lastEmittedTitle = cleared + // Why: tag timer-synthesized facts. Main's timer is unthrottled + // (unlike the renderer timers that previously damped this path in + // hidden windows), so a merely-paused agent must be distinguishable + // from a genuine working→idle completion downstream. + applyingStaleWorkingTitleClear = true + try { + onTitle?.(cleared, cleared, { staleWorkingTitleClear: true }) + agentTracker?.handleTitle(cleared) + } finally { + applyingStaleWorkingTitleClear = false + } + } + }, STALE_WORKING_TITLE_TIMEOUT_MS) + } + // Per-chunk fact order: titles → command-finished → pr-link → + // 2031-subscribe → bell. The bell stays last (the renderer drain's + // order); the byte scanners keep their own cross-chunk carry so split + // sequences/URLs still resolve. + commandFinishedScanner?.scan(data) + if (prLinkDetector) { + for (const link of prLinkDetector(data)) { + onPrLink?.(link) + } + } + if (onMode2031Subscribe) { + const mode2031Scan = scanMode2031Sequences(mode2031ScanTail, data) + mode2031ScanTail = mode2031Scan.tail + if (mode2031Scan.subscribe) { + onMode2031Subscribe() + } + } + if (containsBell) { + onBell?.() + } + } + + function applySyntheticTitleFrame(frame: string): void { + // Why: synthetic frames have an exact main-fabricated shape, so they are + // parsed statelessly here. Feeding them through handleChunk would run the + // stateful bell detector: a tick landing while a REAL OSC is split across + // two chunks would consume the pending escape state, minting a phantom + // bell from the continuation chunk or swallowing a real one. + const titles = extractAllOscTitles(frame) + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + } + // The deliberate permission BEL rides outside the OSC title sequence. A + // FRESH detector instance keeps the OSC-terminator-vs-bell semantics + // while guaranteeing zero interaction with the chunk detector's state. + // Synthetic frames never reach the 133/PR-link scanners: fabricated bytes + // contain neither and must not perturb their cross-chunk carry state. + if (onBell && createBellDetector().chunkContainsBell(frame)) { + onBell() + } + } + + return { + handleChunk, + applySyntheticTitleFrame, + seedInitialTitle(rawTitle: string): void { + // Why: the cursor-agent literal drop applies to seeds too — restoring + // the bare native title would stomp synthesized spinner state exactly + // like emitting it live would. + if (lastEmittedTitle !== null || !rawTitle || isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + agentTracker?.seedTitle(rawTitle) + }, + getLastNormalizedTitle: () => lastEmittedTitle, + dispose(): void { + clearStaleTitleTimer() + agentTracker?.reset() + bellDetector?.reset() + commandFinishedScanner?.reset() + mode2031ScanTail = '' + } + } +} diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts new file mode 100644 index 00000000000..1a546324006 --- /dev/null +++ b/src/shared/terminal-side-effect-facts.ts @@ -0,0 +1,56 @@ +/** + * Derived terminal side-effect facts carried on the `pty:sideEffect` channel + * (main → renderer). Events are facts, not decisions: main parses every + * local-daemon/SSH PTY byte exactly once and emits what it observed; the + * renderer store handler owns notification/unread policy. + * See docs/reference/terminal-side-effect-authority.md. + */ + +import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' + +/** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not + * observed bytes. Renderer policy clears title/cache state from them but + * must not schedule task-complete notifications or unread attention — a + * merely-paused agent (>3s silent mid-task) is not a completion. */ +export type TerminalSideEffectFact = + | { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean } + | { kind: 'bell' } + | { kind: 'agent-working' } + | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } + | { kind: 'agent-exited' } + /** OSC 133;D — foreground shell command exited (exit code best-effort). */ + | { kind: 'command-finished'; exitCode: number | null } + /** Carries the parsed link so the renderer store consumer never re-parses + * the URL (parse drift would break the per-PTY dedupe contract). */ + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + /** Command Code output scrape (that CLI lacks hooks). Working seeds the + * agent-status row immediately; done is a hint the renderer settle-checks + * against its live status row before completing the turn. */ + | { kind: 'command-code-working'; prompt: string } + | { kind: 'command-code-done'; prompt: string } + /** DECSET 2031 color-scheme subscribe observed in the byte stream. Emitted + * so hidden-delivery-gated views (whose bytes never arrive) can still send + * the theme reply — the reply stays renderer-side because query authority + * belongs to the view (model/view contract invariant 6). */ + | { kind: '2031-subscribe' } + +export type TerminalSideEffectBatch = { + ptyId: string + /** PTY output byte sequence at emission. Replay batches carry the sequence + * their title state was current at, so the handler can drop a replay title + * older than the last live title fact it applied. */ + seq: number + /** Facts from one chunk, in byte order: titles in sequence, then bell. + * Command Code scrape facts trail the chunk's parser facts — their policy + * (status-row seeding) never interacts with title/bell ordering. */ + facts: TerminalSideEffectFact[] + /** True for (re)attach snapshots. Replay batches restore title state only — + * attention facts (bell, agent transitions) never replay. */ + replay?: boolean + /** Main-known attribution from runtime leaf/PTY records (same resolution as + * agent-status events). Absent when main has no binding for the PTY yet. */ + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null +} diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index 626973b1051..3df867ef4b2 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -109,6 +109,21 @@ describe('terminal-stream-protocol', () => { expect(unsubscribe?.streamId).toBe(12) }) + it('round-trips output acknowledgement frames', () => { + const ack = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 12, + seq: 4, + payload: encodeTerminalStreamJson({ bytes: 4096 }) + }) + ) + + expect(ack?.opcode).toBe(TerminalStreamOpcode.Ack) + expect(ack?.streamId).toBe(12) + expect(ack && decodeTerminalStreamJson(ack.payload)).toEqual({ bytes: 4096 }) + }) + it('rejects unknown frame versions and opcodes', () => { const encoded = encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 9a993bee99a..57bb9fefe8d 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -13,7 +13,8 @@ export enum TerminalStreamOpcode { Resize = 8, Subscribe = 9, Unsubscribe = 10, - SnapshotRequest = 11 + SnapshotRequest = 11, + Ack = 12 } export type TerminalStreamFrame = { @@ -92,6 +93,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Resize || value === TerminalStreamOpcode.Subscribe || value === TerminalStreamOpcode.Unsubscribe || - value === TerminalStreamOpcode.SnapshotRequest + value === TerminalStreamOpcode.SnapshotRequest || + value === TerminalStreamOpcode.Ack ) } diff --git a/src/shared/terminal-view-attributes.test.ts b/src/shared/terminal-view-attributes.test.ts new file mode 100644 index 00000000000..17c77c08d09 --- /dev/null +++ b/src/shared/terminal-view-attributes.test.ts @@ -0,0 +1,119 @@ +/** + * View-attribute bridge (terminal-query-authority.md §View-attribute bridge): + * the XParseColor mirrors must match the bundled xterm grammar exactly — + * main's replies for hidden PTYs must be byte-identical to a visible + * renderer xterm's. + */ +import { describe, expect, it } from 'vitest' +import { + formatXColorRgbSpec, + parseXColorSpec, + terminalViewAttributesEqual, + validateTerminalViewAttributes, + type TerminalViewAttributes, + type TerminalViewRgb +} from './terminal-view-attributes' + +describe('parseXColorSpec', () => { + // Scaling fixtures mirror XParseColor.parseColor: h|hh|hhh|hhhh channels + // scale from their base (15/255/4095/65535) to 8 bit. + it.each([ + ['rgb:f/f/f', [255, 255, 255]], + ['rgb:0/8/f', [0, 136, 255]], + ['rgb:ff/00/80', [255, 0, 128]], + ['rgb:fff/000/888', [255, 0, 136]], + ['rgb:ffff/0000/8888', [255, 0, 136]], + ['RGB:FF/00/80', [255, 0, 128]], + ['#abc', [0xa0, 0xb0, 0xc0]], + ['#aabbcc', [0xaa, 0xbb, 0xcc]], + ['#aaabbbccc', [0xaa, 0xbb, 0xcc]], + ['#aaaabbbbcccc', [0xaa, 0xbb, 0xcc]] + ])('parses %s like xterm', (spec, expected) => { + expect(parseXColorSpec(spec)).toEqual(expected) + }) + + it.each([ + ['', 'empty'], + ['red', 'named colors (xterm rejects them too)'], + ['rgb:ff/ff', 'missing channel'], + ['rgb:ggg/000/000', 'non-hex'], + ['#abcd', 'hash length 4 is not a valid xparsecolor width'], + ['rgbi:1/1/1', 'rgbi is unsupported'] + ])('rejects %s — %s', (spec) => { + expect(parseXColorSpec(spec)).toBeNull() + }) +}) + +describe('formatXColorRgbSpec', () => { + it('reports 16-bit channels by doubling the 8-bit byte (toRgbString parity)', () => { + expect(formatXColorRgbSpec([0x1e, 0x1e, 0x2e])).toBe('rgb:1e1e/1e1e/2e2e') + expect(formatXColorRgbSpec([0, 8, 255])).toBe('rgb:0000/0808/ffff') + }) +}) + +describe('validateTerminalViewAttributes', () => { + const valid = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('accepts and normalizes a well-formed payload', () => { + const attrs = validateTerminalViewAttributes(valid()) + expect(attrs).not.toBeNull() + expect(attrs?.ansi).toHaveLength(256) + expect(attrs?.colorSchemeMode).toBe('dark') + }) + + it.each([ + ['null payload', null], + ['missing foreground', { ...valid(), foreground: undefined }], + ['short triple', { ...valid(), background: [1, 2] }], + ['out-of-range channel', { ...valid(), cursor: [0, 0, 300] }], + ['non-integer channel', { ...valid(), cursor: [0, 0, 1.5] }], + ['short palette', { ...valid(), ansi: valid().ansi.slice(0, 16) }], + ['bad palette entry', { ...valid(), ansi: [...valid().ansi.slice(0, 255), 'red'] }], + ['bad mode', { ...valid(), colorSchemeMode: 'auto' }], + ['bad cursor style', { ...valid(), cursorStyle: 'beam' }], + ['non-boolean blink', { ...valid(), cursorBlink: 1 }] + ])('rejects %s', (_label, payload) => { + expect(validateTerminalViewAttributes(payload)).toBeNull() + }) +}) + +describe('terminalViewAttributesEqual', () => { + // The store's idempotence gate: a deep-equal snapshot from a fresh renderer + // process must compare equal so the re-push never fans out as a theme apply. + const snapshot = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('treats two independently built identical snapshots as equal', () => { + expect(terminalViewAttributesEqual(snapshot(), snapshot())).toBe(true) + }) + + it.each([ + ['foreground', { ...snapshot(), foreground: [1, 2, 4] as TerminalViewRgb }], + ['background', { ...snapshot(), background: [0, 0, 0] as TerminalViewRgb }], + ['cursor', { ...snapshot(), cursor: [7, 8, 10] as TerminalViewRgb }], + [ + 'an ansi entry', + { ...snapshot(), ansi: snapshot().ansi.map((rgb, i) => (i === 200 ? [9, 9, 9] : rgb)) } + ], + ['colorSchemeMode', { ...snapshot(), colorSchemeMode: 'light' as const }], + ['cursorStyle', { ...snapshot(), cursorStyle: 'bar' as const }], + ['cursorBlink', { ...snapshot(), cursorBlink: false }] + ])('detects a change in %s', (_label, changed) => { + expect(terminalViewAttributesEqual(snapshot(), changed as TerminalViewAttributes)).toBe(false) + }) +}) diff --git a/src/shared/terminal-view-attributes.ts b/src/shared/terminal-view-attributes.ts new file mode 100644 index 00000000000..736003845dd --- /dev/null +++ b/src/shared/terminal-view-attributes.ts @@ -0,0 +1,188 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): payload contract for the renderer→main `pty:terminalViewAttributes` + * push, plus main/renderer mirrors of xterm's XParseColor color-spec grammar + * so main's responder replies byte-identically to a visible renderer xterm. + */ + +/** 8-bit-per-channel RGB triple — the same resolution xterm's theme service + * stores internally (`color.toColorRGB`). */ +export type TerminalViewRgb = [number, number, number] + +export const TERMINAL_VIEW_ANSI_COLOR_COUNT = 256 + +export type TerminalViewCursorStyle = 'bar' | 'block' | 'underline' + +/** One app-global snapshot of the renderer's composed terminal appearance — + * per-pane font zoom never affects these, and terminalColorOverrides / + * cursor settings are global, so one push covers all PTYs. */ +export type TerminalViewAttributes = { + foreground: TerminalViewRgb + background: TerminalViewRgb + /** Already blended over the background (xterm ThemeService blends the + * cursor color's alpha at theme-set time, e.g. terminalCursorOpacity). */ + cursor: TerminalViewRgb + /** Full 256-entry palette: theme's 16 named colors + extendedAnsi/default + * tail, exactly as the renderer ThemeService resolves them. */ + ansi: TerminalViewRgb[] + /** Resolved APP color-scheme mode (the 2031/997 flip source). NOT the DSR + * ?996n answer: that is computed from background/foreground relative + * luminance like a visible xterm (_reportColorScheme), and the two can + * disagree (e.g. dark terminal theme in light app mode). */ + colorSchemeMode: 'dark' | 'light' + cursorStyle: TerminalViewCursorStyle + cursorBlink: boolean +} + +// Mirror of @xterm XParseColor RGB_REX: r/g/b channels in 1-4 hex digits. +const X_RGB_SPEC_RE = + /^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/ +const X_HASH_SPEC_RE = /^[\da-f]+$/ + +/** Mirror of xterm's XParseColor `parseColor` (the grammar the renderer + * accepts for OSC 4/10/11/12 SET payloads): `rgb:h/h/h`..`rgb:hhhh/hhhh/hhhh` + * and `#RGB|#RRGGBB|#RRRGGGBBB|#RRRRGGGGBBBB`. Anything else (named colors, + * rgbi:) is rejected exactly like the renderer rejects it. */ +export function parseXColorSpec(spec: string): TerminalViewRgb | null { + if (!spec) { + return null + } + let low = spec.toLowerCase() + if (low.startsWith('rgb:')) { + low = low.slice(4) + const m = X_RGB_SPEC_RE.exec(low) + if (m) { + const base = m[1] ? 15 : m[4] ? 255 : m[7] ? 4095 : 65535 + return [ + Math.round((parseInt(m[1] || m[4] || m[7] || m[10], 16) / base) * 255), + Math.round((parseInt(m[2] || m[5] || m[8] || m[11], 16) / base) * 255), + Math.round((parseInt(m[3] || m[6] || m[9] || m[12], 16) / base) * 255) + ] + } + return null + } + if (low.startsWith('#')) { + low = low.slice(1) + if (X_HASH_SPEC_RE.exec(low) && [3, 6, 9, 12].includes(low.length)) { + const adv = low.length / 3 + const result: TerminalViewRgb = [0, 0, 0] + for (let i = 0; i < 3; ++i) { + const c = parseInt(low.slice(adv * i, adv * i + adv), 16) + result[i] = adv === 1 ? c << 4 : adv === 2 ? c : adv === 3 ? c >> 4 : c >> 8 + } + return result + } + } + return null +} + +function padChannelTo16Bit(value: number): string { + const hex = value.toString(16) + const byte = hex.length < 2 ? `0${hex}` : hex + // Why doubled: xterm reports 16-bit channels by repeating the 8-bit byte + // (XParseColor.toRgbString with bits=16) — pinned reply-format parity. + return byte + byte +} + +/** Mirror of xterm's `toRgbString(color, 16)` — the exact channel format a + * visible renderer xterm uses in OSC 4/10/11/12 query replies. */ +export function formatXColorRgbSpec(rgb: TerminalViewRgb): string { + return `rgb:${padChannelTo16Bit(rgb[0])}/${padChannelTo16Bit(rgb[1])}/${padChannelTo16Bit(rgb[2])}` +} + +function rgbEqual(a: TerminalViewRgb, b: TerminalViewRgb): boolean { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +} + +/** Value equality over the whole snapshot. Lets main's store treat a + * re-push of identical attributes (fresh renderer process: second window, + * reload, macOS re-activation) as a no-op instead of a theme apply. */ +export function terminalViewAttributesEqual( + a: TerminalViewAttributes, + b: TerminalViewAttributes +): boolean { + if (a === b) { + return true + } + if ( + !rgbEqual(a.foreground, b.foreground) || + !rgbEqual(a.background, b.background) || + !rgbEqual(a.cursor, b.cursor) || + a.colorSchemeMode !== b.colorSchemeMode || + a.cursorStyle !== b.cursorStyle || + a.cursorBlink !== b.cursorBlink || + a.ansi.length !== b.ansi.length + ) { + return false + } + for (let i = 0; i < a.ansi.length; i++) { + if (!rgbEqual(a.ansi[i], b.ansi[i])) { + return false + } + } + return true +} + +function isRgbChannel(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255 +} + +function validateRgbTriple(value: unknown): TerminalViewRgb | null { + if (!Array.isArray(value) || value.length !== 3) { + return null + } + const [r, g, b] = value + if (!isRgbChannel(r) || !isRgbChannel(g) || !isRgbChannel(b)) { + return null + } + return [r, g, b] +} + +/** IPC-boundary validation for the `pty:terminalViewAttributes` push. Returns + * a normalized copy or null — main must never store a malformed palette (a + * wrong color reply is worse than silence, the OSC-11 lesson). */ +export function validateTerminalViewAttributes(payload: unknown): TerminalViewAttributes | null { + if (typeof payload !== 'object' || payload === null) { + return null + } + const candidate = payload as Record + const foreground = validateRgbTriple(candidate.foreground) + const background = validateRgbTriple(candidate.background) + const cursor = validateRgbTriple(candidate.cursor) + if (!foreground || !background || !cursor) { + return null + } + if (!Array.isArray(candidate.ansi) || candidate.ansi.length !== TERMINAL_VIEW_ANSI_COLOR_COUNT) { + return null + } + const ansi: TerminalViewRgb[] = [] + for (const entry of candidate.ansi) { + const triple = validateRgbTriple(entry) + if (!triple) { + return null + } + ansi.push(triple) + } + if (candidate.colorSchemeMode !== 'dark' && candidate.colorSchemeMode !== 'light') { + return null + } + if ( + candidate.cursorStyle !== 'bar' && + candidate.cursorStyle !== 'block' && + candidate.cursorStyle !== 'underline' + ) { + return null + } + if (typeof candidate.cursorBlink !== 'boolean') { + return null + } + return { + foreground, + background, + cursor, + ansi, + colorSchemeMode: candidate.colorSchemeMode, + cursorStyle: candidate.cursorStyle, + cursorBlink: candidate.cursorBlink + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index e403caba22d..f2b5721a554 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2693,6 +2693,28 @@ export type GlobalSettings = { * does not surface commands from other worktrees. Defaults to true. * Disable to revert to shared global shell history. */ terminalScopeHistoryByWorktree: boolean + /** Kill switch for hidden terminal view parking — unmounting long-hidden + * terminal panes while a pane-less watcher keeps PTY side effects alive. + * Defaults to true; `false` disables parking entirely. + * See docs/reference/terminal-hidden-view-parking.md. */ + terminalHiddenViewParking?: boolean + /** Kill switch for main-process terminal side-effect authority: when true + * (default), local-daemon/SSH PTY title/bell/agent facts are consumed from + * the `pty:sideEffect` channel and renderer byte parsers stay unregistered + * for those PTYs; `false` restores renderer byte parsing. + * See docs/reference/terminal-side-effect-authority.md. */ + terminalMainSideEffectAuthority?: boolean + /** Kill switch for main's hidden-delivery gate (Phase 4): when true + * (default) AND terminalMainSideEffectAuthority is on, main drops PTY byte + * delivery to hidden renderer views after model ingestion; reveal restores + * from the model snapshot. `false` restores hidden byte delivery. */ + terminalHiddenDeliveryGate?: boolean + /** Kill switch for the main model query responder (Phase 5): when true + * (default) AND both Phase-4 gate switches are on, main answers terminal + * queries (DA1/CPR/DECRPM, …) embedded in hidden-dropped chunks from the + * runtime emulator. `false` silences the responder without changing drops. + * See docs/reference/terminal-query-authority.md. */ + terminalModelQueryAuthority?: boolean /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 7609459f216..9705c15dd5e 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -1,8 +1,12 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { rmSync } from 'node:fs' import path from 'node:path' +import { + type HiddenPressureOutputMode, + writePressureOutputScript +} from './artificial-opencode-hidden-pressure-script' import { ensureTerminalVisible, getActiveWorktreeId, @@ -47,13 +51,13 @@ type HiddenPressureDeps Promise releaseTerminalAckGate: (page: Page) => Promise resetTerminalPtyOutputDebug: (page: Page) => Promise - waitForMainPtyPressureBacklog: (page: Page) => Promise writeInteractivePromptScript: (scriptPath: string, runId: string) => void } +// Why: the renderer hidden-skip counters are gone with the skip grammar — +// withheld hidden output is observed via main's delivery-drop counters only. type HiddenPressureDebug = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number } type HiddenPressureMeasurement = { @@ -66,6 +70,13 @@ type HiddenPressureMainSnapshot = { peakPendingChars: number peakRendererInFlightChars: number ackGatedFlushSkipCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryGatedPtyCount: number +} + +type HiddenPressureSchedulerSnapshot = { + peakQueuedChars: number + droppedBacklogCount: number } type HiddenPressureAckGate = { @@ -75,50 +86,23 @@ type HiddenPressureAckGate = { // Why: restore still has to finish promptly, but parallel Electron workers on // Linux CI can overshoot the 1s product target without a responsiveness regression. const MAX_HIDDEN_RESTORE_LATENCY_MS = 1_500 - -export function pressureOutputScript(runId: string): string { - return ` -const paneIndex = process.argv[2] ?? '0' -const targetChars = Number(process.argv[3] ?? '0') -const delayMs = Number(process.argv[4] ?? '0') -const header = 'OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' -const chunkBody = '#'.repeat(8192) -let written = 0 -process.stdout.write(header) -function writeMore() { - let canContinue = true - while (canContinue && written < targetChars) { - const frame = String(written).padStart(8, '0') - const chunk = '\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n' - written += chunk.length - canContinue = process.stdout.write(chunk) - } - if (written < targetChars) { - process.stdout.once('drain', writeMore) - return - } - process.stdout.write('OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') -} -setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) -` -} - -export function writePressureOutputScript(scriptPath: string, runId: string): void { - mkdirSync(path.dirname(scriptPath), { recursive: true }) - writeFileSync(scriptPath, pressureOutputScript(runId)) -} +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped in +// main after model ingestion, so renderer-delivery pressure must stay FAR +// below the old 2 MB ACK-backpressure target instead of reaching it. +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 export async function runHiddenRealPtyPressureScenario< TMeasurement extends HiddenPressureMeasurement, TDebug extends HiddenPressureDebug, TMainPressure extends HiddenPressureMainSnapshot, TAckGate extends HiddenPressureAckGate, - TScheduler + TScheduler extends HiddenPressureSchedulerSnapshot >({ deps, annotationSuffix, hiddenPaneCount, pressureOutputChars, + pressureOutputMode = 'tui', pressureStartDelayMs, testInfo, testRepoPath, @@ -128,6 +112,7 @@ export async function runHiddenRealPtyPressureScenario< annotationSuffix?: string hiddenPaneCount: number pressureOutputChars: number + pressureOutputMode?: HiddenPressureOutputMode pressureStartDelayMs: number testInfo: TestInfo testRepoPath: string @@ -157,7 +142,7 @@ export async function runHiddenRealPtyPressureScenario< `.orca-opencode-hidden-pressure-load-${runId}.mjs` ) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, pressureOutputMode) await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( @@ -175,7 +160,11 @@ export async function runHiddenRealPtyPressureScenario< await switchToTypingWorkspace(orcaPage, firstWorktreeId) const typingPtyId = await waitForActivePanePtyId(orcaPage) - const pressureBeforeTyping = await deps.waitForMainPtyPressureBacklog(orcaPage) + // Why: under the Phase-4 hidden-delivery gate the hidden panes' bytes are + // dropped in main after model ingestion, so renderer-delivery pressure + // never builds. Wait for the gate to drop at least one pane's worth of + // output instead of the old 2 MB ACK-backpressure target. + await waitForMainHiddenDeliveryDrops(orcaPage, deps, pressureOutputChars) const measurement = await deps.measureTypingDuringLoad( orcaPage, typingScriptPath, @@ -183,6 +172,7 @@ export async function runHiddenRealPtyPressureScenario< runId ) const debug = await deps.readTerminalPtyOutputDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) const mainPressure = await deps.readMainPtyPressureDebug(orcaPage) const ackGate = await deps.readTerminalAckGateDebug(orcaPage) deps.annotateTypingMeasurement( @@ -191,17 +181,26 @@ export async function runHiddenRealPtyPressureScenario< hiddenPanes.length + 1, measurement, debug, - await deps.readTerminalOutputSchedulerDebug(orcaPage), + scheduler, mainPressure, ackGate ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) - expect(pressureBeforeTyping.peakPendingChars).toBeGreaterThan(0) - expect(pressureBeforeTyping.ackGatedFlushSkipCount).toBeGreaterThan(0) - expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual(8 * 1024 * 1024) - expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) + // Hidden-delivery contract (all pressure modes): bytes never reach the + // renderer — main's drop counter is the withheld-output signal (the + // renderer skip counters were deleted with the skip grammar) — and main's + // renderer-delivery pressure must stay clearly below the old 2 MB + // backpressure target. + expect(mainPressure?.hiddenDeliveryDroppedChars ?? 0).toBeGreaterThanOrEqual( + pressureOutputChars + ) + expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeLessThan( + MAIN_RENDERER_PRESSURE_TARGET_CHARS + ) + // Why: the renderer scheduler queue must stay ~empty (no hidden bytes to + // queue) and must never drop a backlog — strict, per the gate contract. + expect(scheduler?.peakQueuedChars ?? 0).toBeLessThan(pressureOutputChars) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(75) expect(measurement.worstLatencyMs).toBeLessThan(300) expect(measurement.maxTimerDriftMs).toBeLessThan(150) @@ -216,9 +215,11 @@ export async function runHiddenRealPtyPressureScenario< type: `opencode-hidden-real-pty-restore${annotationSuffix ?? ''}`, description: `panes=${hiddenPanes.length + 1} restore=${restoreLatencyMs.toFixed( 1 - )}ms hiddenSkippedChars=${debug?.hiddenRendererSkippedChars ?? 0} mainPeakInFlightChars=${ - mainPressure?.peakRendererInFlightChars ?? 0 - } heldAckChars=${ackGate?.heldAckChars ?? 0}` + )}ms hiddenDeliveryDroppedChars=${ + mainPressure?.hiddenDeliveryDroppedChars ?? 0 + } mainPeakInFlightChars=${mainPressure?.peakRendererInFlightChars ?? 0} heldAckChars=${ + ackGate?.heldAckChars ?? 0 + }` }) expect(restoreLatencyMs).toBeLessThan(MAX_HIDDEN_RESTORE_LATENCY_MS) } finally { @@ -234,6 +235,22 @@ export async function runHiddenRealPtyPressureScenario< } } +// Why: replaces the old waitForMainPtyPressureBacklog premise — the Phase-4 +// gate drops hidden bytes in main, so renderer-delivery pressure never builds; +// readiness is the gate reporting one pane's worth of dropped output. +async function waitForMainHiddenDeliveryDrops( + orcaPage: Page, + deps: { readMainPtyPressureDebug: (page: Page) => Promise }, + pressureOutputChars: number +): Promise { + await expect + .poll( + async () => (await deps.readMainPtyPressureDebug(orcaPage))?.hiddenDeliveryDroppedChars ?? 0, + { timeout: 30_000, message: 'Main hidden-delivery gate did not drop hidden PTY output' } + ) + .toBeGreaterThanOrEqual(pressureOutputChars) +} + async function measureHiddenOutputRestoreLatency( orcaPage: Page, worktreeId: string, diff --git a/tests/e2e/artificial-opencode-hidden-pressure-script.ts b/tests/e2e/artificial-opencode-hidden-pressure-script.ts new file mode 100644 index 00000000000..1eb94b3de89 --- /dev/null +++ b/tests/e2e/artificial-opencode-hidden-pressure-script.ts @@ -0,0 +1,52 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' | 'latin' | 'rich-model' + +export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMode): string { + const headerPrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' + const donePrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' + const chunkExpression = + mode === 'plain' + ? "'plain pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : mode === 'latin' + ? "'latin pressure café déjà vu São Tomé Żubrówka pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : mode === 'title' + ? "'\\x1b]0;title pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x07'" + : mode === 'rich-model' + ? "'\\x1b[?2026h\\x1b[?1049h\\x1b[2J\\x1b[H\\x1b[?25l\\x1b[2;36m╭────────────────────────────────────────╮\\x1b[0m\\r\\n\\x1b[2;36m│ rich model pane=' + paneIndex + ' frame=' + frame + ' 😀 ███░ │\\x1b[0m\\r\\n\\x1b[2;36m│ ' + chunkBody + ' │\\x1b[0m\\r\\n\\x1b[2;36m╰────────────────────────────────────────╯\\x1b[0m\\x1b[6;4H\\x1b[?25h\\x1b[?2026l\\n'" + : "'\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n'" + return ` +const paneIndex = process.argv[2] ?? '0' +const targetChars = Number(process.argv[3] ?? '0') +const delayMs = Number(process.argv[4] ?? '0') +const header = '${headerPrefix}OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' +const chunkBody = '#'.repeat(8192) +let written = 0 +process.stdout.write(header) +function writeMore() { + let canContinue = true + while (canContinue && written < targetChars) { + const frame = String(written).padStart(8, '0') + const chunk = ${chunkExpression} + written += chunk.length + canContinue = process.stdout.write(chunk) + } + if (written < targetChars) { + process.stdout.once('drain', writeMore) + return + } + process.stdout.write('${donePrefix}OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') +} +setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) +` +} + +export function writePressureOutputScript( + scriptPath: string, + runId: string, + mode: HiddenPressureOutputMode +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, pressureOutputScript(runId, mode)) +} diff --git a/tests/e2e/artificial-opencode-main-pressure-scenario.ts b/tests/e2e/artificial-opencode-main-pressure-scenario.ts index da2bd0f9a0f..1c13a5d26a0 100644 --- a/tests/e2e/artificial-opencode-main-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-main-pressure-scenario.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto' import { rmSync } from 'node:fs' import path from 'node:path' import { sendToTerminal } from './helpers/terminal' -import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-scenario' +import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' import { annotateScrollMeasurement, getResponsiveScrollPath, @@ -42,6 +42,7 @@ type MainPressureSchedulerSnapshot = { // Why: peak queued chars is noisy at the byte level on CI, but a coarse cap // still catches renderer queue growth that dropped-backlog/latency checks miss. const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 5 * 1024 * 1024 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 type MainPressureDeps< TMeasurement, @@ -124,7 +125,7 @@ export async function runMainPressureScenario< const pressureScriptPath = path.join(testRepoPath, `.orca-opencode-pressure-load-${runId}.mjs`) await seedActiveTerminalScrollback(orcaPage, typingPane.ptyId, scrollRunId) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( orcaPage, @@ -272,7 +273,9 @@ function expectMainPressureAndTyping = { + annotateTypingMeasurement: ( + testInfo: TestInfo, + type: string, + paneCount: number, + measurement: TMeasurement, + debug: TDebug | null, + scheduler: TScheduler | null, + mainPressure: TMainPressure | null, + ackGate: TAckGate | null + ) => void + ensureActiveWorktreePaneLoad: (page: Page, paneCount: number) => Promise + focusPane: (page: Page, paneKey: string) => Promise + holdTerminalAckGate: (page: Page, ptyIds: string[]) => Promise + measureTypingDuringLoad: ( + page: Page, + scriptPath: string, + ptyId: string, + runId: string + ) => Promise + readMainPtyPressureDebug: (page: Page) => Promise + readTerminalAckGateDebug: (page: Page) => Promise + readTerminalOutputSchedulerDebug: (page: Page) => Promise + readTerminalPtyOutputDebug: (page: Page) => Promise + releaseTerminalAckGate: (page: Page) => Promise + resetTerminalPtyOutputDebug: (page: Page) => Promise + waitForMainPtyPressureBacklog: (page: Page) => Promise + writeInteractivePromptScript: (scriptPath: string, runId: string) => void +} + +export async function runRendererBackpressureRevisitScenario< + TMeasurement extends RevisitPressureMeasurement, + TDebug extends RevisitPressureDebug, + TScheduler extends RevisitPressureSchedulerSnapshot, + TMainPressure extends RevisitPressureMainSnapshot, + TAckGate extends RevisitPressureAckGate +>({ + backgroundPaneCount, + deps, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + mainRendererPressureTargetChars, + pressureOutputChars, + orcaPage, + testInfo, + testRepoPath +}: { + backgroundPaneCount: number + deps: RevisitPressureDeps + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + mainRendererPressureTargetChars: number + pressureOutputChars: number + orcaPage: Page + testInfo: TestInfo + testRepoPath: string +}): Promise { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== firstWorktreeId) + expect(Boolean(secondWorktreeId), 'renderer backpressure revisit needs a second worktree').toBe( + true + ) + if (!secondWorktreeId) { + return + } + + const runId = randomUUID() + const typingPtyReadyMarker = `OPENCODE_REVISIT_TYPING_PTY_READY_${runId}` + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, `printf '\\n${typingPtyReadyMarker}\\n'\r`) + await waitForMarkerLatency(orcaPage, typingPtyReadyMarker, 10_000) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const panes = await deps.ensureActiveWorktreePaneLoad(orcaPage, backgroundPaneCount + 1) + const [revisitPane, ...loadPanes] = panes + await deps.focusPane(orcaPage, revisitPane.paneKey) + + const typingScriptPath = path.join(testRepoPath, `.orca-revisit-typing-${runId}.mjs`) + const pressureScriptPath = path.join(testRepoPath, `.orca-revisit-pressure-${runId}.mjs`) + const revisitMarker = `OPENCODE_REVISIT_READY_${runId}` + const pressureDoneMarker = `OPENCODE_PRESSURE_DONE_${runId}_0` + deps.writeInteractivePromptScript(typingScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') + await deps.resetTerminalPtyOutputDebug(orcaPage) + await deps.holdTerminalAckGate( + orcaPage, + loadPanes.map((pane) => pane.ptyId) + ) + try { + await startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath + }) + const pressureBeforeSwitch = await deps.waitForMainPtyPressureBacklog(orcaPage) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const measurement = await deps.measureTypingDuringLoad( + orcaPage, + typingScriptPath, + typingPtyId, + runId + ) + const duringPressure = await deps.readMainPtyPressureDebug(orcaPage) + const ackGate = await deps.readTerminalAckGateDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + const hiddenDebug = await deps.readTerminalPtyOutputDebug(orcaPage) + deps.annotateTypingMeasurement( + testInfo, + 'opencode-main-pressure-worktree-revisit-typing', + panes.length + 1, + measurement, + hiddenDebug, + scheduler, + duringPressure, + ackGate + ) + + expectPressureStayedBounded({ + ackGate, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure + }) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await deps.focusPane(orcaPage, revisitPane.paneKey) + await sendToTerminal(orcaPage, revisitPane.ptyId, `printf '\\n${revisitMarker}\\n'\r`) + const revisitLatencyMs = await waitForMarkerLatency(orcaPage, revisitMarker, 10_000) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-marker', + description: `panes=${panes.length + 1} revisit=${revisitLatencyMs.toFixed( + 1 + )}ms heldAckChars=${ackGate?.heldAckChars ?? 0}` + }) + expect(revisitLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + + await deps.releaseTerminalAckGate(orcaPage) + await deps.focusPane(orcaPage, loadPanes[0]?.paneKey ?? revisitPane.paneKey) + const pressureDrainLatencyMs = await waitForMarkerLatency(orcaPage, pressureDoneMarker, 20_000) + const finalScheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-drain', + description: `panes=${panes.length + 1} drain=${pressureDrainLatencyMs.toFixed( + 1 + )}ms rendererPeakQueuedChars=${finalScheduler?.peakQueuedChars ?? 0} rendererDroppedBacklogs=${ + finalScheduler?.droppedBacklogCount ?? 0 + }` + }) + expect(finalScheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(finalScheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + } finally { + await deps.releaseTerminalAckGate(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, '\x03').catch(() => undefined) + await sendToTerminal(orcaPage, revisitPane.ptyId, '\x03').catch(() => undefined) + await Promise.all( + loadPanes.map((pane) => sendToTerminal(orcaPage, pane.ptyId, '\x03').catch(() => undefined)) + ) + rmSync(typingScriptPath, { force: true }) + rmSync(pressureScriptPath, { force: true }) + } +} + +async function startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath +}: { + loadPanes: RevisitPressurePane[] + orcaPage: Page + pressureOutputChars: number + pressureScriptPath: string +}): Promise { + await Promise.all( + loadPanes.map((pane, paneIndex) => + sendToTerminal( + orcaPage, + pane.ptyId, + `node ${JSON.stringify(pressureScriptPath)} ${paneIndex} ${pressureOutputChars}\r` + ) + ) + ) +} + +async function waitForMarkerLatency( + page: Page, + marker: string, + timeoutMs: number +): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if ((await getTerminalContent(page, 12_000)).includes(marker)) { + return performance.now() - start + } + await page.waitForTimeout(5) + } + throw new Error(`Timed out waiting for terminal marker ${marker}`) +} + +function expectPressureStayedBounded({ + ackGate, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure +}: { + ackGate: RevisitPressureAckGate | null + mainRendererPressureTargetChars: number + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + measurement: TMeasurement + pressureBeforeSwitch: RevisitPressureMainSnapshot + scheduler: RevisitPressureSchedulerSnapshot | null + duringPressure: RevisitPressureMainSnapshot | null +}): void { + expect(pressureBeforeSwitch.peakPendingChars).toBeGreaterThan(0) + expect(pressureBeforeSwitch.ackGatedFlushSkipCount).toBeGreaterThan(0) + expect(duringPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual( + mainRendererPressureTargetChars + ) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(scheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + expect(measurement.medianLatencyMs).toBeLessThan(maxMedianKeyLatencyMs) + expect(measurement.worstLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + expect(measurement.maxTimerDriftMs).toBeLessThan(maxTimerDriftMs) +} diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index b40021abbe2..5f14954c199 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -20,7 +20,9 @@ import { waitForPaneIdentitySnapshot } from './helpers/terminal' import { runHiddenRealPtyPressureScenario } from './artificial-opencode-hidden-pressure-scenario' +import type { HiddenPressureOutputMode } from './artificial-opencode-hidden-pressure-script' import { runMainPressureScenario } from './artificial-opencode-main-pressure-scenario' +import { runRendererBackpressureRevisitScenario } from './artificial-opencode-revisit-pressure-scenario' import { startSyntheticOpenCodeInjection } from './artificial-opencode-synthetic-injection' type TerminalLoadPane = { @@ -55,9 +57,10 @@ type SyntheticOpenCodeWindow = Window & { } } +// Why: the renderer hidden-skip grammar is deleted — hidden bytes are dropped +// in main (gate) or ride the background queue. Only the mode-2031 fact-reply +// counter still has a renderer-side producer. type TerminalPtyOutputDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number } @@ -98,6 +101,12 @@ type MainPtyPressureDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + // Phase-4 hidden-delivery gate: bytes dropped in main after model ingestion. + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number } const KEY_LATENCY_SAMPLES = 'abcdefghijklmnop' @@ -110,6 +119,7 @@ const HIDDEN_PRESSURE_START_DELAY_MS = 1200 const DEFAULT_FRAME_COUNT = 180 const DEFAULT_FRAME_INTERVAL_MS = 6 const TIMER_SAMPLE_MS = 16 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 // Why: these are regression budgets, not observed baselines. Repeated local // 100-pane OpenCode-scale runs are below 50ms worst-key latency; keep enough // CI headroom while still failing changes that make typing visibly sluggish. @@ -119,6 +129,7 @@ const MAX_WORST_KEY_LATENCY_MS = 300 // without visible typing lag. Keep this as a smoke gate, not a CPU lottery. const MAX_TIMER_DRIFT_MS = 250 const MAX_SCROLL_LATENCY_MS = 150 +const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 3 * 1024 * 1024 function readPositiveInt(name: string, fallback: number): number { const raw = process.env[name] @@ -420,7 +431,7 @@ async function waitForMainPtyPressureBacklog(page: Page): Promise { lastSnapshot = await readMainPtyPressureDebug(page) return ( - (lastSnapshot?.peakRendererInFlightChars ?? 0) >= 8 * 1024 * 1024 && + (lastSnapshot?.peakRendererInFlightChars ?? 0) >= MAIN_RENDERER_PRESSURE_TARGET_CHARS && (lastSnapshot?.peakPendingChars ?? 0) > 0 && (lastSnapshot?.ackGatedFlushSkipCount ?? 0) > 0 ) @@ -447,14 +458,12 @@ function annotateTypingMeasurement( mainPressure: MainPtyPressureDebugSnapshot | null = null, ackGate: TerminalPtyAckGateSnapshot | null = null ): void { - const hiddenSkipSummary = debug - ? ` hiddenSkips=${debug.hiddenRendererSkipCount} hiddenSkippedChars=${debug.hiddenRendererSkippedChars} mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` - : '' + const mode2031Summary = debug ? ` mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` : '' const schedulerSummary = scheduler ? ` deferredForegroundEnqueue=${scheduler.deferredForegroundEnqueueCount} deferredForegroundWrite=${scheduler.deferredForegroundWriteCount} scheduledDrains=${scheduler.scheduledDrainCount} rendererQueuedTerminals=${scheduler.queuedTerminalCount} rendererQueuedChars=${scheduler.queuedChars} rendererPeakQueuedTerminals=${scheduler.peakQueuedTerminalCount} rendererPeakQueuedChars=${scheduler.peakQueuedChars} rendererPeakQueuedCharsByTerminal=${scheduler.peakQueuedCharsByTerminal} rendererDroppedBacklogs=${scheduler.droppedBacklogCount}` : '' const mainPressureSummary = mainPressure - ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount}` + ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount} mainHiddenGatedPtys=${mainPressure.hiddenDeliveryGatedPtyCount} mainHiddenDroppedChars=${mainPressure.hiddenDeliveryDroppedChars} mainPendingDroppedChars=${mainPressure.pendingDroppedChars}` : '' const ackGateSummary = ackGate ? ` heldAckPtys=${ackGate.heldAckCount} heldAckChars=${ackGate.heldAckChars} gatedAckPtys=${ackGate.gatedPtyCount}` @@ -467,7 +476,7 @@ function annotateTypingMeasurement( 1 )}ms maxTimerDrift=${measurement.maxTimerDriftMs.toFixed(1)}ms samples=${measurement.latencies .map((value) => value.toFixed(1)) - .join(',')}${hiddenSkipSummary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}` + .join(',')}${mode2031Summary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}` }) } @@ -526,8 +535,7 @@ async function measureCrossWorkspaceTypingDuringHiddenLoad({ scheduler, mainPressure ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) + expect(scheduler?.rendererDroppedBacklogs ?? 0).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_TIMER_DRIFT_MS) @@ -562,26 +570,28 @@ async function runConfiguredMainPressureScenario({ maxScrollLatencyMs: MAX_SCROLL_LATENCY_MS, maxTimerDriftMs: MAX_TIMER_DRIFT_MS, maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_MS, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - focusPane, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForActiveWorktree, - waitForMainPtyPressureBacklog, - waitForSessionReady, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps }) } +const terminalLoadScenarioDeps = { + annotateTypingMeasurement, + ensureActiveWorktreePaneLoad, + focusPane, + holdTerminalAckGate, + measureTypingDuringLoad, + readMainPtyPressureDebug, + readTerminalAckGateDebug, + readTerminalOutputSchedulerDebug, + readTerminalPtyOutputDebug, + releaseTerminalAckGate, + resetTerminalPtyOutputDebug, + waitForActiveWorktree, + waitForMainPtyPressureBacklog, + waitForSessionReady, + writeInteractivePromptScript +} + test.describe('Artificial OpenCode terminal load', () => { test.describe.configure({ mode: 'serial' }) @@ -681,6 +691,25 @@ test.describe('Artificial OpenCode terminal load', () => { }) }) + test('keeps renderer backpressure bounded across worktree revisit', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await runRendererBackpressureRevisitScenario({ + backgroundPaneCount: PRESSURE_BACKGROUND_PANES, + deps: terminalLoadScenarioDeps, + mainRendererPressureTargetChars: MAIN_RENDERER_PRESSURE_TARGET_CHARS, + maxMedianKeyLatencyMs: MAX_MEDIAN_KEY_LATENCY_MS, + maxRendererSchedulerQueuedChars: MAX_RENDERER_SCHEDULER_QUEUED_CHARS, + maxTimerDriftMs: MAX_TIMER_DRIFT_MS, + maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_MS, + orcaPage, + pressureOutputChars: PRESSURE_OUTPUT_CHARS, + testInfo, + testRepoPath + }) + }) + for (const paneCount of SCALE_PRESSURE_PANES) { test(`keeps active interactions responsive at ${paneCount} ACK-backpressured OpenCode PTYs`, async ({ orcaPage, @@ -761,7 +790,8 @@ test.describe('Artificial OpenCode terminal load', () => { testRepoPath: string, testInfo: TestInfo, hiddenPaneCount: number, - annotationSuffix?: string + annotationSuffix?: string, + pressureOutputMode?: HiddenPressureOutputMode ): Promise { await runHiddenRealPtyPressureScenario({ orcaPage, @@ -769,35 +799,56 @@ test.describe('Artificial OpenCode terminal load', () => { annotationSuffix, hiddenPaneCount, pressureOutputChars: PRESSURE_OUTPUT_CHARS, + pressureOutputMode, + // Why: the 10s codex startup renderer-query window is deleted — every + // pressure mode measures steady-state model restore with one delay. pressureStartDelayMs: HIDDEN_PRESSURE_START_DELAY_MS, testInfo, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForMainPtyPressureBacklog, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps + }) + } + const hiddenPressureCases: { + title: string + suffix?: string + mode?: HiddenPressureOutputMode + }[] = [ + { title: 'keeps typing responsive while hidden real PTYs are ACK-backpressured' }, + // Why: "withholds renderer delivery" — hidden bytes are dropped in main by + // the delivery gate; the renderer no longer skip-scans chunks (Phase 6). + { + title: 'withholds renderer delivery for plain hidden PTY output while preserving restore', + suffix: '-plain', + mode: 'plain' + }, + { + title: 'withholds renderer delivery for Latin hidden PTY output while preserving restore', + suffix: '-latin', + mode: 'latin' + }, + { + title: + 'withholds renderer delivery for title-only hidden PTY output while preserving restore', + suffix: '-title', + mode: 'title' + }, + { + title: 'restores rich hidden model output under ACK-backpressured PTY output', + suffix: '-rich-model', + mode: 'rich-model' + } + ] + for (const hiddenPressureCase of hiddenPressureCases) { + test(hiddenPressureCase.title, async ({ orcaPage, testRepoPath }, testInfo) => { + await runConfiguredHiddenRealPtyPressureScenario( + orcaPage, + testRepoPath, + testInfo, + HIDDEN_PRESSURE_PANES, + hiddenPressureCase.suffix, + hiddenPressureCase.mode + ) }) } - test('keeps typing responsive while hidden real PTYs are ACK-backpressured', async ({ - orcaPage, - testRepoPath - }, testInfo) => { - await runConfiguredHiddenRealPtyPressureScenario( - orcaPage, - testRepoPath, - testInfo, - HIDDEN_PRESSURE_PANES - ) - }) for (const paneCount of SCALE_HIDDEN_PRESSURE_PANES) { test(`keeps hidden restore responsive with ${paneCount} ACK-backpressured real PTYs`, async ({ orcaPage, diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index da3e58cf3b5..6cddd6399ba 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -26,11 +26,11 @@ export default function globalSetup(): void { // ── 1. Build the Electron app ────────────────────────────────────── if (process.env.SKIP_BUILD && existsSync(outMain)) { - console.log('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') + console.error('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') } else { // Why: --mode e2e is the build-time signal that exposes window.__store; // the explicit env var keeps older local overrides working too. - console.log('[e2e] Building Electron app with electron-vite build --mode e2e...') + console.error('[e2e] Building Electron app with electron-vite build --mode e2e...') execSync('npx electron-vite build --mode e2e', { env: { ...process.env, VITE_EXPOSE_STORE: 'true' }, cwd: root, @@ -39,13 +39,13 @@ export default function globalSetup(): void { // when healthy; global setup should not fail before specs can run. timeout: ELECTRON_E2E_BUILD_TIMEOUT_MS }) - console.log('[e2e] Build complete.') + console.error('[e2e] Build complete.') } if (process.env.ORCA_E2E_SSH_LOCALHOST === '1' || process.env.ORCA_E2E_SSH_DOCKER === '1') { // Why: the SSH specs deploy Orca's relay from out/relay. The // normal Electron E2E build does not produce that bundle, so build it only // for explicit SSH runs. - console.log('[e2e] Building SSH relay bundle for SSH E2E...') + console.error('[e2e] Building SSH relay bundle for SSH E2E...') execSync('pnpm run build:relay', { cwd: root, stdio: 'inherit', @@ -90,9 +90,9 @@ export default function globalSetup(): void { cwd: testRepoDir, stdio: 'pipe' }) - console.log(`[e2e] Secondary worktree created at ${worktreeDir}`) + console.error(`[e2e] Secondary worktree created at ${worktreeDir}`) // Write the test repo path so the fixture can read it writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) - console.log(`[e2e] Test repo created at ${testRepoDir}`) + console.error(`[e2e] Test repo created at ${testRepoDir}`) } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 61d4a2315d7..abb797878af 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -32,7 +32,7 @@ export default function globalTeardown(): void { } rmSync(testRepoDir, { recursive: true, force: true }) - console.log(`[e2e] Cleaned up test repo at ${testRepoDir}`) + console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) } rmSync(TEST_REPO_PATH_FILE, { force: true }) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index ecfa238d9d4..850022924f3 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -51,6 +51,14 @@ type OrcaTestFixtures = { // Why: most E2E specs need a ready project before assertions start. Golden // first-run specs opt out so they can prove the zero-project onboarding path. seedTestRepo: boolean + // Why: spec-scoped launch env. Mutating process.env at spec module scope + // leaks into other specs when a worker reloads files without replaying the + // first spec's afterAll; per-test launch env cannot leak. + orcaAppExtraEnv: Record + // Why: spec-scoped Chromium switches (e.g. --enable-precise-memory-info for + // memory benchmarks). Prepended before the main entry so Electron forwards + // them to Chromium without affecting other specs' launches. + orcaAppExtraArgs: string[] // Why: a few IPC repro specs need to launch the Electron app with a scoped // PATH/token environment. Keep this fixture-owned so tests never mutate the // developer's shell or already-running Orca instance. @@ -203,7 +211,11 @@ export const test = base.extend({ ], // Test-scoped: one Electron app per test - electronApp: async ({ dismissOnboarding, launchEnv }, provideFixture, testInfo) => { + electronApp: async ( + { dismissOnboarding, launchEnv, orcaAppExtraEnv, orcaAppExtraArgs }, + provideFixture, + testInfo + ) => { const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-')) @@ -240,7 +252,7 @@ export const test = base.extend({ mkdirSync(recordVideoDir, { recursive: true }) } const app = await electron.launch({ - args: getOrcaElectronLaunchArgs(mainPath, headful), + args: [...orcaAppExtraArgs, ...getOrcaElectronLaunchArgs(mainPath, headful)], ...(slowMo > 0 ? { slowMo } : {}), ...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}), // Why: keep NODE_ENV=development so window.__store is exposed and @@ -265,7 +277,8 @@ export const test = base.extend({ !cleanEnv.ORCA_RELAY_PATH ? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') } : {}), - ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) + ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }), + ...orcaAppExtraEnv } }) forwardElectronProcessLogs(app, testInfo) @@ -281,6 +294,8 @@ export const test = base.extend({ dismissOnboarding: [true, { option: true }], seedTestRepo: [true, { option: true }], launchEnv: [{}, { option: true }], + orcaAppExtraEnv: [{}, { option: true }], + orcaAppExtraArgs: [[], { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index ddcbee65586..c9679ac39d7 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -3,6 +3,8 @@ import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { execInTerminal, + focusLastTerminalPane, + splitActiveTerminalPane, waitForActivePanePtyId, waitForActiveTerminalManager, waitForTerminalOutput @@ -18,6 +20,7 @@ const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' const KEY_LATENCY_SAMPLES = 'abcdefghij' const MAX_MEDIAN_KEY_LATENCY_MS = 500 const MAX_WORST_KEY_LATENCY_MS = 2_000 +const MIN_HELD_SSH_ACK_CHARS = 256 * 1024 type TypingMeasurement = { latencies: number[] @@ -25,6 +28,20 @@ type TypingMeasurement = { worstLatencyMs: number } +type SshPtyAckGateSnapshot = { + gatedPtyCount: number + heldAckCount: number + heldAckChars: number +} + +type SshPtyAckGateWindow = Window & { + __terminalPtyAckGate?: { + hold: (ptyIds: string[]) => void + release: () => void + snapshot: () => SshPtyAckGateSnapshot + } +} + type ConnectedDockerRemote = { targetId: string repoId: string @@ -56,6 +73,22 @@ function remoteTypingLoadScript(runId: string): string { ].join(';') } +function remoteBackgroundFloodScript(runId: string): string { + return [ + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + `process.stdout.write('REMOTE_ACK_FLOOD_READY_${runId}\\n')`, + 'let frame = 0', + 'let timer = null', + "const chunk = 'R'.repeat(8192)", + 'function stop() { if (timer) clearInterval(timer); process.exit(0) }', + "function start() { if (timer) return; timer = setInterval(() => { frame += 1; process.stdout.write('REMOTE_ACK_FLOOD_' + frame + '_' + chunk + '\\n') }, 2) }", + "process.stdin.on('data', (chunk) => { if (chunk.includes(String.fromCharCode(3))) stop(); if (chunk.includes('g')) start() })", + "process.on('SIGINT', stop)" + ].join(';') +} + async function connectDockerRemote( page: Page, target: DockerSshRelayTarget @@ -148,6 +181,28 @@ async function measureRemoteTyping( } } +async function holdSshPtyAckGate(page: Page, ptyIds: string[]): Promise { + await page.evaluate((heldPtyIds) => { + const gate = (window as SshPtyAckGateWindow).__terminalPtyAckGate + if (!gate) { + throw new Error('terminal PTY ACK gate is unavailable') + } + gate.hold(heldPtyIds) + }, ptyIds) +} + +async function releaseSshPtyAckGate(page: Page): Promise { + await page.evaluate(() => { + ;(window as SshPtyAckGateWindow).__terminalPtyAckGate?.release() + }) +} + +async function readSshPtyAckGate(page: Page): Promise { + return page.evaluate( + () => (window as SshPtyAckGateWindow).__terminalPtyAckGate?.snapshot() ?? null + ) +} + async function stopRemoteLoad(page: Page, ptyId: string): Promise { await page.evaluate((targetPtyId) => window.api.pty.write(targetPtyId, '\x03'), ptyId) } @@ -207,6 +262,84 @@ test.describe('Docker SSH relay perf', () => { } }) + test('keeps active remote typing responsive while a background SSH PTY stream is ACK-stalled', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + let backgroundPtyId: string | null = null + let activePtyId: string | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await connectDockerRemote(orcaPage, target) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + backgroundPtyId = await waitForActivePanePtyId(orcaPage, 60_000) + + const runId = String(Date.now()) + await execInTerminal( + orcaPage, + backgroundPtyId, + `node -e ${shellQuote(remoteBackgroundFloodScript(runId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_ACK_FLOOD_READY_${runId}`, 30_000, 80_000) + await holdSshPtyAckGate(orcaPage, [backgroundPtyId]) + await orcaPage.evaluate((ptyId) => window.api.pty.write(ptyId, 'g'), backgroundPtyId) + + await splitActiveTerminalPane(orcaPage, 'vertical') + await focusLastTerminalPane(orcaPage) + activePtyId = await waitForActivePanePtyId(orcaPage, 60_000) + expect(activePtyId).not.toBe(backgroundPtyId) + + const activeRunId = `${runId}_active` + await execInTerminal( + orcaPage, + activePtyId, + `node -e ${shellQuote(remoteTypingLoadScript(activeRunId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${activeRunId}`, 30_000, 80_000) + await expect + .poll(async () => (await readSshPtyAckGate(orcaPage))?.heldAckChars ?? 0, { + timeout: 30_000, + message: 'remote background SSH PTY stream did not build held ACK pressure' + }) + .toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + + const measurement = await measureRemoteTyping(orcaPage, activePtyId, activeRunId) + const ackGate = await readSshPtyAckGate(orcaPage) + const summary = `median=${measurement.medianLatencyMs.toFixed( + 1 + )}ms worst=${measurement.worstLatencyMs.toFixed(1)}ms heldAckChars=${ + ackGate?.heldAckChars ?? 0 + } heldPtys=${ackGate?.heldAckCount ?? 0} samples=${measurement.latencies + .map((value) => value.toFixed(1)) + .join(',')}` + console.log(`[docker-ssh-relay-pty-ack-pressure] ${summary}`) + testInfo.annotations.push({ + type: 'docker-ssh-relay-pty-ack-pressure', + description: summary + }) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) + expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) + + await releaseSshPtyAckGate(orcaPage) + const releasedAckGate = await readSshPtyAckGate(orcaPage) + expect(releasedAckGate?.heldAckChars ?? 0).toBe(0) + } finally { + await releaseSshPtyAckGate(orcaPage).catch(() => undefined) + if (activePtyId) { + await stopRemoteLoad(orcaPage, activePtyId).catch(() => undefined) + } + if (backgroundPtyId) { + await stopRemoteLoad(orcaPage, backgroundPtyId).catch(() => undefined) + } + cleanupDockerSshRelayTarget(target) + } + }) + test('keeps an SSH workspace terminal usable after disconnect and reconnect', async ({ orcaPage }, testInfo) => { diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index 74453aa4076..2dccaf2e87c 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -1,6 +1,6 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { @@ -22,29 +22,23 @@ type HiddenTuiWindow = Window & { __terminalPtyDataInjection?: { inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean } + // Why: only the mode-2031 fact-reply counter survives Phase 6 — the + // hidden-skip counters were deleted with the renderer skip grammar. __terminalPtyOutputDebug?: { reset: () => void snapshot: () => { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number } } } -type HiddenTuiDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number -} - type TuiCursorState = { hidden: boolean | null initialized: boolean | null - cursorElementVisible: boolean - cursorCanvasPresent: boolean } +const HIDDEN_FRAME_SCRIPT_DELAY_MS = 750 + function tuiFrame(runId: string, frame: number): string { const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` const rows = [ @@ -79,23 +73,43 @@ function lowRiskRestoreFrame(runId: string, frame: number): string { } async function resetHiddenDebug(page: Page): Promise { - await page.evaluate(() => { + await page.evaluate(async () => { ;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset() + // Why: under the Phase-4 hidden-delivery gate the withheld-output signal + // lives in main's delivery debug counters, not the renderer skip path. + await window.api.pty.resetRendererDeliveryDebug() }) } function writeHiddenFrameScript(scriptPath: string, runId: string): void { const frames = Array.from({ length: 25 }, (_, frame) => tuiFrame(runId, frame)) - writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(frames.join(''))})\n`) + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` + ) +} + +function writeLowRiskFrameScript(scriptPath: string, frame: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frame)}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` + ) } async function writeHiddenFrames(page: Page, ptyId: string, scriptPath: string): Promise { await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) } -async function readHiddenDebug(page: Page): Promise { - return page.evaluate(() => { - return (window as HiddenTuiWindow).__terminalPtyOutputDebug?.snapshot() ?? null +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped +// in main after model ingestion and never reach the renderer, so "hidden +// output was withheld" is observed via main's dropped-chars counter instead +// of the old renderer hidden-skip counters. +async function readMainHiddenDeliveryDroppedChars(page: Page): Promise { + return page.evaluate(async () => { + const snapshot = await window.api.pty.getRendererDeliveryDebugSnapshot() + return snapshot.hiddenDeliveryDroppedChars }) } @@ -120,23 +134,9 @@ async function readTuiCursorState(page: Page): Promise { _core?: { coreService?: { isCursorHidden?: boolean; isCursorInitialized?: boolean } } } )._core - const cursorElement = pane.container.querySelector('.xterm-cursor') - const cursorRect = cursorElement?.getBoundingClientRect() - const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null return { hidden: terminalCore?.coreService?.isCursorHidden ?? null, - initialized: terminalCore?.coreService?.isCursorInitialized ?? null, - // Why: a blinking DOM cursor may be transparent during the sampled frame; - // disappearance regressions remove the laid-out cursor element/layer. - cursorElementVisible: - !!cursorElement && - !!cursorRect && - cursorRect.width > 0 && - cursorRect.height > 0 && - cursorStyle?.display !== 'none' && - cursorStyle?.visibility !== 'hidden', - cursorCanvasPresent: - pane.container.querySelector('.xterm-cursor-layer canvas') !== null + initialized: terminalCore?.coreService?.isCursorInitialized ?? null } }) } @@ -245,13 +245,23 @@ test.describe('Hidden terminal TUI visual restore', () => { writeHiddenFrameScript(scriptPath, runId) await resetHiddenDebug(orcaPage) await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) + // Why: hidden-delivery gate contract — the bulk TUI frames must be + // withheld in main (dropped after model ingestion), not delivered and + // skipped renderer-side. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'visually rich hidden TUI output should stay on the live xterm path' + message: 'visually rich hidden TUI output was not withheld from the renderer' }) - .toBe(0) + .toBeGreaterThan(1024) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'visually rich hidden TUI source did not come from headless model' + }) + .toBe('headless') await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -279,6 +289,7 @@ test.describe('Hidden terminal TUI visual restore', () => { hidden: false, initialized: true }) + const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png') await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) await testInfo.attach('hidden-tui-restore-final.png', { @@ -288,8 +299,9 @@ test.describe('Hidden terminal TUI visual restore', () => { rmSync(scriptPath, { force: true }) }) - test('keeps newer live output correct after hidden output stayed live', async ({ - orcaPage + test('keeps newer live output correct after plain hidden output restores', async ({ + orcaPage, + testRepoPath }, testInfo: TestInfo) => { await waitForSessionReady(orcaPage) const firstWorktreeId = await waitForActiveWorktree(orcaPage) @@ -323,18 +335,20 @@ test.describe('Hidden terminal TUI visual restore', () => { const hiddenFrame = lowRiskRestoreFrame(runId, 40) const liveFrame = lowRiskRestoreFrame(runId, 41) const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41` + const scriptPath = path.join(testRepoPath, `.orca-low-risk-hidden-${runId}.mjs`) + writeLowRiskFrameScript(scriptPath, hiddenFrame) + await resetHiddenDebug(orcaPage) + await sendToTerminal(orcaPage, hiddenPane.ptyId, `node ${JSON.stringify(scriptPath)}\r`) await resetHiddenDebug(orcaPage) - await injectPaneData(orcaPage, paneKey, hiddenFrame, { - seq: hiddenFrame.length, - rawLength: hiddenFrame.length - }) + // Why: hidden-delivery gate contract — even plain hidden output is + // dropped in main, so the withheld signal is main's dropped counter. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'hidden injected output should stay on the live xterm path for release' + message: 'plain hidden injected output was not withheld from the renderer' }) - .toBe(0) + .toBeGreaterThan(0) await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -347,7 +361,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => getTerminalContent(orcaPage, 12_000), { timeout: 10_000, - message: 'newer live TUI frame did not render after hidden output stayed live' + message: 'newer live TUI frame did not render after hidden output restored' }) .toContain(finalMarker) @@ -361,7 +375,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => readTuiCursorState(orcaPage), { timeout: 5_000, - message: 'live TUI cursor stayed hidden after hidden output stayed live' + message: 'live TUI cursor stayed hidden after hidden output restored' }) .toMatchObject({ hidden: false, @@ -373,9 +387,102 @@ test.describe('Hidden terminal TUI visual restore', () => { path: screenshotPath, contentType: 'image/png' }) + rmSync(scriptPath, { force: true }) }) - test('keeps hidden terminal side effects live while hidden output stays live', async ({ + test('restores rich synchronized TUI output from the headless model', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const hiddenPane = hiddenSnapshot.panes[0] + if (!hiddenPane?.ptyId) { + throw new Error('hidden rich model pane did not bind a PTY') + } + await switchToWorktree(orcaPage, firstWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { + timeout: 10_000, + message: 'first worktree did not become active before hidden rich model restore' + }) + .toBe(firstWorktreeId) + + const runId = randomUUID() + const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_24` + const scriptPath = path.join(testRepoPath, `.orca-hidden-rich-model-${runId}.mjs`) + writeHiddenFrameScript(scriptPath, runId) + await resetHiddenDebug(orcaPage) + try { + await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) + + // Why: hidden-delivery gate contract — synchronized rich frames are + // withheld in main; the headless model snapshot is the restore source. + await expect + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { + timeout: 10_000, + message: 'rich hidden TUI output was not withheld from the renderer' + }) + .toBeGreaterThan(0) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'rich hidden TUI source did not come from headless model' + }) + .toBe('headless') + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'rich headless TUI frame did not restore when visible' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame 024`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + await expect + .poll(() => readTuiCursorState(orcaPage), { + timeout: 5_000, + message: 'rich headless TUI cursor stayed hidden after restore' + }) + .toMatchObject({ + hidden: false, + initialized: true + }) + + const screenshotPath = testInfo.outputPath('hidden-rich-model-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('hidden-rich-model-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('keeps hidden terminal side effects live while hidden output may restore', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) @@ -411,12 +518,6 @@ test.describe('Hidden terminal TUI visual restore', () => { await resetHiddenDebug(orcaPage) await writeHiddenSideEffectBurst(orcaPage, hiddenPane.ptyId, hiddenTitle, marker) - await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { - timeout: 10_000, - message: 'hidden side-effect output should stay on the live xterm path for release' - }) - .toBe(0) await expect .poll(() => getRuntimePaneTitle(orcaPage, hiddenSnapshot.tabId, hiddenPane.numericPaneId), { timeout: 10_000, diff --git a/tests/e2e/terminal-hidden-view-parking.spec.ts b/tests/e2e/terminal-hidden-view-parking.spec.ts new file mode 100644 index 00000000000..aa97ba4bf25 --- /dev/null +++ b/tests/e2e/terminal-hidden-view-parking.spec.ts @@ -0,0 +1,409 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getWorktreeTabs, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: the parking wiring registers this handle (dev/exposeStore builds only) +// so tests can detect that hidden-view parking is compiled in and which delay +// override the app actually applied. +type ParkingDebugWindow = Window & { + __terminalParkingDebug?: { + parkDelayMs?: number + } +} + +// Why: production cold-park hysteresis is 30s with a multi-minute hot-retain +// window. The fast-park override must be scoped to THIS spec's app launches — +// mutating process.env at module scope leaked into later specs when a worker +// reloaded files without replaying this file's afterAll. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +const PARKED_FRAME_SCRIPT_DELAY_MS = 750 +const PARKED_FRAME_COUNT = 25 + +function parkedTuiFrame(runId: string, frame: number): string { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + const rows = [ + '╭────────────────────────────────────────────────────────────────────╮', + `│ Parked view restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress} │`, + '├──────────────┬──────────────────────┬──────────────────────────────┤', + `│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)} │`, + `│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)} │`, + `│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)} │`, + '╰──────────────┴──────────────────────┴──────────────────────────────╯', + `PARKED_RESTORE_FINAL_${runId}_${frame}` + ] + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'), + '\x1b[10;18H\x1b[?25h', + '\x1b[?2026l' + ].join('') +} + +function writeParkedFrameScript(scriptPath: string, runId: string): void { + const frames = Array.from({ length: PARKED_FRAME_COUNT }, (_, frame) => + parkedTuiFrame(runId, frame) + ) + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${PARKED_FRAME_SCRIPT_DELAY_MS})\n` + ) +} + +async function readParkingWiring( + page: Page +): Promise<{ present: boolean; parkDelayMs: number | null }> { + return page.evaluate(() => { + const debug = (window as ParkingDebugWindow).__terminalParkingDebug + return { present: debug !== undefined, parkDelayMs: debug?.parkDelayMs ?? null } + }) +} + +// Why: the spec lands ahead of the feature wiring. Skip (rather than fail) +// when the app under test does not expose the parking debug handle so this +// file is safe to merge in any order with the wiring branch. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let wiring = await readParkingWiring(page) + while (!wiring.present && Date.now() < deadline) { + await page.waitForTimeout(250) + wiring = await readParkingWiring(page) + } + test.skip( + !wiring.present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so a +// missing manager is the observable signal that the tab's xterm was parked. +async function waitForTabParked(page: Page, tabId: string): Promise { + const parkWaitStartedAt = Date.now() + await expect + .poll(async () => (await readTerminalTabViewState(page, tabId)).hasManager, { + timeout: Math.max(20_000, PARKING_DELAY_MS * 10), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) + return Date.now() - parkWaitStartedAt +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function getUnreadTerminalTabIds(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.keys(store.getState().unreadTerminalTabs) + }) +} + +async function isWorktreeUnread(page: Page, worktreeId: string): Promise { + return page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + return false + } + const worktree = Object.values(store.getState().worktreesByRepo) + .flat() + .find((candidate) => candidate.id === worktreeId) + return worktree?.isUnread === true + }, worktreeId) +} + +async function getTerminalTabTitle( + page: Page, + worktreeId: string, + tabId: string +): Promise { + const tabs = await getWorktreeTabs(page, worktreeId) + return tabs.find((tab) => tab.id === tabId)?.title ?? null +} + +async function hasPendingStartupCommand(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + return false + } + return store.getState().pendingStartupByTabId[tabId] !== undefined + }, tabId) +} + +type ParkableTabSetup = { + worktreeId: string + tabAId: string + tabAPtyId: string +} + +// Why: every scenario starts from the same shape — tab A live in the active +// worktree; callers then create more tabs on top so tab A goes hidden. +async function setUpParkableTabA(page: Page): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const tabASnapshot = await waitForPaneIdentitySnapshot(page, 1) + const tabAPtyId = tabASnapshot.panes[0]?.ptyId + if (!tabAPtyId) { + throw new Error('parking spec tab A did not bind a PTY') + } + return { + worktreeId, + tabAId: tabASnapshot.tabId, + tabAPtyId + } +} + +test.describe('Terminal hidden view parking', () => { + test('parks a hidden terminal tab and restores rich TUI output on reveal', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + const runId = randomUUID() + const finalMarker = `PARKED_RESTORE_FINAL_${runId}_${PARKED_FRAME_COUNT - 1}` + const scriptPath = path.join(testRepoPath, `.orca-parked-rich-tui-${runId}.mjs`) + writeParkedFrameScript(scriptPath, runId) + try { + await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'rich TUI final frame did not render while tab A was visible' + }) + .toContain(finalMarker) + + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + const parkDetectedAfterMs = await waitForTabParked(orcaPage, tabAId) + const wiring = await readParkingWiring(orcaPage) + testInfo.annotations.push({ + type: 'terminal-parking', + description: `parkDelayMs=${wiring.parkDelayMs ?? PARKING_DELAY_MS} parkDetectedAfterMs=${parkDetectedAfterMs}` + }) + + // Why: parking must be scoped to the hidden tab — the visible tab keeps + // a live pane manager and xterm. + const tabBState = await readTerminalTabViewState(orcaPage, tabBId) + expect(tabBState.hasManager).toBe(true) + expect(tabBState.paneCount).toBeGreaterThan(0) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + const revealedSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealedSnapshot.tabId).toBe(tabAId) + // Why: parking only tears down the renderer view; the PTY session must + // survive so reveal reattaches to the same shell. + expect(revealedSnapshot.panes[0]?.ptyId).toBe(tabAPtyId) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked rich TUI frame did not restore when the tab was revealed' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame ${String(PARKED_FRAME_COUNT - 1).padStart(3, '0')}`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + + // Why: the typed marker only appears joined in command *output*, so this + // proves the revealed terminal accepts input end-to-end, not just echo. + const typedMarker = `PARKED_TYPED_OK_${runId}` + const typedProbeScript = `console.log('PARKED_TYPED_OK_' + '${runId}')` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(typedProbeScript)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'revealed terminal did not execute and display typed input' + }) + .toContain(typedMarker) + + const screenshotPath = testInfo.outputPath('parked-tab-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('parked-tab-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('keeps bell and title side effects live while parked', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabAId) + + const runId = randomUUID() + const parkedTitle = `Parked side effects ${runId}` + const marker = `PARKED_SIDE_EFFECT_MARKER_${runId}` + // Why: OSC 0 title first, then a standalone BEL (the OSC terminator BEL + // must not count as a bell), then a content marker for the reveal check. + // The 30s keep-alive stops the shell prompt from overwriting the title + // before the store assertion lands. + const payload = `\x1b]0;${parkedTitle}\x07\x07${marker}\n` + const sideEffectScript = `process.stdout.write(${JSON.stringify(payload)}); setTimeout(() => process.exit(0), 30000)` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(sideEffectScript)}\r`) + + await expect + .poll(() => getTerminalTabTitle(orcaPage, worktreeId, tabAId), { + timeout: 10_000, + message: 'parked OSC 0 title did not update the tab title in the store' + }) + .toBe(parkedTitle) + await expect + .poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(tabAId), { + timeout: 10_000, + message: 'parked BEL did not mark the terminal tab unread' + }) + .toBe(true) + await expect + .poll(() => isWorktreeUnread(orcaPage, worktreeId), { + timeout: 10_000, + message: 'parked BEL did not mark the worktree unread' + }) + .toBe(true) + + // Why: side effects must come from the pane-less watcher — the burst must + // not have woken the parked view back up. + expect((await readTerminalTabViewState(orcaPage, tabAId)).hasManager).toBe(false) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked side-effect marker did not restore when the tab was revealed' + }) + .toContain(marker) + }) + + test('does not park excluded tabs', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId } = setup + + // Tab C: parking-excluded because it has a pending startup command. Queue + // it after the pane mounted so the mount-time consume cannot drain it. + const tabCId = await createActiveTerminalTab(orcaPage, worktreeId) + await orcaPage.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('parking exclusion spec: window.__store is unavailable') + } + store.getState().queueTabStartupCommand(tabId, { command: 'echo parked-exclusion-probe' }) + }, tabCId) + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + + // Tab B on top hides both A and C. + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + await expect + .poll(() => getActiveTabId(orcaPage), { + timeout: 5_000, + message: 'tab B did not stay active while waiting on the parking window' + }) + .toBe(tabBId) + + // Why: tab A parking proves the machinery ran past the delay in this app + // instance, so the tab C assertion below is not vacuously green. + await waitForTabParked(orcaPage, tabAId) + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + + // Premise guard: nothing consumed the pending startup while hidden. + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + const tabCState = await readTerminalTabViewState(orcaPage, tabCId) + expect(tabCState.hasManager).toBe(true) + expect(tabCState.paneCount).toBeGreaterThan(0) + }) +}) diff --git a/tests/e2e/terminal-long-table-scroll-restore.spec.ts b/tests/e2e/terminal-long-table-scroll-restore.spec.ts index 1af4b1ecf41..18c9647a68e 100644 --- a/tests/e2e/terminal-long-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-long-table-scroll-restore.spec.ts @@ -51,15 +51,6 @@ type TerminalRenderDiagnostics = { }[] } -type LongTableDebugWindow = Window & { - __terminalPtyOutputDebug?: { - reset: () => void - snapshot: () => { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number - } - } } async function setNarrowTerminalViewport(page: Page): Promise { @@ -359,7 +350,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -398,10 +388,6 @@ test.describe('Terminal long table scroll restore repro', () => { await scrollActiveTerminalLikeUser(orcaPage) await closeFeatureTips(orcaPage) const diagnostics = await readTerminalRenderDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) const restoredPane = diagnostics.allPaneStates.find((paneState) => paneState.hasMarker) expect(restoredPane).toBeDefined() expect(diagnostics.cursorHidden).toBe(false) @@ -426,7 +412,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -467,10 +452,6 @@ test.describe('Terminal long table scroll restore repro', () => { await scrollActiveTerminalLikeUser(orcaPage) await closeFeatureTips(orcaPage) const diagnostics = await readTerminalRenderDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) // Why: renderer cell metrics can land one column wider in headless runs; // the content and screenshot assertions below cover the actual regression. expect(diagnostics.cols).toBeLessThanOrEqual(112) @@ -504,7 +485,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -571,10 +551,6 @@ test.describe('Terminal long table scroll restore repro', () => { const diagnostics = await readTerminalRenderDiagnostics(orcaPage) const overpaint = await readTerminalRightEdgeOverpaint(orcaPage) const wrapDiagnostics = await readTerminalBoxTableWrapDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) expect(diagnostics.cols).toBeLessThanOrEqual(NARROW_TERMINAL_MAX_COLS) expect(wrapDiagnostics.cols).toBeGreaterThanOrEqual(generatedTableWidth) expect(diagnostics.cursorHidden).toBe(false) diff --git a/tests/e2e/terminal-parked-memory.spec.ts b/tests/e2e/terminal-parked-memory.spec.ts new file mode 100644 index 00000000000..2fb1165abbe --- /dev/null +++ b/tests/e2e/terminal-parked-memory.spec.ts @@ -0,0 +1,358 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: production cold-park hysteresis is 30s. The fast-park env override is +// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as +// terminal-hidden-view-parking.spec.ts) so it cannot leak into other specs. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }, + // Why: without this switch Chromium quantizes performance.memory and only + // refreshes it every ~20 minutes, so both scenarios report the same stale + // launch-time bucket instead of a comparable heap figure. + orcaAppExtraArgs: ['--enable-precise-memory-info'] +}) + +// Why: 8 hidden tabs is below the 12-tab hot-retain limit, but that limit +// never retains anything here — the ORCA_E2E_TERMINAL_PARKING_DELAY_MS +// collapse (terminal-parking-e2e-overrides.ts) shrinks hotRetainMs to the +// same delay as coldParkDelayMs, and the policy cold-parks any tab hidden +// past hotRetainMs before the retain-count limit is even consulted. So all 8 +// park without needing 14 tabs or extra policy knobs. +const SCROLLBACK_TAB_COUNT = 8 +const SCROLLBACK_LINE_COUNT = 3000 +const PARK_SETTLE_MS = 2_000 +const HEAP_SAMPLE_COUNT = 5 +const HEAP_SAMPLE_INTERVAL_MS = 250 +// Why: each test launches a fresh app, fills 8 terminals with ~3000 lines of +// scrollback each, then waits out the parking window — well past the default +// 120s per-test budget. +const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000 + +// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes +// each xterm hold realistic narrow+wide buffer rows, so released parked-tab +// memory reflects real agent output rather than uniform filler. +function writeScrollbackFillScript(scriptPath: string, runId: string): void { + const script = [ + `const tabIndex = process.argv[2] ?? '0'`, + `const wide = '統合端末記憶計測'`, + `const emoji = ['🟢', '🟡', '🔵', '🟣']`, + `const lines = []`, + `for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`, + ` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`, + ` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`, + ` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`, + `}`, + `process.stdout.write(lines.join('\\n') + '\\n')`, + `process.stdout.write('PARKED_MEMORY_FILL_DONE_${runId}_' + tabIndex + '\\n')` + ].join('\n') + writeFileSync(scriptPath, `${script}\n`) +} + +// Why: the spec lands ahead of the feature wiring in some merge orders. Skip +// (rather than fail) when the app under test does not expose the parking +// debug handle, mirroring terminal-hidden-view-parking.spec.ts. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + while (!present && Date.now() < deadline) { + await page.waitForTimeout(250) + present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + } + test.skip( + !present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so +// a missing manager is the observable signal that the tab's xterm was parked. +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +async function countMountedPaneManagers(page: Page, tabIds: string[]): Promise { + return page.evaluate( + (tabIds) => tabIds.filter((tabId) => window.__paneManagers?.get(tabId) !== undefined).length, + tabIds + ) +} + +async function waitForTabsParked(page: Page, tabIds: string[]): Promise { + await expect + .poll(() => countMountedPaneManagers(page, tabIds), { + timeout: Math.max(30_000, PARKING_DELAY_MS * 10), + message: 'hidden scrollback tabs did not all park (pane managers still mounted)' + }) + .toBe(0) +} + +type ScrollbackTab = { + tabId: string + ptyId: string +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + const snapshot = await waitForPaneIdentitySnapshot(page, 1) + const ptyId = snapshot.panes[0]?.ptyId + if (snapshot.tabId !== tabId || !ptyId) { + throw new Error('createActiveTerminalTab: new tab did not bind a PTY') + } + return { tabId, ptyId } +} + +async function fillActiveTerminalWithScrollback( + page: Page, + ptyId: string, + scriptPath: string, + tabIndex: number, + runId: string +): Promise { + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`) + await expect + .poll(() => getTerminalContent(page, 4_000), { + timeout: 30_000, + message: `scrollback fill marker for tab ${tabIndex} did not render` + }) + .toContain(`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`) +} + +type ScrollbackTabSetup = { + worktreeId: string + scrollbackTabs: ScrollbackTab[] +} + +// Why: each tab generates its scrollback while visible, so every xterm holds +// the full buffer before going hidden — the hidden-delivery gate never gets a +// chance to drop the output the memory comparison depends on. +async function setUpScrollbackTabs( + page: Page, + scriptPath: string, + runId: string +): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const baselineSnapshot = await waitForPaneIdentitySnapshot(page, 1) + const baselinePtyId = baselineSnapshot.panes[0]?.ptyId + if (!baselinePtyId) { + throw new Error('parked memory spec: baseline terminal tab did not bind a PTY') + } + + const scrollbackTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }] + await fillActiveTerminalWithScrollback(page, baselinePtyId, scriptPath, 0, runId) + for (let tabIndex = 1; tabIndex < SCROLLBACK_TAB_COUNT; tabIndex += 1) { + const tab = await createActiveTerminalTab(page, worktreeId) + scrollbackTabs.push(tab) + await fillActiveTerminalWithScrollback(page, tab.ptyId, scriptPath, tabIndex, runId) + } + return { worktreeId, scrollbackTabs } +} + +type ParkedMemoryMetrics = { + heapUsedMB: number + liveTerminals: number + livePaneManagers: number +} + +// Why: usedJSHeapSize only drops after a GC, so force one over CDP (best +// effort) and take the min of several settled samples — the min reflects +// retained heap instead of allocation noise between collections. Note xterm +// buffer rows are typed-array backing stores outside the V8 heap, so the +// liveTerminals/livePaneManagers counts are the strong release signal and the +// heap figure tracks only the on-heap share. +async function sampleParkedMemoryMetrics(page: Page): Promise { + await page.waitForTimeout(PARK_SETTLE_MS) + try { + const session = await page.context().newCDPSession(page) + await session.send('HeapProfiler.collectGarbage') + await session.detach() + } catch { + // GC over CDP is a measurement-fidelity improvement, not a gate. + } + + let minHeapBytes: number | null = null + for (let sample = 0; sample < HEAP_SAMPLE_COUNT; sample += 1) { + const heapBytes = await page.evaluate(() => { + const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory + return memory?.usedJSHeapSize ?? null + }) + if (heapBytes !== null) { + minHeapBytes = minHeapBytes === null ? heapBytes : Math.min(minHeapBytes, heapBytes) + } + await page.waitForTimeout(HEAP_SAMPLE_INTERVAL_MS) + } + if (minHeapBytes === null) { + throw new Error('sampleParkedMemoryMetrics: performance.memory.usedJSHeapSize is unavailable') + } + + const liveCounts = await page.evaluate(() => ({ + liveTerminals: document.querySelectorAll('.xterm').length, + livePaneManagers: window.__paneManagers?.size ?? 0 + })) + return { heapUsedMB: minHeapBytes / (1024 * 1024), ...liveCounts } +} + +function formatParkedMemoryAnnotation(metrics: ParkedMemoryMetrics, parkedTabs: number): string { + return [ + `panes=${SCROLLBACK_TAB_COUNT}`, + `parkedTabs=${parkedTabs}`, + `heapUsedMB=${metrics.heapUsedMB.toFixed(1)}`, + `liveTerminals=${metrics.liveTerminals}`, + `livePaneManagers=${metrics.livePaneManagers}` + ].join(' ') +} + +test.describe('Terminal parked memory', () => { + test('releases renderer terminal memory when hidden tabs park', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + + // A fresh 9th tab hides all 8 scrollback tabs. + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabsParked( + orcaPage, + scrollbackTabs.map((tab) => tab.tabId) + ) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory', + description: formatParkedMemoryAnnotation(metrics, scrollbackTabs.length) + }) + + // Structural assertions: all 8 parked (managers gone), and the only + // live xterm/pane manager belongs to the visible tab. + for (const tab of scrollbackTabs) { + expect((await readTerminalTabViewState(orcaPage, tab.tabId)).hasManager).toBe(false) + } + const visibleState = await readTerminalTabViewState(orcaPage, visibleTab.tabId) + expect(visibleState.hasManager).toBe(true) + expect(visibleState.paneCount).toBeGreaterThan(0) + // Why: design invariant 5 — renderer terminal views scale with visible + // panes, so parked tabs must leave no xterm DOM behind. + expect(metrics.liveTerminals).toBe(visibleState.paneCount) + expect(metrics.livePaneManagers).toBe(1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('retains terminal views when parking is disabled', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + // Why: settings.terminalHiddenViewParking === false is the design-doc + // kill switch. updateSettings persists it through window.api.settings.set + // and updates the store slice the cold-park hook subscribes to — the same + // mutation path dead-terminal-repro.spec.ts uses, so no extra launch-env + // wiring is needed. + await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('parked memory spec: window.__store is unavailable') + } + await store.getState().updateSettings({ terminalHiddenViewParking: false }) + }) + await expect + .poll( + () => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalHiddenViewParking), + { timeout: 5_000, message: 'terminalHiddenViewParking kill switch did not persist' } + ) + .toBe(false) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + const scrollbackTabIds = scrollbackTabs.map((tab) => tab.tabId) + + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + // Why: with parking enabled these tabs park within ~1x the collapsed + // delay (the first test proves the machinery in this app build), so + // surviving 3x the delay shows the kill switch held. + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + expect(await countMountedPaneManagers(orcaPage, scrollbackTabIds)).toBe(SCROLLBACK_TAB_COUNT) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory-disabled', + description: formatParkedMemoryAnnotation(metrics, 0) + }) + + // Structural assertions: every hidden tab keeps its pane manager and + // xterm; nothing parked even after the settle + sampling window. + for (const tab of scrollbackTabs) { + const state = await readTerminalTabViewState(orcaPage, tab.tabId) + expect(state.hasManager).toBe(true) + expect(state.paneCount).toBeGreaterThan(0) + } + expect((await readTerminalTabViewState(orcaPage, visibleTab.tabId)).hasManager).toBe(true) + expect(metrics.livePaneManagers).toBe(SCROLLBACK_TAB_COUNT + 1) + expect(metrics.liveTerminals).toBe(SCROLLBACK_TAB_COUNT + 1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts index 043fbe61012..61b930d9653 100644 --- a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts @@ -180,7 +180,10 @@ function rawEmojiFixtureFrameTailMarker(runId: string): string { } async function setWideRenderedTableViewport(page: Page): Promise { - await page.setViewportSize({ width: 1480, height: 820 }) + const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows')) + // Why: macOS hosted runners need extra room for font/column variance, while + // Windows Electron golden rendering is stable at the native-sized viewport. + await page.setViewportSize({ width: isWindows ? 1480 : 1760, height: 820 }) await page.waitForTimeout(250) await page.evaluate(() => { const store = window.__store @@ -537,6 +540,9 @@ test.describe('Terminal raw emoji table scroll restore repro', () => { await switchToWorktree(orcaPage, secondWorktreeId) await waitForActiveTerminalManager(orcaPage, 30_000) await orcaPage.waitForTimeout(1_000) + // Why: switching back can replay hidden terminal contents immediately; + // make the viewport wide before restore so the table cannot wrap first. + await setWideRenderedTableViewport(orcaPage) await switchToWorktree(orcaPage, firstWorktreeId) // Why: activating another worktree can restore the right sidebar. This // golden is about terminal renderer restore at a deliberately wide width. diff --git a/tests/e2e/terminal-sleep-wake-restore.spec.ts b/tests/e2e/terminal-sleep-wake-restore.spec.ts new file mode 100644 index 00000000000..83a924a1099 --- /dev/null +++ b/tests/e2e/terminal-sleep-wake-restore.spec.ts @@ -0,0 +1,220 @@ +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +type SleepWakeTerminalDebug = { + activeTabId: string | null + activeWorktreeId: string | null + tabs: { + id: string + ptyId?: string + generation?: number + pendingActivationSpawn?: boolean | number + }[] + ptyIdsByTabId: Record + ptyIdsByLeafIdByTabId: Record> +} + +async function sleepWorktreeTerminals(page: Page, worktreeId: string): Promise { + await page.evaluate(async (id) => { + const store = window.__store + if (!store) { + throw new Error('store unavailable') + } + const state = store.getState() + await state.shutdownWorktreeBrowsers(id) + await state.shutdownWorktreeTerminals(id, { keepIdentifiers: true }) + }, worktreeId) +} + +async function readLivePtyCountForWorktree(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return 0 + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return tabs.reduce((count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), 0) + }, worktreeId) +} + +async function readSleepWakeTerminalDebug( + page: Page, + worktreeId: string +): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return { + activeTabId: null, + activeWorktreeId: null, + tabs: [], + ptyIdsByTabId: {}, + ptyIdsByLeafIdByTabId: {} + } + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return { + activeTabId: state.activeTabId, + activeWorktreeId: state.activeWorktreeId, + tabs: tabs.map((tab) => ({ + id: tab.id, + ptyId: tab.ptyId, + generation: tab.generation, + pendingActivationSpawn: tab.pendingActivationSpawn + })), + ptyIdsByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.ptyIdsByTabId[tab.id] ?? []]) + ), + ptyIdsByLeafIdByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}]) + ) + } + }, worktreeId) +} + +async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise { + return page.evaluate( + async ({ targetPtyId, expectedText }) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 200 + }) + return snapshot?.data.includes(expectedText) ?? false + }, + { targetPtyId: ptyId, expectedText: text } + ) +} + +function richSleepWakePayload(runId: string): string { + const shortId = runId.slice(0, 8) + return [ + '\x1b[?2026h', + '\x1b[2J\x1b[H', + '╭────────────────────────────────────────────╮', + `│ sleep wake restore ${shortId} 😀 │`, + '├────────────┬───────────────┬───────────────┤', + '│ agent │ status │ output │', + '├────────────┼───────────────┼───────────────┤', + `│ codex-${shortId.slice(0, 4)} │ thinking │ box/table ok │`, + '│ opencode │ streaming │ unicode ✓ │', + '│ shell │ idle │ prompt ready │', + '╰────────────┴───────────────┴───────────────╯', + `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, + '\x1b[?2026l' + ].join('\r\n') +} + +function sleepWakeExpectedMarkers(runId: string): string[] { + return [ + `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, + 'box/table ok', + 'unicode ✓', + 'prompt ready' + ] +} + +function writeSleepWakePayloadScript(scriptPath: string, payload: string): void { + const encodedPayload = Buffer.from(payload, 'utf8').toString('base64') + writeFileSync( + scriptPath, + `process.stdout.write(Buffer.from(${JSON.stringify(encodedPayload)}, 'base64').toString('utf8'))\n`, + 'utf8' + ) +} + +test.describe('Terminal sleep wake restore', () => { + test('restores slept terminal output and accepts fresh input after wake', async ({ + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'sleep wake restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const restoreMarker = `SLEEP_WAKE_RESTORE_${runId}` + const freshMarker = `SLEEP_WAKE_FRESH_${runId}` + const expectedMarkers = sleepWakeExpectedMarkers(runId) + const scriptPath = path.join(testRepoPath, `.orca-sleep-wake-restore-${runId}.mjs`) + writeSleepWakePayloadScript(scriptPath, richSleepWakePayload(runId)) + try { + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000) + const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + for (const marker of expectedMarkers) { + expect(await mainSnapshotContains(orcaPage, ptyId, marker)).toBe(true) + } + + await switchToWorktree(orcaPage, firstWorktreeId) + await sleepWorktreeTerminals(orcaPage, secondWorktreeId) + const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + await expect + .poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), { + timeout: 10_000, + message: 'sleep did not release live PTYs for the background worktree' + }) + .toBe(0) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const awakePtyId = await waitForActivePanePtyId(orcaPage) + const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000) + for (const marker of expectedMarkers) { + expect + .soft(awakeTerminalContent.includes(marker), { + message: JSON.stringify( + { + missingMarker: marker, + ptyId, + awakePtyId, + beforeSleepDebug, + afterSleepDebug, + afterWakeDebug, + terminalTail: awakeTerminalContent.slice(-2000) + }, + null, + 2 + ) + }) + .toBe(true) + } + await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000) + await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`) + await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +})