Compare commits

...
Author SHA1 Message Date
netcatty-bot 0682f36c8f fix: address Codex review on PR #2875 2026-08-10 15:44:33 +00:00
bincxz 62d82a1264 fix(session-logs): seed replay sanitizer on observe attach mid-TUI
When an observe popup restores a snapshot while vim/less is already on
the alternate buffer, re-seed the connection-log sanitizer from the
restored xterm active-buffer type so subsequent TUI redraws are omitted
until leave.
2026-08-10 23:22:19 +08:00
bincxz e89e8841ed fix(session-logs): token-bind dialog path and rebase after RIS
- chooseManualSessionLogPath stores the selected path in main and returns
  an opaque selectionToken; startManualSessionLog redeems that token and
  rejects renderer-supplied filePath so the save dialog cannot be bypassed.
- RIS rebases screenBaseRow onto a fresh log screen (history preserved)
  so post-reset CSI H cannot overwrite pre-RIS text.
2026-08-10 23:13:10 +08:00
bincxz d1591ccf5b fix(session-logs): apply log-safe RIS reset in text renderer
RIS now leaves alternate-screen mode, resets SGR to defaults, and starts
post-reset shell text on a new log line when needed so mid-line TUI
exits do not glue onto prior output or keep pre-entry colors. History is
preserved because session logs are not live screen wipes.
2026-08-10 23:05:54 +08:00
bincxz 62642ff59a fix(session-logs): honor C1 CSI alternate-screen modes in text renderer
8-bit CSI (U+009B) is a valid alternate form of ESC[. Recognize it in
TerminalTextRenderer so DECSET 47/1047/1049 enter/leave still omits TUI
paint from txt/HTML session logs when remotes use the C1 encoding.
2026-08-10 23:00:41 +08:00
bincxz 4af9de15e6 fix(session-logs): sample alternate-screen state after save dialog
Manual session log start previously snapshotted alternate-screen mode
and the initial line before showSaveDialog resolved. Split path selection
from stream start so the renderer re-samples buffer state at activation,
avoiding stale omission/leak if the user enters or leaves a TUI while the
dialog is open.
2026-08-10 22:59:11 +08:00
netcatty-bot d678b9c623 fix: address Codex review on PR #2875 2026-08-10 14:49:20 +00:00
netcatty-bot 47d5f1aad3 fix: address Codex review on PR #2875 2026-08-10 14:19:08 +00:00
netcatty-bot c502096a0d fix(#2872): automated Cursor CLI fix 2026-08-10 13:32:03 +00:00
15 changed files with 769 additions and 55 deletions

No files matched your search

+25 -1
View File
@@ -1,13 +1,26 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
type ManualChoosePathPayload = {
sessionId: string;
sessionName?: string;
preferredDirectory?: string;
format?: "txt" | "raw" | "html";
};
type ManualStartPayload = {
sessionId: string;
sessionName?: string;
preferredDirectory?: string;
/**
* Opaque token from chooseManualSessionLogPath. Required for the post-dialog
* second phase; raw file paths from the renderer are rejected.
*/
selectionToken?: string;
format?: "txt" | "raw" | "html";
timestampsEnabled?: boolean;
initialLine?: string;
alternateScreenActive?: boolean;
};
type ManualStopPayload = {
@@ -19,6 +32,12 @@ type ManualStatusPayload = {
};
export const useSessionLogBackend = () => {
const chooseManualSessionLogPath = useCallback(async (payload: ManualChoosePathPayload) => {
const bridge = netcattyBridge.get();
return bridge?.chooseManualSessionLogPath?.(payload)
?? { success: false, canceled: false, error: "Session log bridge unavailable" };
}, []);
const startManualSessionLog = useCallback(async (payload: ManualStartPayload) => {
const bridge = netcattyBridge.get();
return bridge?.startManualSessionLog?.(payload) ?? { success: false, started: false, error: "Session log bridge unavailable" };
@@ -34,5 +53,10 @@ export const useSessionLogBackend = () => {
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
}, []);
return { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus };
return {
chooseManualSessionLogPath,
startManualSessionLog,
stopManualSessionLog,
getManualSessionLogStatus,
};
};
+26 -2
View File
@@ -775,7 +775,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus } = useSessionLogBackend();
const {
chooseManualSessionLogPath,
startManualSessionLog,
stopManualSessionLog,
getManualSessionLogStatus,
} = useSessionLogBackend();
const {
resizeSession,
receiveSerialYmodem,
@@ -3560,13 +3565,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
const startResult = await startManualSessionLog({
// Choose the destination first, then re-sample terminal state. The save
// dialog can stay open long enough for the user to enter/leave vim, so
// alternate-screen / initial-line must be captured at stream activation.
const chooseResult = await chooseManualSessionLogPath({
sessionId: currentSessionId,
sessionName: host.label || host.hostname || currentSessionId,
preferredDirectory: sessionLog?.directory,
format: sessionLog?.format,
});
if (!chooseResult?.success) {
toast.error(chooseResult?.error || "Failed to start session log");
return;
}
if (chooseResult.canceled || !chooseResult.selectionToken) return;
const startResult = await startManualSessionLog({
sessionId: currentSessionId,
sessionName: host.label || host.hostname || currentSessionId,
selectionToken: chooseResult.selectionToken,
format: sessionLog?.format,
timestampsEnabled: sessionLog?.timestampsEnabled,
initialLine: termRef.current ? getSessionLogInitialLine(termRef.current) : "",
alternateScreenActive: termRef.current
? isTerminalAlternateScreenActive(termRef.current)
: false,
});
if (startResult?.success) {
if (!startResult?.started && startResult?.canceled) return;
@@ -3579,6 +3602,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
toast.error("Failed to toggle session log");
}
}, [
chooseManualSessionLogPath,
getManualSessionLogStatus,
host.hostname,
host.label,
@@ -185,14 +185,18 @@ test("pending cursor-home lookahead controls are bounded before clear", () => {
assert.equal(log.includes("\x1b[31m"), false);
});
test("alternate-screen entry protects preserved replay history", () => {
test("alternate-screen body is omitted from replay-safe connection logs", () => {
const log = createReplaySafeTerminalLog(
"before\n\x1b[?1049h\x1b[Hvim screen\n\x1b[?1049lafter\n"
"before\n\x1b[?1049h\x1b[Hvim screen\n~\n\"file\" 0L, 0B\x1b[?1049lafter\n"
+ "\x1b[?47h\x1b[10;5Htop screen\n\x1b[?47l"
+ "\x1b[?25lcursor hidden\n",
);
assert.equal(log, "before\n\r\nvim screen\nafter\n\r\ntop screen\n\x1b[?25lcursor hidden\n");
assert.equal(log, "before\nafter\n\x1b[?25lcursor hidden\n");
assert.equal(log.includes("vim screen"), false);
assert.equal(log.includes("~"), false);
assert.equal(log.includes("0L, 0B"), false);
assert.equal(log.includes("top screen"), false);
assert.equal(log.includes("\x1b[?1049h"), false);
assert.equal(log.includes("\x1b[?1049l"), false);
assert.equal(log.includes("\x1b[?47h"), false);
@@ -202,6 +206,49 @@ test("alternate-screen entry protects preserved replay history", () => {
assert.equal(log.includes("\x1b[?25l"), true);
});
test("alternate-screen omit tracks enter/leave split across chunks", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const log = sanitizer.append("$ vim\n\x1b[?1049")
+ sanitizer.append("h~\nstatus\x1b[?1049")
+ sanitizer.append("l$ ")
+ sanitizer.finish();
assert.equal(log, "$ vim\n$ ");
assert.equal(log.includes("~"), false);
assert.equal(log.includes("status"), false);
});
test("alternate-screen omit does not use plain-text fast path between chunks", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const log = sanitizer.append("\x1b[?1049h")
+ sanitizer.append("~\nstatus")
+ sanitizer.append("\x1b[?1049l")
+ sanitizer.finish();
assert.equal(log, "");
assert.equal(log.includes("~"), false);
assert.equal(log.includes("status"), false);
});
test("seeded alternate-screen sanitizer omits TUI paint until leave", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer({ alternateScreenActive: true });
const log = sanitizer.append("~\nstatus")
+ sanitizer.append("\x1b[?1049l$ done")
+ sanitizer.finish();
assert.equal(log, "$ done");
assert.equal(log.includes("~"), false);
assert.equal(log.includes("status"), false);
});
test("pending cursor-home is discarded when entering alternate screen", () => {
const log = createReplaySafeTerminalLog("before\n\x1b[H\x1b[?1049hTUI\x1b[?1049lafter");
assert.equal(log, "before\nafter");
assert.equal(log.includes("\x1b[H"), false);
assert.equal(log.includes("TUI"), false);
});
test("dec cursor save mode is not treated as alternate screen", () => {
const log = createReplaySafeTerminalLog("before\n\x1b[?1048h\x1b[?1048l\x1b[10;5Hpositioned\n");
@@ -225,6 +272,26 @@ test("terminal reset controls are dropped from replay data", () => {
assert.equal(log.includes("\x1bc"), false);
});
test("RIS during alternate screen restores logging of later shell output", () => {
const log = createReplaySafeTerminalLog("before\n\x1b[?1049hTUI\x1bcshell-after\n");
assert.equal(log, "before\n\r\nshell-after\n");
assert.equal(log.includes("TUI"), false);
assert.equal(log.includes("\x1bc"), false);
});
test("split RIS during alternate screen restores logging of later shell output", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const log = sanitizer.append("before\n\x1b[?1049hTUI\x1b")
+ sanitizer.append("cshell-after\n")
+ sanitizer.finish();
assert.equal(log, "before\n\r\nshell-after\n");
assert.equal(log.includes("TUI"), false);
assert.equal(log.includes("\x1bc"), false);
});
test("split terminal reset controls are dropped from replay data", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
+46 -4
View File
@@ -13,6 +13,11 @@ const MAX_PENDING_ESCAPE_CHARS = 4096;
type ControlStringMode = "osc" | "string";
export type ReplaySafeTerminalLogSanitizerOptions = {
/** Seed when attaching mid-TUI so live redraws stay omitted until leave. */
alternateScreenActive?: boolean;
};
export interface ReplaySafeTerminalLogSanitizer {
append(input: string): string;
finish(): string;
@@ -175,9 +180,14 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
private discardingCsi = false;
private inClearCluster = false;
private protectingClearedHistory = false;
private alternateScreenActive: boolean;
private hasOutput = false;
private lastOutputChar = "";
constructor(options: ReplaySafeTerminalLogSanitizerOptions = {}) {
this.alternateScreenActive = options.alternateScreenActive === true;
}
append(input: string): string {
let output = "";
const data = this.pendingInput + input;
@@ -191,7 +201,8 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
};
if (
!this.pendingCursorHome
!this.alternateScreenActive
&& !this.pendingCursorHome
&& !this.discardingCsi
&& !this.controlStringMode
&& !hasReplayControlCandidate(data)
@@ -260,13 +271,22 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
if (sequence) {
const alternateScreenMode = getAlternateScreenMode(sequence);
if (alternateScreenMode) {
this.alternateScreenActive = alternateScreenMode === "enter";
if (alternateScreenMode === "enter") {
emitClearSeparator(false);
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
}
i = sequence.end;
continue;
}
if (this.alternateScreenActive) {
i = sequence.end;
continue;
}
if (isCursorHomeSequence(sequence)) {
if (!this.inClearCluster) {
if (!this.pendingCursorHome) {
@@ -319,6 +339,25 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
continue;
}
if (this.alternateScreenActive) {
if (data[i] === ESC && i + 1 >= data.length) {
this.setPendingEscapeInput(data.slice(i));
break;
}
// RIS (ESC c) fully resets the terminal, including leaving the
// alternate screen. Process it before the omit-scan skip so later
// shell output is not dropped from the connection log.
if (data[i] === ESC && data[i + 1] === "c") {
this.alternateScreenActive = false;
emitClearSeparator(false);
i += 2;
continue;
}
const nextControl = nextReplayControlCandidate(data, i + 1);
i = nextControl === -1 ? data.length : nextControl;
continue;
}
if (data[i] === ESC) {
if (i + 1 >= data.length) {
this.setPendingEscapeInput(data.slice(i));
@@ -326,6 +365,7 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
}
if (data[i + 1] === "c") {
this.alternateScreenActive = false;
emitClearSeparator(false);
i += 2;
continue;
@@ -448,8 +488,10 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
}
}
export const createReplaySafeTerminalLogSanitizer = (): ReplaySafeTerminalLogSanitizer =>
new ReplaySafeTerminalLogSanitizerImpl();
export const createReplaySafeTerminalLogSanitizer = (
options: ReplaySafeTerminalLogSanitizerOptions = {},
): ReplaySafeTerminalLogSanitizer =>
new ReplaySafeTerminalLogSanitizerImpl(options);
/**
* Convert terminal output into a form that can be replayed in LogView without
@@ -3,9 +3,15 @@ import test from "node:test";
import { getSessionLogInitialLine } from "./sessionLogInitialLine.ts";
const makeTerm = (rows: Array<{ text: string; isWrapped?: boolean }>, cursorY: number, cursorX: number) => ({
const makeTerm = (
rows: Array<{ text: string; isWrapped?: boolean }>,
cursorY: number,
cursorX: number,
options: { type?: "normal" | "alternate" } = {},
) => ({
buffer: {
active: {
type: options.type ?? "normal",
baseY: 0,
cursorY,
cursorX,
@@ -37,3 +43,17 @@ test("getSessionLogInitialLine falls back to nearest trusted prompt line", () =>
assert.equal(getSessionLogInitialLine(term), "root@MyNAS:~# ");
});
test("getSessionLogInitialLine skips alternate-screen vim tilde rows", () => {
const term = makeTerm(
[
{ text: "~" },
{ text: "\"file.txt\" 0L, 0B" },
],
0,
1,
{ type: "alternate" },
);
assert.equal(getSessionLogInitialLine(term), "");
});
+4 -1
View File
@@ -12,7 +12,7 @@ type ActiveBufferLike = {
export type TerminalSessionLogInitialLineSource = {
buffer: {
active: ActiveBufferLike;
active: ActiveBufferLike & { type?: string };
};
};
@@ -26,6 +26,9 @@ const CMD_PROMPT_RE = /^[A-Za-z]:\\.*>$/;
export function getSessionLogInitialLine(term: TerminalSessionLogInitialLineSource): string {
const buffer = term.buffer.active;
if (buffer.type === "alternate") {
return "";
}
const cursorY = buffer.cursorY + buffer.baseY;
const currentPrefix = getLogicalLinePrefix(buffer, cursorY, buffer.cursorX);
@@ -657,6 +657,12 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
logger.warn("Failed to write attach snapshot to popup terminal", writeErr);
}
}
// Snapshot was applied with term.write(), not the connection-log
// sanitizer. Re-seed from the restored buffer so live TUI redraws
// after attach (vim already open) stay omitted until leave.
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer({
alternateScreenActive: isTerminalAlternateScreenActive(term),
});
const rebind = await terminalBackend.rebindSessionOutput?.(
sessionId,
+4 -1
View File
@@ -214,7 +214,9 @@ function createStreamEntry(sessionId, opts) {
format,
isRaw,
isHtml,
renderer: isRaw ? null : createTerminalTextRenderer(),
renderer: isRaw ? null : createTerminalTextRenderer({
alternateScreenActive: opts.alternateScreenActive === true,
}),
renderedTimestampPrefixer: !isRaw && opts.timestampsEnabled
? createRenderedLineTimestampPrefixer({ timestampProvider: opts.timestampProvider })
: null,
@@ -266,6 +268,7 @@ function startStreamToFile(sessionId, opts = {}) {
timestampsEnabled: opts.timestampsEnabled,
timestampProvider: opts.timestampProvider,
stopRequiresToken: opts.stopRequiresToken,
alternateScreenActive: opts.alternateScreenActive === true,
});
if (typeof initialLine === "string" && initialLine.length > 0) {
appendData(sessionId, initialLine);
@@ -370,6 +370,31 @@ test("startStreamToFile can write human-readable text logs from ANSI terminal ou
}
});
test("startStreamToFile seeds alternate-screen omission for late-started txt logs", async () => {
const directory = path.join(TEMP_ROOT, `manual-alt-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "txt",
hostLabel: "manual",
alternateScreenActive: true,
});
assert.equal(result.ok, true);
appendData(sessionId, "~\nstatus line\n\x1b[?1049l$ done\n");
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "$ done");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream hides sudo autofill prompt markers and rewritten command echoes", async () => {
const directory = path.join(TEMP_ROOT, `stream-sudo-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+118 -10
View File
@@ -3,6 +3,7 @@
* Provides functionality to export terminal logs to files and manage auto-save settings
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { dialog } = require("electron");
@@ -14,6 +15,8 @@ const {
const FILE_NAME_UNSAFE_CHARS = new Set(["<", ">", ":", "\"", "/", "\\", "|", "?", "*"]);
const WINDOWS_RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/i;
const manualSessionLogTokens = new Map();
/** @type {Map<string, { sessionId: string, filePath: string, format: string, senderId: number | null }>} */
const pendingManualSessionLogChoices = new Map();
const SESSION_LOG_FORMATS = new Set(["txt", "raw", "html"]);
function isControlCharacter(char) {
@@ -473,15 +476,19 @@ async function clearSessionLogsDir(event, payload = {}, terminalWorkerManager =
}
}
async function startManualSessionLog(event, payload = {}) {
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { sessionId, sessionName, preferredDirectory, initialLine } = payload;
/**
* Resolve a manual session-log destination via the save dialog (and optional
* overwrite confirm). Separated from stream start so the renderer can re-sample
* alternate-screen / initial-line state after the dialog closes.
*
* The selected path is stored only in the main process. Callers receive an
* opaque selectionToken for startManualSessionLog — renderer-supplied paths are
* never trusted for open/truncate.
*/
async function chooseManualSessionLogPath(event, payload = {}) {
const { sessionId, sessionName, preferredDirectory } = payload;
if (!sessionId) {
return { success: false, started: false, error: "Missing sessionId" };
}
if (sessionLogStreamManager.hasStream(sessionId)) {
return { success: false, started: false, error: "Session log is already active" };
return { success: false, canceled: false, error: "Missing sessionId" };
}
const targetDirectory = typeof preferredDirectory === "string" && preferredDirectory.trim()
@@ -503,12 +510,108 @@ async function startManualSessionLog(event, payload = {}) {
});
if (result.canceled || !result.filePath) {
return { success: true, started: false, canceled: true };
return { success: true, canceled: true };
}
const filePath = normalizeManualSessionLogFilePath(result.filePath, extension);
if (filePath !== result.filePath && !(await confirmManualSessionLogOverwrite(filePath))) {
return { success: true, started: false, canceled: true };
return { success: true, canceled: true };
}
// Drop any prior unused selection for this session so only the latest dialog
// result can be redeemed.
for (const [token, pending] of pendingManualSessionLogChoices) {
if (pending.sessionId === sessionId) pendingManualSessionLogChoices.delete(token);
}
const selectionToken = crypto.randomBytes(16).toString("hex");
pendingManualSessionLogChoices.set(selectionToken, {
sessionId,
filePath,
format,
senderId: event?.sender?.id ?? null,
});
return {
success: true,
canceled: false,
selectionToken,
// Informational for UI/tests; start must redeem selectionToken, not this path.
filePath,
format,
};
} catch (err) {
return { success: false, canceled: false, error: err?.message || String(err) };
}
}
function redeemManualSessionLogSelection(event, sessionId, selectionToken) {
if (typeof selectionToken !== "string" || !selectionToken) return null;
const pending = pendingManualSessionLogChoices.get(selectionToken);
if (!pending || pending.sessionId !== sessionId) return null;
const senderId = event?.sender?.id;
if (pending.senderId != null && senderId != null && pending.senderId !== senderId) {
return null;
}
pendingManualSessionLogChoices.delete(selectionToken);
return pending;
}
async function startManualSessionLog(event, payload = {}) {
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { sessionId, sessionName, preferredDirectory, initialLine } = payload;
if (!sessionId) {
return { success: false, started: false, error: "Missing sessionId" };
}
if (sessionLogStreamManager.hasStream(sessionId)) {
return { success: false, started: false, error: "Session log is already active" };
}
const displaySessionName = sessionName || sessionId;
let filePath = "";
let format = SESSION_LOG_FORMATS.has(payload.format) ? payload.format : "raw";
try {
if (typeof payload.selectionToken === "string" && payload.selectionToken) {
const pending = redeemManualSessionLogSelection(event, sessionId, payload.selectionToken);
if (!pending) {
return {
success: false,
started: false,
error: "Invalid or expired session log selection",
};
}
filePath = pending.filePath;
format = pending.format;
} else if (typeof payload.filePath === "string" && payload.filePath.trim()) {
// Do not trust renderer-supplied paths: they bypass the save dialog and
// overwrite confirmation and could truncate arbitrary writable files.
return {
success: false,
started: false,
error: "Session log path must be chosen via the save dialog",
};
} else {
// One-shot callers / tests: show the dialog and start immediately.
const chosen = await chooseManualSessionLogPath(event, {
sessionId,
sessionName,
preferredDirectory,
format,
});
if (!chosen.success) {
return { success: false, started: false, error: chosen.error || "Failed to choose session log path" };
}
if (chosen.canceled || !chosen.selectionToken) {
return { success: true, started: false, canceled: true };
}
const pending = redeemManualSessionLogSelection(event, sessionId, chosen.selectionToken);
if (!pending) {
return { success: false, started: false, error: "Invalid or expired session log selection" };
}
filePath = pending.filePath;
format = pending.format;
}
const startResult = sessionLogStreamManager.startStreamToFile(sessionId, {
@@ -520,6 +623,9 @@ async function startManualSessionLog(event, payload = {}) {
initialLine: typeof initialLine === "string" ? initialLine : "",
separateInitialLineBeforeLeadingCarriageReturn: true,
stopRequiresToken: true,
// Caller should sample this after the save dialog resolves so enter/leave
// while the dialog is open does not seed a stale alternate-screen mode.
alternateScreenActive: payload.alternateScreenActive === true,
});
if (!startResult.ok) {
@@ -620,6 +726,7 @@ function registerHandlers(ipcMain, options = {}) {
);
// Main can also answer this (manual / script streams) for symmetry / tests.
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
ipcMain.handle("netcatty:sessionLog:manualChoosePath", chooseManualSessionLogPath);
ipcMain.handle("netcatty:sessionLog:manualStart", startManualSessionLog);
ipcMain.handle("netcatty:sessionLog:manualStop", stopManualSessionLog);
ipcMain.handle("netcatty:sessionLog:manualStatus", getManualSessionLogStatus);
@@ -650,6 +757,7 @@ module.exports = {
collectActiveLogPaths,
getLocalActiveLogPaths,
isSessionLogArtifactName,
chooseManualSessionLogPath,
startManualSessionLog,
stopManualSessionLog,
getManualSessionLogStatus,
@@ -827,3 +827,95 @@ test("manual session log confirmed normalized overwrite replaces existing file",
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session log two-phase start re-samples alternate-screen after dialog", async () => {
const directory = path.join(TEMP_ROOT, `manual-two-phase-alt-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let dialogCalls = 0;
const dialogMock = {
showSaveDialog: async () => {
dialogCalls += 1;
return { canceled: false, filePath };
},
};
const {
chooseManualSessionLogPath,
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
fs.mkdirSync(directory, { recursive: true });
const chooseResult = await chooseManualSessionLogPath(null, {
sessionId,
sessionName: "host",
preferredDirectory: directory,
format: "txt",
});
assert.equal(chooseResult.success, true);
assert.equal(chooseResult.canceled, false);
assert.equal(chooseResult.filePath, filePath);
assert.equal(typeof chooseResult.selectionToken, "string");
assert.equal(dialogCalls, 1);
// Simulated post-dialog state: TUI is active when the stream actually starts.
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "host",
selectionToken: chooseResult.selectionToken,
format: "txt",
alternateScreenActive: true,
initialLine: "",
});
assert.equal(startResult.success, true);
assert.equal(startResult.started, true);
// Starting with a selection token must not reopen the save dialog.
assert.equal(dialogCalls, 1);
sessionLogStreamManager.appendData(sessionId, "~\nstatus line\n\x1b[?1049l$ done\n");
const stopResult = await stopManualSessionLog(null, { sessionId });
assert.equal(stopResult.stopped, true);
assert.equal(fs.readFileSync(filePath, "utf8"), "$ done");
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session log rejects renderer-supplied filePath without selection token", async () => {
const directory = path.join(TEMP_ROOT, `manual-path-reject-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "evil.txt");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let dialogCalls = 0;
const dialogMock = {
showSaveDialog: async () => {
dialogCalls += 1;
return { canceled: false, filePath };
},
};
const { startManualSessionLog } = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
fs.mkdirSync(directory, { recursive: true });
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "host",
filePath,
format: "txt",
initialLine: "",
});
assert.equal(startResult.success, false);
assert.equal(startResult.started, false);
assert.match(startResult.error || "", /save dialog/i);
assert.equal(dialogCalls, 0);
assert.equal(sessionLogStreamManager.hasStream(sessionId), false);
assert.equal(fs.existsSync(filePath), false);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
+197 -31
View File
@@ -5,6 +5,10 @@
* erase operations, not plain text with decoration. The renderer below keeps a
* small virtual text buffer so plain-text and HTML logs reflect what common
* line-editing output actually leaves on screen.
*
* Full-screen TUIs (vim/less/htop) use the alternate screen buffer
* (DECSET 47/1047/1049). While that mode is active, paint is omitted so session
* logs keep shell history instead of tilde rows and status lines.
*/
const CSI_FINAL_RE = /[@-~]/;
@@ -32,7 +36,7 @@ const BRIGHT_COLORS = [
];
class TerminalTextRenderer {
constructor() {
constructor(options = {}) {
this.lines = [[]];
this.row = 0;
this.col = 0;
@@ -44,6 +48,15 @@ class TerminalTextRenderer {
this.justStartedLogScreen = false;
this.hasPreservedScreenHistory = false;
this.pendingClearedScreen = null;
// Seed when a session log starts while vim/less is already on the alternate buffer.
this.alternateScreenActive = options.alternateScreenActive === true;
// DECSET 1049 saves/restores the normal-buffer cursor; 47/1047 transfer the
// alternate buffer's cursor back on leave. Track that distinction so omitted
// TUI paint still leaves the log cursor where the live terminal would.
// Seeded mid-TUI attach assumes 1049 (modern smcup); we have no pre-entry
// cursor to restore, but must not apply legacy transfer tracking either.
this.alternateScreenRestoresCursor = this.alternateScreenActive;
this.savedNormalCursor = null;
}
feed(input) {
@@ -112,23 +125,65 @@ class TerminalTextRenderer {
this.state = "esc";
this.escapeBuffer = "";
break;
case "\x9b":
// 8-bit C1 CSI (single-byte form of ESC [). Same private modes as the
// 7-bit sequence, including alternate-screen enter/leave.
this.state = "csi";
this.escapeBuffer = "";
break;
case "\b":
if (this.alternateScreenActive) {
if (!this.alternateScreenRestoresCursor) {
this.col = Math.max(0, this.col - 1);
}
break;
}
this.col = Math.max(0, this.col - 1);
break;
case "\r":
if (this.alternateScreenActive) {
if (!this.alternateScreenRestoresCursor) {
this.col = 0;
this.cursorMovedHomeByCsi = false;
}
break;
}
this.col = 0;
this.cursorMovedHomeByCsi = false;
break;
case "\n":
if (this.alternateScreenActive) {
if (!this.alternateScreenRestoresCursor) {
this.row += 1;
this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
}
break;
}
this.row += 1;
this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "\t":
if (this.alternateScreenActive) {
if (!this.alternateScreenRestoresCursor) {
this.col += 8 - (this.col % 8);
this.cursorMovedHomeByCsi = false;
}
break;
}
this.#writeText(" ".repeat(8 - (this.col % 8)));
break;
default:
if (this.alternateScreenActive) {
if (!this.alternateScreenRestoresCursor && this.#isPrintable(ch)) {
this.col += 1;
this.cursorMovedHomeByCsi = false;
}
break;
}
if (this.#isPrintable(ch)) this.#writeText(ch);
break;
}
@@ -145,15 +200,53 @@ class TerminalTextRenderer {
this.escapeBuffer = "";
return;
}
// RIS (ESC c) fully resets the terminal: leave the alternate buffer, clear
// SGR, and rebase the log screen so post-reset cursor homes cannot overwrite
// preserved history. Prior lines stay in the log (not a live ED2 wipe).
if (ch === "c") {
this.#applyRisReset();
}
// Single-character ESC sequences are terminal controls. Ignore them for
// logs, but consume them so they never leak into txt/html output.
this.state = "normal";
this.escapeBuffer = "";
}
#applyRisReset() {
this.alternateScreenActive = false;
this.alternateScreenRestoresCursor = false;
this.savedNormalCursor = null;
this.style = createDefaultStyle();
this.pendingClearedScreen = null;
// Drop trailing blank rows so "before\\n" + RIS does not leave an empty
// separator before the new screen, then base cursor-home on a fresh row.
while (this.lines.length > 0 && getTrimmedLineLength(this.lines[this.lines.length - 1]) === 0) {
this.lines.pop();
}
if (this.lines.length === 0) {
this.lines = [[]];
this.row = 0;
this.col = 0;
this.screenBaseRow = 0;
this.hasPreservedScreenHistory = false;
} else {
this.lines.push([]);
this.screenBaseRow = this.lines.length - 1;
this.row = this.screenBaseRow;
this.col = 0;
this.hasPreservedScreenHistory = true;
}
this.cursorMovedHomeByCsi = false;
this.justStartedLogScreen = true;
}
#applyCsi(sequence) {
const final = sequence.at(-1);
const params = sequence.slice(0, -1);
const isPrivateMode = params.includes("?");
const values = params
.replace(/[?><=]/g, "")
.split(";")
@@ -164,43 +257,34 @@ class TerminalTextRenderer {
});
const n = values[0] || 1;
if ((final === "h" || final === "l") && isPrivateMode) {
const alternateModes = values.filter((value) => value === 47 || value === 1047 || value === 1049);
if (alternateModes.length > 0) {
this.#applyAlternateScreenMode(final === "h", alternateModes.includes(1049));
return;
}
}
if (this.alternateScreenActive) {
// 1049 restores the saved normal cursor on leave, so alt-buffer motion can
// be ignored. Legacy 47/1047 transfer the alt cursor back — keep tracking.
if (!this.alternateScreenRestoresCursor) {
this.#applyCursorMotionCsi(final, values, n);
}
return;
}
switch (final) {
case "A":
this.row = Math.max(this.screenBaseRow, this.row - n);
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "B":
case "E":
this.row += n;
if (final === "E") this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "C":
this.col += n;
this.cursorMovedHomeByCsi = false;
break;
case "D":
this.col = Math.max(0, this.col - n);
this.cursorMovedHomeByCsi = false;
break;
case "E":
case "F":
this.row = Math.max(this.screenBaseRow, this.row - n);
this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "G":
this.col = Math.max(0, n - 1);
this.cursorMovedHomeByCsi = false;
break;
case "H":
case "f":
this.row = this.screenBaseRow + Math.max(0, (values[0] || 1) - 1);
this.col = Math.max(0, (values[1] || 1) - 1);
this.cursorMovedHomeByCsi = this.row === this.screenBaseRow && this.col === 0;
this.#ensureLine();
this.#applyCursorMotionCsi(final, values, n);
break;
case "J":
this.#eraseDisplay(values[0] || 0);
@@ -217,6 +301,88 @@ class TerminalTextRenderer {
}
}
#applyAlternateScreenMode(entering, restoresCursor) {
if (entering) {
if (!this.alternateScreenActive) {
this.alternateScreenRestoresCursor = restoresCursor;
if (restoresCursor) {
this.savedNormalCursor = {
row: this.row,
col: this.col,
cursorMovedHomeByCsi: this.cursorMovedHomeByCsi,
};
} else {
this.savedNormalCursor = null;
}
} else if (restoresCursor && !this.alternateScreenRestoresCursor) {
// Late upgrade to 1049: adopt save/restore from the current position.
this.alternateScreenRestoresCursor = true;
this.savedNormalCursor = {
row: this.row,
col: this.col,
cursorMovedHomeByCsi: this.cursorMovedHomeByCsi,
};
}
this.alternateScreenActive = true;
return;
}
if (!this.alternateScreenActive) return;
if (this.alternateScreenRestoresCursor && this.savedNormalCursor) {
this.row = this.savedNormalCursor.row;
this.col = this.savedNormalCursor.col;
this.cursorMovedHomeByCsi = this.savedNormalCursor.cursorMovedHomeByCsi;
this.#ensureLine();
}
this.alternateScreenActive = false;
this.alternateScreenRestoresCursor = false;
this.savedNormalCursor = null;
}
#applyCursorMotionCsi(final, values, n) {
switch (final) {
case "A":
this.row = Math.max(this.screenBaseRow, this.row - n);
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
return true;
case "B":
case "E":
this.row += n;
if (final === "E") this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
return true;
case "C":
this.col += n;
this.cursorMovedHomeByCsi = false;
return true;
case "D":
this.col = Math.max(0, this.col - n);
this.cursorMovedHomeByCsi = false;
return true;
case "F":
this.row = Math.max(this.screenBaseRow, this.row - n);
this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
return true;
case "G":
this.col = Math.max(0, n - 1);
this.cursorMovedHomeByCsi = false;
return true;
case "H":
case "f":
this.row = this.screenBaseRow + Math.max(0, (values[0] || 1) - 1);
this.col = Math.max(0, (values[1] || 1) - 1);
this.cursorMovedHomeByCsi = this.row === this.screenBaseRow && this.col === 0;
this.#ensureLine();
return true;
default:
return false;
}
}
#applySgr(values) {
const codes = values.length > 0 ? values : [0];
@@ -438,8 +604,8 @@ function terminalDataToHtmlContent(terminalData) {
return renderer.toHtmlContent();
}
function createTerminalTextRenderer() {
return new TerminalTextRenderer();
function createTerminalTextRenderer(options = {}) {
return new TerminalTextRenderer(options);
}
module.exports = {
@@ -151,3 +151,119 @@ test("standalone ED3 preserves current visible screen", () => {
"before\n\nscreen\nafter",
);
});
test("alternate screen body is omitted from plain text session logs", () => {
assert.equal(
terminalDataToPlainText(
"$ vim file\n\x1b[?1049h\x1b[H~\n~\n\"file\" 0L, 0B\x1b[?1049l$ ls\n",
),
"$ vim file\n$ ls",
);
});
test("alternate screen omit tracks enter/leave split across chunks", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("$ vim\n\x1b[?1049");
renderer.feed("h\x1b[H~\nstatus line");
renderer.feed("\x1b[?1049l$ done\n");
assert.equal(renderer.finish(), "$ vim\n$ done");
});
test("seeded alternate screen omits TUI paint until leave", () => {
const renderer = createTerminalTextRenderer({ alternateScreenActive: true });
renderer.feed("~\nstatus line\n");
renderer.feed("\x1b[?1049l$ done\n");
assert.equal(renderer.finish(), "$ done");
});
test("legacy smcup/rmcup alternate screen modes are omitted", () => {
assert.equal(
terminalDataToPlainText("shell\n\x1b[?47hTUI\n\x1b[?47lafter\n"),
"shell\n\nafter",
);
assert.equal(
terminalDataToPlainText("shell\n\x1b[?1047hTUI\n\x1b[?1047lafter\n"),
"shell\n\nafter",
);
});
test("legacy alternate screen transfers cursor back onto the normal buffer", () => {
// DECSET 47/1047 do not save/restore the normal cursor the way 1049 does.
// Home + omitted paint still moves the transferred cursor, so post-leave text
// overwrites at that position (live terminal: "befafter").
assert.equal(
terminalDataToPlainText("before\x1b[?47h\x1b[Hxxx\x1b[?47lafter"),
"befafter",
);
assert.equal(
terminalDataToPlainText("before\x1b[?1047h\x1b[Hxxx\x1b[?1047lafter"),
"befafter",
);
// 1049 still restores the pre-entry cursor, so omitted TUI must not shift it.
assert.equal(
terminalDataToPlainText("before\x1b[?1049h\x1b[Hxxx\x1b[?1049lafter"),
"beforeafter",
);
});
test("RIS during alternate screen restores logging of later shell output", () => {
assert.equal(
terminalDataToPlainText("before\n\x1b[?1049hTUI\x1bcshell-after\n"),
"before\nshell-after",
);
});
test("split RIS during alternate screen restores logging of later shell output", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("before\n\x1b[?1049hTUI\x1b");
renderer.feed("cshell-after\n");
assert.equal(renderer.finish(), "before\nshell-after");
});
test("RIS after mid-line TUI does not glue post-reset shell text", () => {
assert.equal(
terminalDataToPlainText("before\x1b[?1049hTUI\x1bcshell-after\n"),
"before\nshell-after",
);
});
test("RIS resets SGR so post-reset text is not colored from pre-entry style", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("\x1b[31mbefore\x1b[?1049hTUI\x1bcshell-after\n");
const html = renderer.toHtmlContent();
assert.match(html, /shell-after/);
// Pre-entry red SGR must not wrap post-RIS shell text.
assert.equal(/color:\s*#cd3131[^"]*"[^>]*>shell-after/.test(html), false);
assert.match(html, /color:\s*#cd3131[^"]*"[^>]*>before/);
});
test("RIS rebases screen so later cursor-home does not overwrite history", () => {
// Post-reset "shell" can be partially overwritten by home+write on the new
// screen ("newll"), but pre-RIS history ("before") must remain intact.
// Without rebasing, CSI H would rewrite history into something like "newore".
const log = terminalDataToPlainText("before\x1bcshell\x1b[Hnew");
assert.equal(log.startsWith("before\n"), true);
assert.equal(log.includes("new"), true);
assert.equal(log.startsWith("newore"), false);
assert.equal(log, "before\nnewll");
});
test("C1 CSI alternate-screen modes are omitted like ESC [ forms", () => {
assert.equal(
terminalDataToPlainText("before\n\x9b?1049hTUI\n\x9b?1049lafter\n"),
"before\nafter",
);
assert.equal(
terminalDataToPlainText("before\n\x9b?47hTUI\n\x9b?47lafter\n"),
"before\n\nafter",
);
});
test("split C1 CSI alternate-screen enter still omits TUI paint", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("before\n\x9b?1049");
renderer.feed("h~\nstatus\n");
renderer.feed("\x9b?1049l$ done\n");
assert.equal(renderer.finish(), "before\n$ done");
});
+2
View File
@@ -1432,6 +1432,8 @@ function createPreloadApi(ctx) {
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
clearSessionLogsDir: (directory) =>
ipcRenderer.invoke("netcatty:sessionLogs:clear", { directory }),
chooseManualSessionLogPath: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:manualChoosePath", payload),
startManualSessionLog: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:manualStart", payload),
stopManualSessionLog: (payload) =>
+17 -1
View File
@@ -98,13 +98,29 @@ declare global {
}): Promise<{ success: boolean; error?: string; filePath?: string }>;
openSessionLogsDir?(directory: string): Promise<{ success: boolean; error?: string }>;
clearSessionLogsDir?(directory: string): Promise<{ success: boolean; deletedCount: number; failedCount: number; error?: string }>;
startManualSessionLog?(payload: {
chooseManualSessionLogPath?(payload: {
sessionId: string;
sessionName?: string;
preferredDirectory?: string;
format?: 'txt' | 'raw' | 'html';
}): Promise<{
success: boolean;
canceled?: boolean;
error?: string;
selectionToken?: string;
filePath?: string;
format?: 'txt' | 'raw' | 'html';
}>;
startManualSessionLog?(payload: {
sessionId: string;
sessionName?: string;
preferredDirectory?: string;
/** Opaque token from chooseManualSessionLogPath (path is main-process only). */
selectionToken?: string;
format?: 'txt' | 'raw' | 'html';
timestampsEnabled?: boolean;
initialLine?: string;
alternateScreenActive?: boolean;
}): Promise<{ success: boolean; started: boolean; canceled?: boolean; error?: string; filePath?: string }>;
stopManualSessionLog?(payload: {
sessionId: string;