From dcf51b171532e8dbfce67ce220ff374f40a19b62 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:30:52 +0800 Subject: [PATCH] fix(discord): post the rendered screen, not the raw stream, as the alert tail A full-screen UI repaints in place, so the raw output stream read as garbage. Read capture-pane instead and drop rules, blanks and escape codes. --- src/discord-bridge.ts | 18 ++++++++++++++++ src/web/server.ts | 21 +++++++++---------- test/discord-bridge.test.ts | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 18406bb9..f548359f 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -184,6 +184,24 @@ export function getDiscordStartupHint(): string | undefined { return activeStartupHint; } +/** + * The last lines of a rendered screen for a post: colour and charset codes removed, blank lines and + * pure rule lines (box drawing, dashes) dropped, the last 14 lines, at most 900 characters, cut on a line. + */ +export function screenTail(screen: string, maxLines = 14, maxChars = 900): string { + const lines = screen + // eslint-disable-next-line no-control-regex + .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[()][A-Za-z0-9]|\u001b[@-Z\\-_]/g, '') + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '') + .split(/\r?\n/) + .map((l) => l.trimEnd()) + .filter((l) => l.trim() !== '' && !/^[\s\-_=\u2500-\u257f]+$/.test(l)); + let out = lines.slice(-maxLines); + while (out.length > 1 && out.join('\n').length > maxChars) out = out.slice(1); + return out.join('\n').slice(-maxChars); +} + /** Final path segment only, `[A-Za-z0-9._-]`, no leading dot, at most 80 characters. */ export function safeAttachmentName(name: string): string { const base = (name.split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, ''); diff --git a/src/web/server.ts b/src/web/server.ts index 2a10dbd2..b7b092ce 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -45,8 +45,7 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from import { rebootRestoreRegistry } from './reboot-restore-registry.js'; import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js'; -import { DiscordBridge } from '../discord-bridge.js'; -import { stripAnsi } from '../utils/regex-patterns.js'; +import { DiscordBridge, screenTail } from '../discord-bridge.js'; import { stopHostSensors } from '../host-sensors.js'; import type { WebhookUrgency } from '../types/push.js'; import { webviewFetch } from './webview-egress.js'; @@ -3010,7 +3009,7 @@ export class WebServer extends EventEmitter { urgency: template.urgency as WebhookUrgency, sessionId: sessionId || undefined, sessionName: sessionName || undefined, - tail: dcSession ? WebServer.outputTail(dcSession) : undefined, + tail: dcSession ? this.discordTail(dcSession) : undefined, host: this.windowTitle, }) .catch(() => undefined); @@ -3096,14 +3095,14 @@ export class WebServer extends EventEmitter { } } - /** The last few lines the CLI printed, with colour codes removed, for a Discord alert. */ - private static outputTail(session: { textOutput: string; terminalBuffer: string }): string { - const raw = session.textOutput || session.terminalBuffer.slice(-4000); - const lines = stripAnsi(raw) - .split(/\r?\n/) - .map((l) => l.trimEnd()) - .filter((l) => l.trim() !== ''); - return lines.slice(-12).join('\n').slice(-900); + /** + * What the person would see on the tab right now, for a Discord alert: the rendered tmux screen + * (`capture-pane -p`), never the raw output stream, which a full-screen UI repaints in place and + * reads as garbage. A session with no pane falls back to its plain text output, else nothing. + */ + private discordTail(session: { muxName?: string | null; textOutput: string }): string { + const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null; + return screenTail(pane ?? session.textOutput ?? ''); } /** The notification body for an event (shared by Web Push and the webhook channel). */ diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index a553f2e9..f7143ca7 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -12,6 +12,7 @@ import { DISCORD_STARTUP_HINT, isDiscordCdnUrl, safeAttachmentName, + screenTail, DEFAULT_DISCORD_CONFIG, DiscordApi, DiscordBridge, @@ -643,3 +644,43 @@ describe('DiscordBridge files and startup hint', () => { off.stop(); }); }); + +describe('screenTail', () => { + it('keeps the last lines of a rendered screen and drops rules, blanks and escape codes', () => { + const screen = [ + 'old line', + '', + '\u001b[1mWhich option?\u001b[0m', + '─────────────────────────', + ' 1. Cap GPUs', + ' 2. Cap CPU', + '\u001b(B', + ' 4. Type something.', + '━━━━━━━━━━━━━━━━━━━━━━━━━', + 'Enter to select · Esc to cancel\u001b(B', + ].join('\n'); + expect(screenTail(screen)).toBe( + [ + 'old line', + 'Which option?', + ' 1. Cap GPUs', + ' 2. Cap CPU', + ' 4. Type something.', + 'Enter to select · Esc to cancel', + ].join('\n') + ); + }); + + it('caps lines and characters, cutting on a line', () => { + const many = Array.from({ length: 40 }, (_v, i) => `line ${i}`).join('\n'); + expect(screenTail(many).split('\n')).toHaveLength(14); + expect(screenTail(many).endsWith('line 39')).toBe(true); + const wide = Array.from({ length: 5 }, () => 'x'.repeat(400)).join('\n'); + expect(screenTail(wide).length).toBeLessThanOrEqual(900); + }); + + it('is empty for nothing', () => { + expect(screenTail('')).toBe(''); + expect(screenTail('\n\n ───── \n')).toBe(''); + }); +});