mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0682f36c8f | ||
|
|
62d82a1264 | ||
|
|
e89e8841ed | ||
|
|
d1591ccf5b | ||
|
|
62642ff59a | ||
|
|
4af9de15e6 | ||
|
|
d678b9c623 | ||
|
|
47d5f1aad3 | ||
|
|
c502096a0d |
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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), "");
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user