Compare commits

...
Author SHA1 Message Date
netcatty-bot 179bb5dc4e fix: address Codex review on PR #3680 2026-10-15 08:44:18 +00:00
netcatty-bot 3ca1e5dc75 fix: address Codex review on PR #3680 2026-10-15 07:41:26 +00:00
netcatty-bot 7579528c44 fix(#3676): automated AI fix 2026-10-15 07:04:22 +00:00
陈大猫 e85c1fea30 feat(terminal): add a switchable Unix path highlight rule (#3672)
* feat(terminal): highlight Unix paths with existing keyword rules

* fix(terminal): keep Unicode and quoted paths together

* fix(terminal): exclude relative path suffix highlights

* fix(terminal): preserve punctuation inside path text
2026-10-15 14:37:16 +08:00
4 changed files with 369 additions and 0 deletions

No files matched your search

+45
View File
@@ -149,3 +149,48 @@ test("normalize keeps custom (non-built-in) rules verbatim", () => {
assert.deepEqual(rule.patterns, customRule.patterns);
assert.equal(rule.label, customRule.label);
});
test("Unix path highlighting covers terminal paths without matching relative prose or URL slashes", () => {
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
for (const path of ["/sbin/iptables", "/var/log/syslog", "/usr/local/bin/g++", "~/projects/netcatty"]) {
const matches = [...`'${path}'`.matchAll(new RegExp(rule.patterns[0], "gi"))];
assert.deepEqual(matches.map((match) => match[0]), [path]);
}
for (const text of ["RELATED,ESTABLISHED", "TCP/IP", "https://example.com/log", " / ", "./foo", "../foo", "../../foo"]) {
assert.equal(matchesAny(rule.patterns, text), false, text);
}
});
test("Unix path rule upgrades old settings while preserving disabled highlights and edits", () => {
const legacy = DEFAULT_KEYWORD_HIGHLIGHT_RULES.filter((rule) => rule.id !== "unix-path");
const upgraded = normalizeTerminalSettings({ keywordHighlightEnabled: false, keywordHighlightRules: legacy });
assert.equal(upgraded.keywordHighlightEnabled, false);
assert.equal(getRule(upgraded.keywordHighlightRules, "unix-path").enabled, true);
const edited = { ...getRule(upgraded.keywordHighlightRules, "unix-path"), color: "#123456", enabled: false, customized: true, patterns: ["/chosen/path"] };
const restored = normalizeTerminalSettings({ keywordHighlightRules: [...legacy, edited] });
assert.deepEqual(getRule(restored.keywordHighlightRules, "unix-path"), edited);
});
test("Unix path highlighting keeps Unicode paths and quoted spaces together", () => {
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
for (const [text, path] of [
["/home/用户/file", "/home/用户/file"],
['cat "/Users/me/My Projects/app"', "/Users/me/My Projects/app"],
["cat '/home/用户/My Projects/app'", "/home/用户/My Projects/app"],
]) {
const matches = [...text.matchAll(new RegExp(rule.patterns[0], "gi"))];
assert.deepEqual(matches.map((match) => match[0]), [path]);
}
});
test("Unix path highlighting keeps punctuation up to the text boundary", () => {
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
for (const path of [
"/run/user/1000/gvfs/smb-share:server=nas,share=files/report.txt",
"/tmp/build=release/output",
]) {
const matches = [...path.matchAll(new RegExp(rule.patterns[0], "gi"))];
assert.deepEqual(matches.map((match) => match[0]), [path]);
}
});
+1
View File
@@ -288,6 +288,7 @@ export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
{ id: 'info', label: 'Info', patterns: ['\\[info\\]', '\\[notice\\]', '\\[note\\]', '\\bnotice\\b', '\\bnote\\b'], color: '#3B82F6', enabled: true },
{ id: 'debug', label: 'Debug', patterns: ['\\[debug\\]', '\\[trace\\]', '\\[verbose\\]', '\\bdebug\\b', '\\btrace\\b', '\\bverbose\\b'], color: '#A78BFA', enabled: true },
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, IPV6_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
{ id: 'unix-path', label: 'Unix paths', patterns: ["(?<=\")(?:/|~/)[^\"\\r\\n]+(?=\")|(?<=')(?:/|~/)[^'\\r\\n]+(?=')|(?<![\\w.:/\"'\\u0080-\\uFFFF])(?:/|~/)[^\\s\"']+"], color: '#A78BFA', enabled: true },
];
const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlightRule => ({
+111
View File
@@ -548,6 +548,37 @@ function startPtyJob(ptyStream, command, options) {
if (!text) return;
if (!pendingEnd && !deliveringInput) armOutputTimeout();
// Interactive shells can emit bypass escape-sequence output while typed
// input is still being delivered: PS0 / PROMPT_COMMAND hooks such as
// systemd ≥258's "Hierarchical Context Signalling" (Ubuntu 25.10/26.04
// ships /etc/profile.d/80-systemd-osc-context.sh) print OSC 3008 between
// command submission and execution (issue #3676). If the payload's own
// trailing newline ends up desynced against that bypass output, the
// command sits on the input line without ever being committed. Only
// OSC 3008 gates this — common decorative sequences (e.g. OSC 0 terminal
// titles echoed by Ubuntu's default PS1) have always coexisted with
// typed delivery and must not trigger the extra submit Enter below.
// PTY data callbacks may split the escape sequence at any byte, so search
// the retained suffix of the previous chunk together with this chunk;
// `osc3008Tail` is reset at the start of every input delivery.
if (deliveringInput) {
const osc3008Needle = "\x1b]3008;";
const combined = osc3008Tail + text;
if (!inputDeliverySawOsc3008 && combined.includes(osc3008Needle)) {
inputDeliverySawOsc3008 = true;
}
// Retain up to needle.length - 1 chars from the *combined* text so a
// sequence split across any number of callbacks (not just two) still
// reassembles; building the suffix from `text` alone would drop bytes
// retained from earlier callbacks.
osc3008Tail = combined.slice(-(osc3008Needle.length - 1));
// Any PTY output while a recovery Enter is pending means the shell is
// still streaming data — see the recovery note in writeInput.
if (osc3008RecoveryPending) {
osc3008RecoveryOutputSeen = true;
}
}
if (probingShell) {
probeOutput = (probeOutput + text).slice(-16384);
if (cancelRequested && hasExpectedPromptSuffix(probeOutput, expectedPrompt)) {
@@ -753,6 +784,15 @@ function startPtyJob(ptyStream, command, options) {
let inputWriteTimer = null;
let inputDrainListener = null;
let inputWriteGeneration = 0;
// Whether OSC 3008 context reporting landed in the PTY while the current
// input delivery was still being typed (see the PS0/OSC 3008 note in onData).
let inputDeliverySawOsc3008 = false;
// Retained suffix of the last decoded PTY chunk, so an escape sequence split
// across any number of data callbacks is still detected during typed delivery.
let osc3008Tail = "";
// Recovery Enter state: see armOsc3008RecoveryEnter in writeInput.
let osc3008RecoveryPending = false;
let osc3008RecoveryOutputSeen = false;
function stopInputWrite() {
inputWriteGeneration += 1;
clearTimeout(inputWriteTimer);
@@ -767,11 +807,69 @@ function startPtyJob(ptyStream, command, options) {
// so paced typing time never consumes the startup budget.
if (!finished && !cancelRequested && generation === inputWriteGeneration) {
deliveringInput = false;
osc3008RecoveryPending = false;
if (!pendingEnd) armOutputTimeout();
if (!foundStart) armStartupTimeout();
}
}
// Recovery window pacing. QUIET is one input pacing window: the Enter is
// only sent after the PTY has stayed silent that long. MAX bounds the whole
// recovery so a continuously chatty PTY (e.g. a tail -f tab) cannot postpone
// the recovery forever — the payload must still be submitted.
const OSC3008_RECOVERY_QUIET_MS = 30;
const OSC3008_RECOVERY_MAX_MS = 1000;
function armOsc3008RecoveryEnter(generation, isCurrent) {
// Recovery must wait for positive evidence rather than a blind 30ms
// timeout: on sessions whose output return latency exceeds the pacing
// window, the payload may already be committed with its _S start marker
// still in flight, and an extra Enter then would be consumed by the
// foreground command's stdin. A committed submit always produces output —
// the echoed newline / redraw, further bypass OSC chunks, or the wrapper's
// own _S marker — so PTY output while it is in flight is evidence the
// submission is resolving on its own, and onData records that activity via
// osc3008RecoveryOutputSeen. The Enter is sent only once the PTY has been
// quiet for a full pacing window (nothing left to resolve), or when the
// hard cap expires on a chatty PTY that never goes quiet. foundStart
// always cancels the recovery immediately: the wrapper's own final newline
// already committed the payload.
osc3008RecoveryPending = true;
osc3008RecoveryOutputSeen = false;
const maxQuietRounds = Math.max(1, Math.ceil(OSC3008_RECOVERY_MAX_MS / OSC3008_RECOVERY_QUIET_MS));
const writeRecoveryNewline = () => {
osc3008RecoveryPending = false;
try {
ptyStream.write("\n");
} catch (error) {
finish(preStartOutput, -1, `Terminal input failed: ${error.message}`);
return;
}
completeInputDelivery(generation);
};
const attempt = (roundsLeft) => {
inputWriteTimer = setTimeout(() => {
if (!isCurrent()) {
osc3008RecoveryPending = false;
return;
}
inputWriteTimer = null;
if (foundStart) {
osc3008RecoveryPending = false;
completeInputDelivery(generation);
return;
}
if (osc3008RecoveryOutputSeen && roundsLeft > 0) {
// Commit evidence is still in flight; wait for the next quiet window.
osc3008RecoveryOutputSeen = false;
attempt(roundsLeft - 1);
return;
}
writeRecoveryNewline();
}, OSC3008_RECOVERY_QUIET_MS);
};
attempt(maxQuietRounds);
}
function writeInput(text) {
stopInputWrite();
// Each delivery gets a fresh wait budget, including the transition from
@@ -779,6 +877,10 @@ function startPtyJob(ptyStream, command, options) {
clearStartupTimeout();
clearTimeout(timeoutId);
deliveringInput = true;
inputDeliverySawOsc3008 = false;
osc3008Tail = "";
osc3008RecoveryPending = false;
osc3008RecoveryOutputSeen = false;
const generation = inputWriteGeneration;
// Keep each write to one Unicode code point for strict bastions, while
@@ -816,6 +918,15 @@ function startPtyJob(ptyStream, command, options) {
}
}
if (offset < text.length) scheduleNext();
else if (inputDeliverySawOsc3008) {
// Bypass OSC 3008 output (PS0 / systemd context reporting, issue
// #3676) landed while this payload was being typed, so its own
// trailing newline may have been desynced and left the shell holding
// the line uncommitted. Send one standalone Enter — as its own
// write, kept out of the paced payload batch — so the typed command
// is always submitted.
armOsc3008RecoveryEnter(generation, isCurrent);
}
else completeInputDelivery(generation);
} catch (error) {
finish(preStartOutput, -1, `Terminal input failed: ${error.message}`);
@@ -0,0 +1,212 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { startPtyJob } = require('./ptyExec.cjs');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const { buildLiveShellProbe, buildLiveShellProbeCleanup } = require('./liveShellProbe.cjs');
// systemd >=258 (Ubuntu 25.10/26.04) ships a PS0/PROMPT_COMMAND hook that emits
// OSC 3008 context sequences around every submitted command (issue #3676).
// During paced typed delivery that bypass output can desync the payload's own
// final newline and leave the command sitting on the input line uncommitted.
// A delivery that observed OSC 3008 must be followed by one standalone submit
// Enter written as its own write; quiet deliveries must not change shape.
const COMMAND = "echo " + "x".repeat(1200);
const OSC_3008 = "\x1b]3008;start=uuid;type=command\x1b\\";
function makePty() {
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => { writes.push(String(data)); return true; };
return { pty, writes };
}
function expectedDeliveryLength(marker) {
const probe = 2 + buildLiveShellProbe(marker).length;
const wrapper = 2 + buildLiveShellProbeCleanup(marker).length + buildWrappedCommand(COMMAND, "posix", marker, true).length;
return probe + wrapper;
}
async function driveDelivery(t, pty, writes, marker, { osc, finalTick = true }) {
const totalLength = expectedDeliveryLength(marker);
// Short probe is delivered as a single write; reply to finish probing.
t.mock.timers.tick(30);
assert.ok(writes.length >= 1, "probe was written");
pty.emit("data", `${marker}_P:sh\n${marker}_Q`);
let delivered = false;
for (let i = 0; i < 4000; i += 1) {
t.mock.timers.tick(30);
if (osc && i % 4 === 0) pty.emit("data", osc);
if (writes.join("").length >= totalLength) { delivered = true; break; }
}
assert.ok(delivered, "wrapper delivery completed");
if (finalTick) t.mock.timers.tick(60);
}
test("delivery that observes OSC 3008 sends a standalone submit Enter (issue #3676)", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { pty, writes } = makePty();
const job = startPtyJob(pty, COMMAND, {
shellKind: "posix", probeLiveShell: true, timeoutMs: 5000,
});
await driveDelivery(t, pty, writes, job.marker, { osc: OSC_3008 });
assert.equal(writes[writes.length - 1], "\n", "a standalone Enter is written after the noisy delivery");
assert.ok(writes[writes.length - 2].endsWith("\n"), "payload batch still ends with its own newline");
pty.emit("data", `${job.marker}_S\nout\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
pty.emit("close");
t.mock.timers.reset();
});
test("delivery without OSC 3008 noise does not gain an extra standalone Enter", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { pty, writes } = makePty();
const job = startPtyJob(pty, COMMAND, {
shellKind: "posix", probeLiveShell: true, timeoutMs: 5000,
});
await driveDelivery(t, pty, writes, job.marker, { osc: null });
const payload = writes[writes.length - 1];
assert.notEqual(payload, "\n", "no standalone Enter is written on quiet delivery");
assert.ok(payload.endsWith("\n"), "wrapper payload still ends with its own newline");
pty.emit("data", `${job.marker}_S\nout\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
pty.emit("close");
t.mock.timers.reset();
});
test("decorative title OSC noise does not trigger the standalone Enter", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { pty, writes } = makePty();
const job = startPtyJob(pty, COMMAND, {
shellKind: "posix", probeLiveShell: true, timeoutMs: 5000,
});
await driveDelivery(t, pty, writes, job.marker, { osc: "\x1b]0;user@host: /tmp\x07" });
assert.notEqual(writes[writes.length - 1], "\n", "title-only noise must not add a standalone Enter");
pty.emit("data", `${job.marker}_S\nout\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
pty.emit("close");
t.mock.timers.reset();
});
test("start marker arriving during the recovery delay suppresses the extra Enter", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { pty, writes } = makePty();
const job = startPtyJob(pty, COMMAND, {
shellKind: "posix", probeLiveShell: true, timeoutMs: 5000,
});
// Skip the final tick so the recovery Enter is still pending when the marker
// arrives; it must then be withheld instead of reaching the command's stdin.
await driveDelivery(t, pty, writes, job.marker, { osc: OSC_3008, finalTick: false });
pty.emit("data", `${job.marker}_S\nout\n${job.marker}_E:0\n`);
assert.ok(writes[writes.length - 1].length > 1, "no standalone Enter was written after foundStart");
assert.equal((await job.resultPromise).exitCode, 0);
pty.emit("close");
t.mock.timers.reset();
});
test("OSC 3008 split across PTY chunks still triggers the standalone Enter", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { pty, writes } = makePty();
const job = startPtyJob(pty, COMMAND, {
shellKind: "posix", probeLiveShell: true, timeoutMs: 5000,
});
const totalLength = expectedDeliveryLength(job.marker);
t.mock.timers.tick(30);
pty.emit("data", `${job.marker}_P:sh\n${job.marker}_Q`);
let delivered = false;
const half = Math.ceil(OSC_3008.length / 2);
for (let i = 0; i < 4000; i += 1) {
t.mock.timers.tick(30);
if (i % 4 === 0) pty.emit("data", OSC_3008.slice(0, half));
if (i % 4 === 1) pty.emit("data", OSC_3008.slice(half));
if (writes.join("").length >= totalLength) { delivered = true; break; }
}
assert.ok(delivered, "wrapper delivery completed");
t.mock.timers.tick(60);
assert.equal(writes[writes.length - 1], "\n", "a split OSC 3008 sequence still latches the flag");
pty.emit("data", `${job.marker}_S\nout\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
pty.emit("close");
t.mock.timers.reset();
});
test("OSC 3008 split across three PTY chunks still triggers the standalone Enter", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { pty, writes } = makePty();
const job = startPtyJob(pty, COMMAND, {
shellKind: "posix", probeLiveShell: true, timeoutMs: 5000,
});
const totalLength = expectedDeliveryLength(job.marker);
t.mock.timers.tick(30);
pty.emit("data", `${job.marker}_P:sh\n${job.marker}_Q`);
let delivered = false;
// "\x1b]", "30" and "08;..." — the constant "\x1b]3008;" prefix itself
// spans three data callbacks; the retained suffix must survive every hop.
const three = [OSC_3008.slice(0, 2), OSC_3008.slice(2, 4), OSC_3008.slice(4)];
for (let i = 0; i < 4000; i += 1) {
t.mock.timers.tick(30);
if (i % 6 === 0) pty.emit("data", three[0]);
if (i % 6 === 1) pty.emit("data", three[1]);
if (i % 6 === 2) pty.emit("data", three[2]);
if (writes.join("").length >= totalLength) { delivered = true; break; }
}
assert.ok(delivered, "wrapper delivery completed");
// Stop emitting: the first quiet recovery window (after the chunk that
// landed alongside delivery completion is drained) must send the Enter.
let enterSent = false;
for (let i = 0; i < 10 && !enterSent; i += 1) {
t.mock.timers.tick(30);
enterSent = writes[writes.length - 1] === "\n";
}
assert.ok(enterSent, "a three-chunk split OSC 3008 sequence still latches the flag");
pty.emit("data", `${job.marker}_S\nout\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
pty.emit("close");
t.mock.timers.reset();
});
test("PTY output during the recovery window postpones the standalone Enter", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { pty, writes } = makePty();
const job = startPtyJob(pty, COMMAND, {
shellKind: "posix", probeLiveShell: true, timeoutMs: 5000,
});
await driveDelivery(t, pty, writes, job.marker, { osc: OSC_3008, finalTick: false });
// Output keeps flowing after the payload finished (echo of the submit /
// further OSC chunks): the recovery must not fire while it streams in.
pty.emit("data", OSC_3008);
t.mock.timers.tick(30);
assert.notEqual(writes[writes.length - 1], "\n", "output in flight postpones the Enter");
// One full quiet pacing window without any PTY output: the submission is
// not resolving on its own, so the recovery Enter is justified.
t.mock.timers.tick(30);
assert.equal(writes[writes.length - 1], "\n", "quiet window sends the recovery Enter");
pty.emit("data", `${job.marker}_S\nout\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
pty.emit("close");
t.mock.timers.reset();
});
test("a continuously chatty PTY cannot postpone the recovery Enter past the cap", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { pty, writes } = makePty();
const job = startPtyJob(pty, COMMAND, {
shellKind: "posix", probeLiveShell: true, timeoutMs: 5000,
});
await driveDelivery(t, pty, writes, job.marker, { osc: OSC_3008, finalTick: false });
// Output arriving during every recovery window: the cap (~1s) must still
// submit the payload eventually.
let enterSent = false;
for (let i = 0; i < 60 && !enterSent; i += 1) {
t.mock.timers.tick(15);
pty.emit("data", OSC_3008);
t.mock.timers.tick(15);
enterSent = writes[writes.length - 1] === "\n";
}
assert.ok(enterSent, "the recovery Enter is sent despite ongoing PTY output");
pty.emit("data", `${job.marker}_S\nout\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
pty.emit("close");
t.mock.timers.reset();
});