Skip to content

Commit 0fd8f60

Browse files
committed
fix(notifications): 优化通知内容显示,统一外部完成预览转义协议
技术变更: - 为 Codex、Claude、Gemini 外部完成事件补充 `previewEscapedWhitespace`,区分“JSON 转义片段”与“正文原样文本”。 - 调整 Codex Windows / Unix notify 脚本,安全提取 `last-assistant-message`,并把预览转义语义写入 JSONL。 - 渲染层拆分“通知展示文案归一”和“完成事件去重指纹归一”;Codex 的 OSC 完成事件会短暂等待 external 预览,旧协议继续兼容。 产品行为: - 外部完成通知现在可以正确展示真实换行、制表、引号和 Unicode,同时避免将 `C:\new\temp` 一类路径误解码。 - Codex Windows 原生与 WSL/Unix,以及 Claude、Gemini 的外部完成通知,在预览展示与去重行为上保持一致。 - 旧协议中的 `摘要:\n- 项目`、`Summary\n- item` 仍会按多行预览展示;显式声明保留字面量空白时,不再被渲染层二次解码。 测试: - `npm run test -- --run electron/codex/config.test.ts electron/claude/notifications.test.ts electron/gemini/notifications.test.ts web/src/app/app-shared.notify.test.ts` - `npm run i18n:check` Signed-off-by: Lulu <58587930+lulu-sk@users.noreply.github.com>
1 parent c3f8621 commit 0fd8f60

File tree

11 files changed

+620
-59
lines changed

11 files changed

+620
-59
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
6+
vi.mock("../agentSessions/claude/discovery", () => ({
7+
getClaudeRootCandidatesFastAsync: vi.fn(),
8+
}));
9+
10+
vi.mock("../wsl", () => ({
11+
uncToWsl: vi.fn(() => null),
12+
}));
13+
14+
/**
15+
* 中文说明:创建临时目录,供 Claude 通知脚本生成测试使用。
16+
*/
17+
function createTempDir(prefix: string): string {
18+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
19+
}
20+
21+
/**
22+
* 中文说明:加载 Claude 通知模块,并预置本次测试需要的根目录列表。
23+
*/
24+
async function loadClaudeNotificationsModule(rootPath: string): Promise<typeof import("./notifications")> {
25+
vi.resetModules();
26+
const discovery = await import("../agentSessions/claude/discovery");
27+
vi.mocked(discovery.getClaudeRootCandidatesFastAsync).mockResolvedValue([
28+
{ path: rootPath, exists: true, source: "windows", kind: "local" },
29+
]);
30+
return await import("./notifications");
31+
}
32+
33+
describe("electron/claude/notifications(多行预览保真)", () => {
34+
const tempDirs: string[] = [];
35+
36+
afterEach(() => {
37+
try { vi.restoreAllMocks(); } catch {}
38+
while (tempDirs.length > 0) {
39+
const dir = tempDirs.pop();
40+
if (!dir) continue;
41+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
42+
}
43+
});
44+
45+
it("生成的 Claude hook 脚本:保留 JSONL 预览中的真实换行,仅在 OSC 兜底时折叠为空单行", async () => {
46+
const root = createTempDir("claude-notify-root-");
47+
tempDirs.push(root);
48+
fs.writeFileSync(path.join(root, "settings.json"), "{}\n", "utf8");
49+
50+
const mod = await loadClaudeNotificationsModule(root);
51+
await mod.ensureAllClaudeNotifications();
52+
53+
const script = fs.readFileSync(path.join(root, "hooks", "codexflow_stop_notify.js"), "utf8");
54+
expect(script).toContain("function collapsePreviewForOsc(input)");
55+
expect(script).toContain('const payload = collapsePreviewForOsc(preview) || "agent-turn-complete";');
56+
expect(script).toContain('return s.replace(/[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]/g, " ");');
57+
expect(script).not.toContain("function collapseWs(input)");
58+
expect(script).not.toContain("const s = collapseWs(input);");
59+
});
60+
});

electron/claude/notifications.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,18 @@ const ENV_ENV_LABEL = "CLAUDE_CODEXFLOW_ENV_LABEL";
4141
const ENV_PROVIDER_ID = "CLAUDE_CODEXFLOW_PROVIDER_ID";
4242
4343
/**
44-
* 中文说明:将多行压缩成单行,便于通知展示
44+
* 中文说明:仅为 OSC 兜底通道压平成单行,避免换行打断终端通知载荷
4545
*/
46-
function collapseWs(input) {
46+
function collapsePreviewForOsc(input) {
4747
const s = String(input || "");
48-
return s.replace(/\r/g, " ").replace(/\n/g, " ").replace(/\s+/g, " ").trim();
48+
return s.replace(/\r/g, " ").replace(/\n/g, " ").replace(/\t/g, " ").replace(/[ ][ ]+/g, " ").trim();
4949
}
5050
5151
/**
52-
* 中文说明:按 Unicode code point 截断,避免截断 surrogate pair 造成末尾乱码
52+
* 中文说明:按 Unicode code point 截断,保留正文中的真实换行与制表
5353
*/
5454
function clip(input, limit) {
55-
const s = collapseWs(input);
55+
const s = String(input || "");
5656
if (!s) return "";
5757
let out = "";
5858
let count = 0;
@@ -120,6 +120,7 @@ function buildNotifyPayload(input) {
120120
tabId: tabId || "",
121121
envLabel: envLabel || "",
122122
preview: String(input?.preview || ""),
123+
previewEscapedWhitespace: !!input?.previewEscapedWhitespace,
123124
timestamp: new Date().toISOString(),
124125
sessionId: typeof input?.sessionId === "string" ? input.sessionId : "",
125126
cwd: typeof input?.cwd === "string" ? input.cwd : "",
@@ -147,11 +148,11 @@ function safeParseJson(text) {
147148
}
148149
149150
/**
150-
* 中文说明:移除可能破坏终端状态的控制字符。
151+
* 中文说明:移除可能破坏终端状态的控制字符,但保留换行/回车/制表供 JSONL 预览展示
151152
*/
152153
function stripControlChars(input) {
153154
const s = String(input || "");
154-
return s.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
155+
return s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, " ");
155156
}
156157
157158
/**
@@ -275,7 +276,7 @@ const preview = clip(stripControlChars(previewSource), 240);
275276
276277
const ESC = "\u001b";
277278
const BEL = "\u0007";
278-
const payload = preview ? preview : "agent-turn-complete";
279+
const payload = collapsePreviewForOsc(preview) || "agent-turn-complete";
279280
const osc = ESC + "]9;" + payload + BEL;
280281
281282
const notifyPayload = buildNotifyPayload({
@@ -520,6 +521,7 @@ type ClaudeNotifyEntry = {
520521
tabId?: string;
521522
envLabel?: string;
522523
preview?: string;
524+
previewEscapedWhitespace?: boolean;
523525
timestamp?: string;
524526
sessionId?: string;
525527
cwd?: string;
@@ -650,14 +652,24 @@ function emitClaudeNotify(entry: ClaudeNotifyEntry, sourcePath?: string): void {
650652
if (!win) return;
651653
const providerId = String(entry.providerId || "claude").toLowerCase();
652654
if (providerId && providerId !== "claude") return;
653-
const payload = {
655+
const payload: {
656+
providerId: "claude";
657+
tabId: string;
658+
envLabel: string;
659+
preview: string;
660+
previewEscapedWhitespace?: boolean;
661+
timestamp: string;
662+
eventId: string;
663+
} = {
654664
providerId: "claude" as const,
655665
tabId: entry.tabId ? String(entry.tabId) : "",
656666
envLabel: entry.envLabel ? String(entry.envLabel) : "",
657667
preview: entry.preview ? String(entry.preview) : "",
658668
timestamp: entry.timestamp ? String(entry.timestamp) : "",
659669
eventId: entry.eventId ? String(entry.eventId) : "",
660670
};
671+
if (typeof entry.previewEscapedWhitespace === "boolean")
672+
payload.previewEscapedWhitespace = entry.previewEscapedWhitespace;
661673
try {
662674
win.webContents.send("notifications:externalAgentComplete", payload);
663675
logClaudeNotification(`notify event tab=${payload.tabId || "n/a"} previewLen=${payload.preview.length}`);

electron/codex/config.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,20 @@ function readCodexNotifyScript(home: string): string {
5757
? ["codexflow_after_agent_notify.ps1", "codexflow_after_agent_notify.sh"]
5858
: ["codexflow_after_agent_notify.sh", "codexflow_after_agent_notify.ps1"];
5959
for (const file of candidates) {
60-
try { return fs.readFileSync(path.join(dir, file), "utf8"); } catch {}
60+
const body = readCodexNotifyScriptByName(home, file);
61+
if (body) return body;
6162
}
6263
return "";
6364
}
6465

66+
/**
67+
* 中文说明:按文件名读取当前 HOME 下生成的指定 Codex notify 脚本内容。
68+
*/
69+
function readCodexNotifyScriptByName(home: string, fileName: string): string {
70+
const dir = path.join(home, ".codex");
71+
try { return fs.readFileSync(path.join(dir, fileName), "utf8"); } catch { return ""; }
72+
}
73+
6574
/**
6675
* 中文说明:断言 root notify 命令按当前平台写入为正确执行器。
6776
*/
@@ -96,6 +105,22 @@ async function loadConfigModule(): Promise<{ ensureAllCodexNotifications: () =>
96105
return await import("./config");
97106
}
98107

108+
/**
109+
* 中文说明:临时覆盖 `process.platform`,用于验证不同平台分支的脚本生成逻辑。
110+
*/
111+
async function withMockedPlatform<T>(platform: NodeJS.Platform, action: () => Promise<T>): Promise<T> {
112+
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
113+
Object.defineProperty(process, "platform", {
114+
configurable: true,
115+
value: platform,
116+
});
117+
try {
118+
return await action();
119+
} finally {
120+
if (descriptor) Object.defineProperty(process, "platform", descriptor);
121+
}
122+
}
123+
99124
afterEach(() => {
100125
vi.restoreAllMocks();
101126
});
@@ -115,6 +140,7 @@ describe("electron/codex/config(tui 通知配置修复)", () => {
115140
const script = readCodexNotifyScript(home);
116141
expect(script).toContain("CODEXFLOW_NOTIFY_TAB_ID");
117142
expect(script).toContain("agent-turn-complete");
143+
expect(script).toContain("previewEscapedWhitespace");
118144
} finally {
119145
cleanup();
120146
}
@@ -214,4 +240,42 @@ describe("electron/codex/config(tui 通知配置修复)", () => {
214240
cleanup();
215241
}
216242
});
243+
244+
it("Windows notify 脚本:保留 last-assistant-message 原始转义并显式标记 escaped whitespace", async () => {
245+
await withMockedPlatform("win32", async () => {
246+
const { home, cleanup } = createTempHome();
247+
try {
248+
const mod = await loadConfigModule();
249+
await mod.ensureAllCodexNotifications();
250+
const body = readCodexConfigToml(home);
251+
expectRootNotifyCommand(body);
252+
expectNotifyScriptFileName(body);
253+
const script = readCodexNotifyScriptByName(home, "codexflow_after_agent_notify.ps1");
254+
expect(script).toContain("$PreviewEscapedWhitespace = $true");
255+
expect(script).toContain("$RawPayload -match");
256+
expect(script).toContain("last-assistant-message");
257+
expect(script).toContain("$Preview = $Preview.Trim()");
258+
expect(script).not.toContain('-replace "\\s+"');
259+
} finally {
260+
cleanup();
261+
}
262+
});
263+
});
264+
265+
it("Shell notify 脚本:使用转义安全的提取逻辑保留带引号的 JSON 字符串片段", async () => {
266+
await withMockedPlatform("linux", async () => {
267+
const { home, cleanup } = createTempHome();
268+
try {
269+
const mod = await loadConfigModule();
270+
await mod.ensureAllCodexNotifications();
271+
const script = readCodexNotifyScriptByName(home, "codexflow_after_agent_notify.sh");
272+
expect(script).toContain('match($0, /"last-assistant-message"[[:space:]]*:[[:space:]]*"/)');
273+
expect(script).toContain('out = out "\\\\" ch');
274+
expect(script).toContain("sed -e '1s/^[[:space:]]*//' -e '$s/[[:space:]]*$//'");
275+
expect(script).not.toContain("tr '\\r\\n\\t' ' '");
276+
} finally {
277+
cleanup();
278+
}
279+
});
280+
});
217281
});

electron/codex/config.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,40 @@ const CODEX_NOTIFY_SH_SCRIPT = [
4242
"}",
4343
"",
4444
"PREVIEW=\"\"",
45+
"PREVIEW_ESCAPED_WHITESPACE=\"false\"",
4546
"if [ -n \"$RAW_PAYLOAD\" ]; then",
46-
" PREVIEW=$(printf \"%s\" \"$RAW_PAYLOAD\" | sed -n 's/.*\"last-assistant-message\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' | head -n 1 || true)",
47+
" PREVIEW=$(printf \"%s\" \"$RAW_PAYLOAD\" | awk '",
48+
" match($0, /\"last-assistant-message\"[[:space:]]*:[[:space:]]*\"/) {",
49+
" text = substr($0, RSTART + RLENGTH)",
50+
" out = \"\"",
51+
" escaped = 0",
52+
" for (i = 1; i <= length(text); i++) {",
53+
" ch = substr(text, i, 1)",
54+
" if (escaped) {",
55+
" out = out \"\\\\\" ch",
56+
" escaped = 0",
57+
" continue",
58+
" }",
59+
" if (ch == \"\\\\\") {",
60+
" escaped = 1",
61+
" continue",
62+
" }",
63+
" if (ch == \"\\\"\") {",
64+
" print out",
65+
" exit",
66+
" }",
67+
" out = out ch",
68+
" }",
69+
" }",
70+
" ' | head -n 1 || true)",
71+
" if [ -n \"$PREVIEW\" ]; then",
72+
" PREVIEW_ESCAPED_WHITESPACE=\"true\"",
73+
" fi",
4774
"fi",
48-
"PREVIEW=$(printf \"%s\" \"$PREVIEW\" | tr '\\r\\n\\t' ' ' | sed 's/[[:space:]][[:space:]]*/ /g; s/^ *//; s/ *$//')",
75+
"PREVIEW=$(printf \"%s\" \"$PREVIEW\" | sed -e '1s/^[[:space:]]*//' -e '$s/[[:space:]]*$//')",
4976
"if [ -z \"$PREVIEW\" ]; then",
5077
" PREVIEW=\"agent-turn-complete\"",
78+
" PREVIEW_ESCAPED_WHITESPACE=\"false\"",
5179
"fi",
5280
"",
5381
"if [ -f \"$NOTIFY_PATH\" ]; then",
@@ -60,12 +88,13 @@ const CODEX_NOTIFY_SH_SCRIPT = [
6088
"EVENT_ID=\"$$-$(date +%s 2>/dev/null || echo 0)\"",
6189
"TIMESTAMP=\"$(date -u +\"%Y-%m-%dT%H:%M:%SZ\" 2>/dev/null || date +\"%Y-%m-%dT%H:%M:%S%z\" 2>/dev/null || echo \"\")\"",
6290
"",
63-
"printf '{\"v\":1,\"eventId\":\"%s\",\"providerId\":\"%s\",\"tabId\":\"%s\",\"envLabel\":\"%s\",\"preview\":\"%s\",\"timestamp\":\"%s\"}\\n' \\",
91+
"printf '{\"v\":1,\"eventId\":\"%s\",\"providerId\":\"%s\",\"tabId\":\"%s\",\"envLabel\":\"%s\",\"preview\":\"%s\",\"previewEscapedWhitespace\":%s,\"timestamp\":\"%s\"}\\n' \\",
6492
" \"$(json_escape \"$EVENT_ID\")\" \\",
6593
" \"$(json_escape \"$PROVIDER_ID\")\" \\",
6694
" \"$(json_escape \"$TAB_ID\")\" \\",
6795
" \"$(json_escape \"$ENV_LABEL\")\" \\",
6896
" \"$(json_escape \"$PREVIEW\")\" \\",
97+
" \"$PREVIEW_ESCAPED_WHITESPACE\" \\",
6998
" \"$(json_escape \"$TIMESTAMP\")\" >> \"$NOTIFY_PATH\" 2>/dev/null || true",
7099
"",
71100
"exit 0",
@@ -94,16 +123,25 @@ const CODEX_NOTIFY_PS1_SCRIPT = [
94123
"}",
95124
"",
96125
"$Preview = \"\"",
126+
"$PreviewEscapedWhitespace = $false",
97127
"if (-not [string]::IsNullOrWhiteSpace($RawPayload)) {",
98-
" try {",
99-
" $obj = $RawPayload | ConvertFrom-Json -ErrorAction Stop",
100-
" $Preview = [string]$obj.\"last-assistant-message\"",
101-
" } catch {}",
128+
" if ($RawPayload -match '\"last-assistant-message\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"') {",
129+
" $Preview = [string]$Matches[1]",
130+
" $PreviewEscapedWhitespace = $true",
131+
" } else {",
132+
" try {",
133+
" $obj = $RawPayload | ConvertFrom-Json -ErrorAction Stop",
134+
" $Preview = [string]$obj.\"last-assistant-message\"",
135+
" } catch {}",
136+
" }",
102137
"}",
103138
"if (-not [string]::IsNullOrWhiteSpace($Preview)) {",
104-
" $Preview = ($Preview -replace \"\\s+\", \" \").Trim()",
139+
" $Preview = $Preview.Trim()",
140+
"}",
141+
"if ([string]::IsNullOrWhiteSpace($Preview)) {",
142+
" $Preview = \"agent-turn-complete\"",
143+
" $PreviewEscapedWhitespace = $false",
105144
"}",
106-
"if ([string]::IsNullOrWhiteSpace($Preview)) { $Preview = \"agent-turn-complete\" }",
107145
"",
108146
"try {",
109147
" if (Test-Path -LiteralPath $NotifyPath) {",
@@ -120,6 +158,7 @@ const CODEX_NOTIFY_PS1_SCRIPT = [
120158
" tabId = $TabId",
121159
" envLabel = $EnvLabel",
122160
" preview = $Preview",
161+
" previewEscapedWhitespace = $PreviewEscapedWhitespace",
123162
" timestamp = [DateTime]::UtcNow.ToString(\"o\")",
124163
"}",
125164
"$Line = $LineObj | ConvertTo-Json -Compress",

electron/codex/notifications.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getCodexRootsFastAsync } from "../wsl";
99
import { requestHistoryFastRefresh } from "../indexer";
1010

1111
const CODEX_NOTIFY_FILENAME = "codexflow_after_agent_notify.jsonl";
12-
const CODEX_NOTIFY_POLL_INTERVAL_MS = 1200;
12+
const CODEX_NOTIFY_POLL_INTERVAL_MS = 250;
1313
const CODEX_NOTIFY_READ_LIMIT_BYTES = 128 * 1024;
1414

1515
type CodexNotifyEntry = {
@@ -19,6 +19,7 @@ type CodexNotifyEntry = {
1919
tabId?: string;
2020
envLabel?: string;
2121
preview?: string;
22+
previewEscapedWhitespace?: boolean;
2223
timestamp?: string;
2324
};
2425

@@ -179,14 +180,24 @@ function emitCodexNotify(entry: CodexNotifyEntry, sourcePath?: string): void {
179180
if (!win) return;
180181
const providerId = String(entry.providerId || "codex").toLowerCase();
181182
if (providerId && providerId !== "codex") return;
182-
const payload = {
183+
const payload: {
184+
providerId: "codex";
185+
tabId: string;
186+
envLabel: string;
187+
preview: string;
188+
previewEscapedWhitespace?: boolean;
189+
timestamp: string;
190+
eventId: string;
191+
} = {
183192
providerId: "codex" as const,
184193
tabId: entry.tabId ? String(entry.tabId) : "",
185194
envLabel: entry.envLabel ? String(entry.envLabel) : "",
186195
preview: entry.preview ? String(entry.preview) : "",
187196
timestamp: entry.timestamp ? String(entry.timestamp) : "",
188197
eventId: entry.eventId ? String(entry.eventId) : "",
189198
};
199+
if (typeof entry.previewEscapedWhitespace === "boolean")
200+
payload.previewEscapedWhitespace = entry.previewEscapedWhitespace;
190201
try {
191202
win.webContents.send("notifications:externalAgentComplete", payload);
192203
logCodexNotification(`notify event tab=${payload.tabId || "n/a"} previewLen=${payload.preview.length}`);

0 commit comments

Comments
 (0)