1018 lines
36 KiB
TypeScript
1018 lines
36 KiB
TypeScript
|
|
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<string, string> = {
|
||
|
|
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<XTerm, OutputFlowController>();
|
||
|
|
|
||
|
|
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<XTerm, ReturnType<typeof setTimeout>>();
|
||
|
|
const visibleWriteIdleFlushSettleChecks = new WeakSet<XTerm>();
|
||
|
|
const hiddenPaneDrainTimers = new WeakMap<XTerm, ReturnType<typeof setTimeout>>();
|
||
|
|
const pendingTimestampSecondByTerm = new WeakMap<XTerm, number>();
|
||
|
|
|
||
|
|
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<TerminalSessionStartersContext, "host" | "hostRef">,
|
||
|
|
): 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(/(?<!\r)\n/g, "\r\n");
|
||
|
|
}
|
||
|
|
data = sudoAutofill?.handleOutput(data) ?? data;
|
||
|
|
writeSessionData(ctx, term, data, ingressBytes, meta);
|
||
|
|
ctx.onTerminalOutput?.(data, meta);
|
||
|
|
// Ordinary transports connect on first visible output. Mosh defers that
|
||
|
|
// transition until ready unless its SSH bootstrap explicitly reports
|
||
|
|
// that it is blocked on input Netcatty could not answer automatically.
|
||
|
|
markConnectedOnFirstOutput(meta);
|
||
|
|
},
|
||
|
|
{ replayBacklog: true },
|
||
|
|
);
|
||
|
|
ctx.terminalBackend.notifyTerminalSessionDisplayReady?.(id);
|
||
|
|
|
||
|
|
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
|
||
|
|
// 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);
|
||
|
|
});
|
||
|
|
};
|