import type { Terminal as XTerm } from "@xterm/xterm"; import { scrollTerminalToBottomIfNeeded, shouldScrollOnTerminalOutput, } from "../../../domain/terminalScroll"; import { logger } from "../../../lib/logger"; import type { Host, TerminalSettings } from "../../../types"; import type { TerminalSessionExitEvent } from "../../../application/state/resolveTerminalSessionExitIntent"; import { clearPasteResidualAfterTerminalWrite, prepareTerminalDataForUserPasteDisplay, } from "./terminalUserPaste"; import { detectTerminalCommandCompletions, findTerminalPromptSourceChunkVisibleStarts, prepareTerminalDataForPromptLineBreak, syncPromptLineBreakState, } from "./promptLineBreak"; import { createOutputFlowController, type OutputFlowController } from "./outputFlowController"; import type { TerminalSessionDataMeta, TerminalSessionStartersContext, } from "./createTerminalSessionStarters.types"; import { clearConnectionToken } from "./terminalDistroDetection"; import { resetTerminalLineTimestampRecordingState, type TerminalLineTimestampPerfStep, writeTerminalDataWithLineTimestamps, } from "./terminalLineTimestamps"; import { createTerminalOutputPerfTrace, logTerminalOutputPerf, type TerminalOutputPerfTrace, } from "./terminalPerformanceDiagnostics"; import { noteTerminalOutputPressureData, resetTerminalOutputPressure, setTerminalOutputPressureVisibility, shouldDegradeTerminalSideWork, } from "./terminalOutputPressure"; import { createSudoPasswordAutofill, type SudoPasswordAutofillCandidate, } from "./terminalSudoAutofill"; import { filterTerminalSessionData, isTerminalSyncBlockOpen, resetTerminalSyncBlockFilter, } from "./terminalSyncBlockFilter"; import { appendEraseScrollbackAfterFullErases } from "../clearTerminalViewport"; import { type CoalescedTerminalWriteOptions, enqueueCoalescedTerminalWrite, flushTerminalWriteCoalescer, getTerminalWriteCoalescerPendingBytes, resolveFloodCoalescerByteCap, setTerminalWriteCoalescerByteCapResolver, setTerminalWriteCoalescerFlushGate, shouldPreserveTerminalWriteFrameBatch, } from "./terminalWriteCoalescer"; import { accumulateDeferredTerminalWriteAck, clearDeferredTerminalWriteAck, getDeferredTerminalWriteAckBytes, resetDeferredTerminalWriteAck, scheduleDeferredTerminalWriteAckFlush, shouldDeferTerminalWriteCallback, } from "./terminalWriteAckDeferral"; import { FLOW_HIGH_WATER_MARK, FLOW_LOW_WATER_MARK, XTERM_WRITE_CALLBACK_BATCH_BYTES, XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES, } from "./terminalFlowConstants"; import { ackTerminalSessionFlow, flushTerminalSessionFlowAck, } from "./terminalFlowAckBuffer"; import { enqueueTerminalWrite, flushTerminalWriteQueueBypassingTimers, isTerminalWriteQueueInFloodMode, setTerminalWriteQueueDropHandler, } from "./terminalWriteQueue"; import { filterTerminalInterruptDisplayOutput, releaseTerminalFlowOutputForTerm, teardownTerminalOutputPipeline, } from "./terminalOutputPipeline"; import { hasPendingTerminalWrites, isTerminalPageHidden, maybeFlushTerminalWriteCoalescerWhenUnfocused, scheduleTerminalRepaintWhenUnfocused, shouldFlushTerminalWritesForBackgroundOutput, } from "./terminalUnfocusedRepaint"; export { FLOW_HIGH_WATER_MARK, FLOW_LOW_WATER_MARK }; export const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => { const env: Record = { TERM: terminalSettings?.terminalEmulationType ?? "xterm-256color", }; if (host.environmentVariables) { for (const { name, value } of host.environmentVariables) { if (name) env[name] = value; } } return env; }; const isTerminalPaneVisible = (ctx: TerminalSessionStartersContext): boolean => ( (ctx.isPaneVisibleRef?.current ?? ctx.isVisibleRef?.current) !== false ); /** * Visibility recorded for the output-pressure gates. A pane whose ref says * "visible" still cannot paint while the document is page-hidden, so record it * as background there too; otherwise bulk batches take the background flush * path but keyword-highlight still repainted the viewport per batch. */ const isTerminalOutputVisibleForPressure = (ctx: TerminalSessionStartersContext): boolean => ( isTerminalPaneVisible(ctx) && !isTerminalPageHidden() ); const handleTerminalOutputAutoScroll = ( ctx: TerminalSessionStartersContext, term: XTerm, ) => { const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings; if (!shouldScrollOnTerminalOutput(settings)) { return; } if (!isTerminalPaneVisible(ctx)) { notePendingOutputScrollIfEnabled(ctx); return; } scrollTerminalToBottomIfNeeded(term); }; export const notePendingOutputScrollIfEnabled = ( ctx: TerminalSessionStartersContext, ): void => { const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings; if (!shouldScrollOnTerminalOutput(settings)) return; if (ctx.pendingOutputScrollRef) { ctx.pendingOutputScrollRef.current = true; } }; const terminalFlowControllers = new WeakMap(); type TerminalSessionWriteOptions = CoalescedTerminalWriteOptions & { perfTrace?: TerminalOutputPerfTrace | null; timestampDate?: Date; }; const BACKGROUND_OUTPUT_FLUSH_MAX_PASSES = 64; // With microtask coalescing, idle drain is only a safety net for rAF TUI path // and any leftover queue work. Keep xterm on its public async write path here: // its private flushSync removes a chunk before parsing and can strand the // matching callback when parsing/rendering throws (notably on Herdr frames). const VISIBLE_WRITE_IDLE_FLUSH_MS = 24; const HIDDEN_PANE_DRAIN_MS = 160; const visibleWriteIdleFlushTimers = new WeakMap>(); const visibleWriteIdleFlushSettleChecks = new WeakSet(); const hiddenPaneDrainTimers = new WeakMap>(); const pendingTimestampSecondByTerm = new WeakMap(); type LineTimestampPerfTotals = { segmentCalls: number; segmentMs: number; dataSegments: number; timestampSegments: number; batchedWrites: number; segmentedWrites: number; fallbackWrites: number; writeCalls: number; timestamps: number; measureMs: number; markerMs: number; xtermWriteCallbackMs: number; parsedChars: number; measuredRows: number; }; const createLineTimestampPerfTotals = (): LineTimestampPerfTotals => ({ segmentCalls: 0, segmentMs: 0, dataSegments: 0, timestampSegments: 0, batchedWrites: 0, segmentedWrites: 0, fallbackWrites: 0, writeCalls: 0, timestamps: 0, measureMs: 0, markerMs: 0, xtermWriteCallbackMs: 0, parsedChars: 0, measuredRows: 0, }); const roundMs = (value: number): number => Number(value.toFixed(1)); const recordLineTimestampPerfStep = ( totals: LineTimestampPerfTotals, step: TerminalLineTimestampPerfStep, ): void => { if (step.kind === "segment") { totals.segmentCalls += 1; totals.segmentMs += step.durationMs; totals.dataSegments += step.dataSegmentCount; totals.timestampSegments += step.timestampSegmentCount; totals.parsedChars += step.parsedChars; return; } if (step.kind === "batched-write") { totals.batchedWrites += 1; totals.writeCalls += 1; totals.timestamps += step.timestamps; totals.measureMs += step.measureMs; totals.markerMs += step.markerMs; totals.xtermWriteCallbackMs += step.writeCallbackMs; totals.measuredRows += step.rowOffset; return; } if (step.kind === "segmented-write") { totals.segmentedWrites += 1; totals.writeCalls += step.writeCalls; totals.timestamps += step.timestamps; totals.xtermWriteCallbackMs += step.writeCallbackMs; return; } totals.fallbackWrites += 1; totals.writeCalls += 1; totals.xtermWriteCallbackMs += step.writeCallbackMs; }; const summarizeLineTimestampPerf = (totals: LineTimestampPerfTotals) => ({ segmentCalls: totals.segmentCalls, segmentMs: roundMs(totals.segmentMs), dataSegments: totals.dataSegments, timestampSegments: totals.timestampSegments, batchedWrites: totals.batchedWrites, segmentedWrites: totals.segmentedWrites, fallbackWrites: totals.fallbackWrites, writeCalls: totals.writeCalls, timestamps: totals.timestamps, measureMs: roundMs(totals.measureMs), markerMs: roundMs(totals.markerMs), xtermWriteCallbackMs: roundMs(totals.xtermWriteCallbackMs), parsedChars: totals.parsedChars, measuredRows: totals.measuredRows, }); const flushTerminalWritesForBackgroundOutput = (term: XTerm): void => { for (let pass = 0; pass < BACKGROUND_OUTPUT_FLUSH_MAX_PASSES; pass += 1) { if (!flushTerminalWriteQueueBypassingTimers(term)) { return; } } }; const cancelHiddenPaneDrain = (term: XTerm): void => { const timer = hiddenPaneDrainTimers.get(term); if (timer === undefined) return; clearTimeout(timer); hiddenPaneDrainTimers.delete(term); }; const flushPendingTerminalOutputNow = (term: XTerm): void => { cancelHiddenPaneDrain(term); flushTerminalWriteCoalescer(term); flushTerminalWritesForBackgroundOutput(term); }; const flushBeforeTimestampBoundary = ( term: XTerm, timestampDate: Date, ): void => { const timestampSecond = Math.floor(timestampDate.getTime() / 1000); const pendingTimestampSecond = pendingTimestampSecondByTerm.get(term); const hadPendingOutput = getTerminalWriteCoalescerPendingBytes(term) > 0; if ( hadPendingOutput && pendingTimestampSecond !== undefined && pendingTimestampSecond !== timestampSecond && !shouldPreserveTerminalWriteFrameBatch(term) ) { // Split arrival-time batches at the second boundary, but keep any queued // bulk slices on their cooperative yield schedule. flushTerminalWriteCoalescer(term); } pendingTimestampSecondByTerm.set(term, timestampSecond); }; function flushHiddenPaneWritesNow(term: XTerm, isPaneVisible: () => boolean): void { if (isPaneVisible()) return; flushTerminalWriteCoalescer(term); // Leave both the queue's cooperative yield timer and xterm's parser timer // intact so a large hidden burst cannot turn the backlog into one long task. if (!isPaneVisible() && hasPendingTerminalWrites(term)) { scheduleHiddenPaneDrain(term, isPaneVisible); } } function scheduleHiddenPaneDrain(term: XTerm, isPaneVisible: () => boolean): void { if (isPaneVisible()) return; if (hiddenPaneDrainTimers.has(term)) return; const timer = setTimeout(() => { hiddenPaneDrainTimers.delete(term); flushHiddenPaneWritesNow(term, isPaneVisible); }, HIDDEN_PANE_DRAIN_MS); if (typeof timer === "object" && "unref" in timer && typeof timer.unref === "function") { timer.unref(); } hiddenPaneDrainTimers.set(term, timer); } const cancelVisibleTerminalWriteIdleFlush = (term: XTerm): void => { const timer = visibleWriteIdleFlushTimers.get(term); if (timer === undefined) return; clearTimeout(timer); visibleWriteIdleFlushTimers.delete(term); }; const cancelVisibleTerminalWriteIdleFlushIfSettled = (term: XTerm): void => { if (!visibleWriteIdleFlushTimers.has(term)) return; if (!hasPendingTerminalWrites(term)) { cancelVisibleTerminalWriteIdleFlush(term); return; } // A synchronous xterm callback can run before the serial queue marks its // active item complete. Recheck once after the current queue turn unwinds. if (visibleWriteIdleFlushSettleChecks.has(term)) return; visibleWriteIdleFlushSettleChecks.add(term); queueMicrotask(() => { visibleWriteIdleFlushSettleChecks.delete(term); if (!hasPendingTerminalWrites(term)) { cancelVisibleTerminalWriteIdleFlush(term); } }); }; const scheduleVisibleTerminalWriteIdleFlush = (term: XTerm, isPaneVisible: () => boolean): void => { if (!isPaneVisible()) return; if (!hasPendingTerminalWrites(term)) { cancelVisibleTerminalWriteIdleFlush(term); return; } // This is a maximum wait, not an idle debounce. Sustained TUI output must // not postpone the safety drain forever. if (visibleWriteIdleFlushTimers.has(term)) return; const timer = setTimeout(() => { visibleWriteIdleFlushTimers.delete(term); if (!isPaneVisible()) { flushHiddenPaneWritesNow(term, isPaneVisible); return; } flushTerminalWriteCoalescer(term); flushTerminalWriteQueueBypassingTimers(term); if (hasPendingTerminalWrites(term) && isPaneVisible()) { scheduleVisibleTerminalWriteIdleFlush(term, isPaneVisible); } }, VISIBLE_WRITE_IDLE_FLUSH_MS); if (typeof timer === "object" && "unref" in timer && typeof timer.unref === "function") { timer.unref(); } visibleWriteIdleFlushTimers.set(term, timer); }; export const getFlowControllerForTerm = (term: XTerm): OutputFlowController | undefined => terminalFlowControllers.get(term); export const getFlowController = ( ctx: TerminalSessionStartersContext, term: XTerm, ): OutputFlowController => { let controller = terminalFlowControllers.get(term); if (!controller) { controller = createOutputFlowController({ highWaterMark: FLOW_HIGH_WATER_MARK, lowWaterMark: FLOW_LOW_WATER_MARK, onPause: () => { const id = ctx.sessionRef.current; if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, true); }, onResume: () => { const id = ctx.sessionRef.current; if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, false); }, }); terminalFlowControllers.set(term, controller); setTerminalWriteQueueDropHandler(term, (bytes) => { if (bytes <= 0) return; controller?.written(bytes); const sessionId = ctx.sessionRef.current; ackTerminalSessionFlow(ctx.terminalBackend, sessionId, bytes); if (sessionId) { flushTerminalSessionFlowAck(sessionId); } }); } setTerminalWriteCoalescerByteCapResolver(term, () => ( resolveFloodCoalescerByteCap( controller!.isPaused(), // Treat bulk/large-output pressure like queue flood so we stop packing // multi-MB seq dumps into a single microtask flush (UI freeze). isTerminalWriteQueueInFloodMode(term) || shouldDegradeTerminalSideWork(term), ) )); setTerminalWriteCoalescerFlushGate(term, () => isTerminalPaneVisible(ctx)); return controller; }; export const resetTerminalLineTimestampState = resetTerminalLineTimestampRecordingState; export const acknowledgeDroppedTerminalDisplayBytes = ( ctx: TerminalSessionStartersContext, bytes: number, ): void => { if (bytes <= 0) return; const sessionId = ctx.sessionRef.current; ackTerminalSessionFlow(ctx.terminalBackend, sessionId, bytes); if (sessionId) { flushTerminalSessionFlowAck(sessionId); ctx.terminalBackend.setSessionFlowPaused?.(sessionId, false); } }; /** Live host fields for write-path feature gates (prefer hostRef over frozen host). */ export const resolveLiveHostShowLineTimestamps = ( ctx: Pick, ): boolean => ( (ctx.hostRef?.current ?? ctx.host)?.showLineTimestamps === true ); export const writeTerminalLine = ( ctx: TerminalSessionStartersContext, term: XTerm, data: string, ) => { // Keep lifecycle/control lines ordered after all preceding PTY output. flushPendingTerminalOutputNow(term); const lineData = `${data}\r\n`; enqueueTerminalWrite(term, lineData.length, (done) => { ctx.onTerminalLogData?.(lineData); term.write(lineData, done); }); flushTerminalWritesForBackgroundOutput(term); }; export const writeSessionData = ( ctx: TerminalSessionStartersContext, term: XTerm, data: string, ingressBytes: number = data.length, meta?: TerminalSessionDataMeta, ) => { const flow = getFlowController(ctx, term); const isPaneCurrentlyVisible = () => isTerminalPaneVisible(ctx); const isPaneVisible = isPaneCurrentlyVisible(); const timestampDate = new Date(Date.now()); const usesBackgroundWritePath = shouldFlushTerminalWritesForBackgroundOutput(isPaneVisible); // Flush normal-screen output across an arrival-second boundary so every // line keeps its real timestamp. Alternate-screen repaints stay atomic. flushBeforeTimestampBoundary(term, timestampDate); const perfTrace = createTerminalOutputPerfTrace({ sessionId: ctx.sessionRef.current ?? ctx.sessionId, data, ingressBytes, meta, }); logTerminalOutputPerf("renderer-receive", perfTrace, { visible: isPaneVisible, }); flow.received(ingressBytes); setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx)); noteTerminalOutputPressureData(term, data); const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings; const preservePromptSourceChunks = Boolean( settings?.forcePromptNewLine && ctx.promptLineBreakStateRef?.current?.pendingCommand && ctx.promptLineBreakStateRef.current.lastPromptText, ); if (usesBackgroundWritePath) { const writeBackgroundOutputData = ( batch: string, batchIngress: number, writeOptions?: CoalescedTerminalWriteOptions, ): void => { // Output queued while hidden can be flushed after the pane is revealed. // Refresh the pressure visibility at flush time so the write callback // sees the live pane state instead of the ingress snapshot; otherwise a // bulk batch written after reveal stays on the fully deferred path and // the viewport sits uncolored until the quiet catch-up (#3272). setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx)); writeSessionDataImmediate(ctx, term, batch, batchIngress, { ...writeOptions, deferStart: writeOptions?.deferStart ?? !isPaneCurrentlyVisible(), perfTrace: writeOptions?.preservePerfTrace === false ? null : perfTrace, timestampDate, }); if (isPaneCurrentlyVisible()) { flushTerminalWritesForBackgroundOutput(term); } }; if (isPaneVisible) { flushTerminalWriteCoalescer(term, writeBackgroundOutputData); flushTerminalWritesForBackgroundOutput(term); } enqueueCoalescedTerminalWrite( term, data, writeBackgroundOutputData, ingressBytes, { preserveSourceChunkBoundaries: preservePromptSourceChunks }, ); if (isPaneVisible) { flushTerminalWriteCoalescer(term, writeBackgroundOutputData); flushTerminalWritesForBackgroundOutput(term); } else { scheduleHiddenPaneDrain(term, isPaneCurrentlyVisible); } return; } enqueueCoalescedTerminalWrite(term, data, (batch, batchIngress, writeOptions) => { writeSessionDataImmediate(ctx, term, batch, batchIngress, { ...writeOptions, perfTrace: writeOptions?.preservePerfTrace === false ? null : perfTrace, timestampDate, }); }, ingressBytes, { preserveSourceChunkBoundaries: preservePromptSourceChunks }); scheduleVisibleTerminalWriteIdleFlush(term, isPaneCurrentlyVisible); scheduleHiddenPaneDrain(term, isPaneCurrentlyVisible); maybeFlushTerminalWriteCoalescerWhenUnfocused( term, isPaneVisible, ); }; /** True when a batch has no ESC/C1 CSI — safe to skip TUI/filter transforms. */ const isPlainTerminalDisplayData = (data: string): boolean => !data.includes("\x1b") && !data.includes("\x9b"); const writeSessionDataImmediate = ( ctx: TerminalSessionStartersContext, term: XTerm, data: string, ingressBytes: number = data.length, writeOptions: TerminalSessionWriteOptions = {}, ) => { const flow = getFlowController(ctx, term); // Tabby-like: under bulk pressure, force a yield after sizable shards so the // event loop can paint/input between xterm parses (serial queue otherwise // chains the next write the moment the callback fires). const displayBytes = data.length; const bulkYieldAfter = shouldDegradeTerminalSideWork(term) && displayBytes >= XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES; enqueueTerminalWrite(term, displayBytes, (done) => { const shouldMeasurePerf = Boolean(writeOptions.perfTrace); const queueItemStartedAt = shouldMeasurePerf ? performance.now() : 0; const prepareStartedAt = shouldMeasurePerf ? performance.now() : 0; const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings; const forcePromptNewLine = settings?.forcePromptNewLine ?? false; const promptLineBreakState = ctx.promptLineBreakStateRef?.current; // Always run filter + paste bookkeeping (stateful). Bulk-plain only skips // erase-scrollback / prompt cosmetics when the *post-paste* stream is still // plain and forcePromptNewLine is off (Codex: long paste cleanup must run). // Capture open sync state before this chunk is filtered so a delayed // full-redraw `\x1b[2J` (without a co-chunk `\x1b[?2026h`) still skips // scrollback wipe — see #2291 / Codex review on bare 2J + 3J. const startInDec2026SyncBlock = isTerminalSyncBlockOpen(term); const filteredData = filterTerminalSessionData(term, data); const afterErase = appendEraseScrollbackAfterFullErases(filteredData, { wipeScrollback: settings?.clearWipesScrollback ?? true, normalScreen: term.buffer?.active?.type !== "alternate", startInDec2026SyncBlock, }); const pasteDisplayData = prepareTerminalDataForUserPasteDisplay(term, afterErase); // Prompt indices must match the string passed to prepare… — source-chunk // boundaries are only valid when display transforms are identity. const promptSourceBoundaries = pasteDisplayData === data ? writeOptions.sourceChunkBoundaries : undefined; const promptVisibleStarts = ( forcePromptNewLine && promptLineBreakState?.pendingCommand ? findTerminalPromptSourceChunkVisibleStarts( pasteDisplayData, promptLineBreakState.lastPromptText, promptSourceBoundaries, ) : [] ); const bulkPlainPath = shouldDegradeTerminalSideWork(term) && isPlainTerminalDisplayData(pasteDisplayData) && !forcePromptNewLine; let preparedDisplayData: string; let prepareMs = 0; if (bulkPlainPath) { preparedDisplayData = pasteDisplayData; prepareMs = shouldMeasurePerf ? performance.now() - prepareStartedAt : 0; } else { if (!forcePromptNewLine && ctx.promptLineBreakStateRef?.current) { ctx.promptLineBreakStateRef.current.pendingCommand = false; ctx.promptLineBreakStateRef.current.suppressNextPromptCache = false; } preparedDisplayData = prepareTerminalDataForPromptLineBreak( term, pasteDisplayData, promptLineBreakState, forcePromptNewLine, promptVisibleStarts, ); prepareMs = shouldMeasurePerf ? performance.now() - prepareStartedAt : 0; } ctx.onTerminalLogData?.(pasteDisplayData); const clearPasteResidualAndCapture = () => { const cleanupData = clearPasteResidualAfterTerminalWrite(term); if (cleanupData) { ctx.onTerminalLogData?.(cleanupData); } }; const syncPrompt = () => { if (bulkPlainPath) return; if (forcePromptNewLine) { syncPromptLineBreakState(term, ctx.promptLineBreakStateRef?.current); } }; const publishCommandCompletion = () => { const completed = detectTerminalCommandCompletions( term, ctx.promptLineBreakStateRef?.current, ); for (let index = 0; index < completed; index += 1) { ctx.onCommandCompleted?.(); } }; const finishQueueItem = () => { clearPasteResidualAndCapture(); syncPrompt(); publishCommandCompletion(); if (shouldScrollOnTerminalOutput(settings)) { handleTerminalOutputAutoScroll(ctx, term); } if (isTerminalPaneVisible(ctx)) { // Unfocused-but-visible windows have no rAF-driven render; this // debounced sync repaint is the only path that updates pixels (#1761). scheduleTerminalRepaintWhenUnfocused(term); } done(); // A completed frame ends this safety-deadline generation. Without this, // a later frame can inherit the old deadline and be split before its rAF. cancelVisibleTerminalWriteIdleFlushIfSettled(term); }; const commitIpcAck = (ackedBytes: number) => { if (ackedBytes <= 0) return; ackTerminalSessionFlow(ctx.terminalBackend, ctx.sessionRef.current, ackedBytes); }; const flushIpcAck = (ackedBytes: number) => { commitIpcAck(ackedBytes); flushTerminalSessionFlowAck(ctx.sessionRef.current); }; const flushDeferredIpcAck = () => { flushIpcAck(clearDeferredTerminalWriteAck(term)); }; const deferredBeforeWrite = getDeferredTerminalWriteAckBytes(term); const deferFlowAck = !forcePromptNewLine && shouldDeferTerminalWriteCallback( preparedDisplayData.length, deferredBeforeWrite, ingressBytes, XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES, XTERM_WRITE_CALLBACK_BATCH_BYTES, ); const writePreparedDisplayData = (callback: () => void): void => { const lineTimestampPerf = shouldMeasurePerf ? createLineTimestampPerfTotals() : null; const writeStartedAt = shouldMeasurePerf ? performance.now() : 0; let completed = false; const finishWrite = () => { if (completed) return; completed = true; if (shouldMeasurePerf && lineTimestampPerf) { const now = performance.now(); logTerminalOutputPerf("renderer-write-done", writeOptions.perfTrace, { batchChars: data.length, preparedChars: preparedDisplayData.length, ingressBytes, prepareMs: roundMs(prepareMs), writeMs: roundMs(now - writeStartedAt), totalMs: roundMs(now - queueItemStartedAt), deferredAck: deferFlowAck, lineTimestamps: summarizeLineTimestampPerf(lineTimestampPerf), bulkPlainPath, }); } callback(); }; // Per-second ledger always records (record/render split); true flood still // skips via shouldSkipTerminalLineTimestamps. Sparse reflow anchors ≤1/s. writeTerminalDataWithLineTimestamps( term, preparedDisplayData, finishWrite, { ...(shouldMeasurePerf && lineTimestampPerf ? { onStep: (step: TerminalLineTimestampPerfStep) => recordLineTimestampPerfStep(lineTimestampPerf, step) } : {}), timestampDate: writeOptions.timestampDate, // hostRef: live gutter toggle for call-site compatibility (recording // itself is always on; paint is gated by gutter UI). enabled: resolveLiveHostShowLineTimestamps(ctx), }, ); }; if (deferFlowAck) { writePreparedDisplayData(() => { finishQueueItem(); flow.written(ingressBytes); const deferredTotal = accumulateDeferredTerminalWriteAck(term, ingressBytes); if (deferredTotal >= XTERM_WRITE_CALLBACK_BATCH_BYTES) { flushDeferredIpcAck(); } else { scheduleDeferredTerminalWriteAckFlush(term, flushIpcAck); } }); return; } const deferredBeforeCallback = clearDeferredTerminalWriteAck(term); const ackOnCallback = deferredBeforeCallback + ingressBytes; writePreparedDisplayData(() => { finishQueueItem(); flow.written(ingressBytes); if (deferredBeforeCallback > 0) { flushIpcAck(ackOnCallback); } else { flushIpcAck(ackOnCallback); } }); }, { dropBytes: ingressBytes, deferStart: writeOptions.deferStart, // Intermediate plain shards set yieldAfter via writeLargeTerminalBatch; // bulk pressure also yields after sizable items (Tabby FlowControl intent). yieldAfter: writeOptions.yieldAfter === true || bulkYieldAfter, }); }; export const isTerminalBootActive = (ctx: TerminalSessionStartersContext): boolean => !ctx.isBootActiveRef || ctx.isBootActiveRef.current; /** Capture the current boot epoch so a later reconnect cannot revive this attempt. */ export const createBootAttemptGuard = ( ctx: TerminalSessionStartersContext, ): (() => boolean) => { const epoch = ctx.bootEpochRef?.current ?? 0; return () => isTerminalBootActive(ctx) && (ctx.bootEpochRef?.current ?? 0) === epoch; }; export const closeOrphanBackendSession = ( ctx: TerminalSessionStartersContext, sessionBackendId: string, opts?: { bootEpoch?: number }, ) => { try { const closeResult = ctx.terminalBackend.closeSession(sessionBackendId, opts); void Promise.resolve(closeResult).catch((err) => { logger.warn("Failed to close orphan session after terminal unmount", err); }); } catch (err) { logger.warn("Failed to close orphan session after terminal unmount", err); } }; export const tryAttachSessionToTerminal = ( ctx: TerminalSessionStartersContext, term: XTerm, id: string, opts?: { onExitMessage?: (evt: TerminalSessionExitEvent) => string; onConnected?: (meta?: TerminalSessionDataMeta) => void; onExit?: (evt: TerminalSessionExitEvent) => void; requireExplicitConnectionReady?: boolean; deferConnectionDuringMoshHandshake?: boolean; convertLfToCrlf?: boolean; sudoAutofillPassword?: string; sudoAutofillCandidates?: SudoPasswordAutofillCandidate[]; isCurrentAttempt?: () => boolean; bootEpoch?: number; }, ): boolean => { if (!isTerminalBootActive(ctx) || opts?.isCurrentAttempt?.() === false) { // Pass the attempt bootEpoch so closeSession no-ops when a newer boot // already owns this shared sessionId in the main-process registry. closeOrphanBackendSession(ctx, id, { bootEpoch: opts?.bootEpoch }); return false; } attachSessionToTerminal(ctx, term, id, opts); return true; }; export const releaseTerminalFlowBeforeHibernate = ( backend: TerminalSessionStartersContext["terminalBackend"], term: XTerm, sessionId: string, options?: { resumeBackend?: boolean }, ): void => { const flow = terminalFlowControllers.get(term); flushPendingTerminalOutputNow(term); releaseTerminalFlowOutputForTerm(term, backend, sessionId, flow, options); setTerminalWriteCoalescerByteCapResolver(term); setTerminalWriteCoalescerFlushGate(term); pendingTimestampSecondByTerm.delete(term); resetDeferredTerminalWriteAck(term); terminalFlowControllers.delete(term); }; export const resolveAttachSnapshot = ( finalSnapshot: unknown, fallbackSnapshot: string, ): string => (typeof finalSnapshot === "string" ? finalSnapshot : fallbackSnapshot); export const detachSessionDataListeners = ( ctx: TerminalSessionStartersContext, term: XTerm, ) => { const sessionId = ctx.sessionRef.current; if (sessionId && term) { releaseTerminalFlowBeforeHibernate(ctx.terminalBackend, term, sessionId); } ctx.disposeDataRef.current?.(); ctx.disposeDataRef.current = null; ctx.disposeExitRef.current?.(); ctx.disposeExitRef.current = null; }; export const attachSessionToTerminal = ( ctx: TerminalSessionStartersContext, term: XTerm, id: string, opts?: { onExitMessage?: (evt: TerminalSessionExitEvent) => string; onConnected?: (meta?: TerminalSessionDataMeta) => void; onExit?: (evt: TerminalSessionExitEvent) => void; requireExplicitConnectionReady?: boolean; deferConnectionDuringMoshHandshake?: boolean; convertLfToCrlf?: boolean; sudoAutofillPassword?: string; sudoAutofillCandidates?: SudoPasswordAutofillCandidate[]; }, ) => { if (!isTerminalBootActive(ctx)) { closeOrphanBackendSession(ctx, id, { bootEpoch: ctx.bootEpochRef?.current }); return; } flushPendingTerminalOutputNow(term); pendingTimestampSecondByTerm.delete(term); ctx.sessionRef.current = id; const flow = getFlowController(ctx, term); teardownTerminalOutputPipeline(ctx, term, id, flow); flushTerminalWriteCoalescer(term); resetTerminalSyncBlockFilter(term); // Reattach keeps the xterm buffer (reconnect preserves scrollback), so only // reset per-session recording state — a full reset would strip timestamps // from preserved history. resetTerminalLineTimestampRecordingState(term); resetTerminalOutputPressure(term); ctx.onSessionAttached?.(id); const assistMode = ctx.terminalSettingsRef?.current?.passwordPromptAssist ?? ctx.terminalSettings?.passwordPromptAssist ?? "hint"; const candidates = opts?.sudoAutofillCandidates ?? ctx.sudoAutofillCandidatesRef?.current ?? ctx.sudoAutofillCandidates ?? []; const password = opts?.sudoAutofillPassword ?? ctx.sudoAutofillPasswordRef?.current ?? ctx.sudoAutofillPassword; const sudoAutofill = createSudoPasswordAutofill({ mode: assistMode, password, candidates, write: (data) => ctx.terminalBackend.writeToSession(id, data, { automated: true, sensitive: true }), onHint: (active) => ctx.onSudoHint?.(active) ?? false, onPicker: (active, state) => ctx.onPasswordPromptPicker?.(active, state) ?? false, }); if (ctx.sudoAutofillRef) { ctx.sudoAutofillRef.current = sudoAutofill; } const markConnectedOnFirstOutput = (meta?: TerminalSessionDataMeta) => { const pluginConnectionReady = meta?.pluginConnectionReady === true; if (opts?.requireExplicitConnectionReady === true && !pluginConnectionReady) return; if ( opts?.deferConnectionDuringMoshHandshake === true && meta?.moshHandshake === true && meta?.moshHandshakeRequiresUserInput !== true ) { return; } if (ctx.hasConnectedRef.current && !pluginConnectionReady) return; if (!ctx.hasConnectedRef.current) { ctx.updateStatus("connected"); setTimeout(() => { if (ctx.isVisibleRef?.current === false) { notePendingOutputScrollIfEnabled(ctx); return; } if (!ctx.fitAddonRef.current) return; try { ctx.fitAddonRef.current.fit(); if (ctx.sessionRef.current) { ctx.terminalBackend.resizeSession(ctx.sessionRef.current, term.cols, term.rows); } } catch (err) { logger.warn("Post-connect fit failed", err); } }, 100); } opts?.onConnected?.(meta); }; ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData( id, (chunk, meta) => { if (typeof meta?.pluginPipelineSensitiveInput === "boolean" && ctx.passwordPromptActiveRef) { ctx.passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput; } const filtered = filterTerminalInterruptDisplayOutput(term, chunk); const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes) ? Math.max(0, Number(meta?.pluginPipelineIngressBytes)) : null; if (filtered.accepted && !filtered.data && pluginPipelineIngressBytes != null) { markConnectedOnFirstOutput(meta); if (typeof meta?.pluginPipelineSensitiveInput === "boolean") { ctx.onTerminalOutput?.("", meta); } acknowledgeDroppedTerminalDisplayBytes(ctx, pluginPipelineIngressBytes); return; } acknowledgeDroppedTerminalDisplayBytes( ctx, !filtered.accepted && pluginPipelineIngressBytes != null ? pluginPipelineIngressBytes : pluginPipelineIngressBytes != null ? 0 : filtered.droppedBytes, ); if (!filtered.accepted) return; const ingressBytes = pluginPipelineIngressBytes ?? filtered.acceptedBytes ?? filtered.data.length; let data = filtered.data; if (opts?.convertLfToCrlf) { data = data.replace(/(? { // The backend is already gone. In particular, an observe popup must not // run its normal pause/snapshot/restore handoff while closing afterward. if (ctx.sessionRef.current === id) { ctx.sessionRef.current = null; } ctx.updateStatus("disconnected"); if (evt.error) { ctx.setError(evt.error); } const exitMessage = opts?.onExitMessage?.(evt) ?? "\r\n[session closed]"; writeTerminalLine(ctx, term, exitMessage); if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) { try { await ctx.prepareKeywordHighlightSerialization?.(); const terminalData = ctx.serializeAddonRef.current.serialize(); ctx.onTerminalDataCapture(ctx.sessionId, terminalData); } catch (err) { logger.warn("Failed to serialize terminal data:", err); } } clearConnectionToken(ctx.sessionId); opts?.onExit?.(evt); if (ctx.sudoAutofillRef) { ctx.sudoAutofillRef.current = null; } ctx.onSessionExit?.(ctx.sessionId, evt); }); };