a3d259019b
Three TS surfaces each carried their own ANSI stripper with different
coverage. The TUI's (OSC, DCS/SOS/PM/APC strings, complete and truncated
CSI, multi-byte non-CSI ESC sequences, stray ESC, C0 controls) is now the
single implementation at apps/shared/src/ansi.ts, exported from the root
index and the new `@hermes/shared/ansi` subpath (ui-tui has no DOM lib, so
it imports the subpath like it does for billing/skin).
Sites (path::symbol → canonical):
ui-tui/src/lib/text.ts::stripAnsi, sanitizeAnsiForRender, hasAnsi
→ moved to apps/shared/src/ansi.ts (text.ts now imports stripAnsi
from '@hermes/shared/ansi' for its own trail helpers)
ui-tui: 13 importers repointed from '../lib/text.js' to
'@hermes/shared/ansi' (createGatewayEventHandler.ts,
components/messageLine.tsx, 11 __tests__ files)
apps/desktop/src/lib/ansi.ts::stripAnsi (2 regexes) → deleted;
parseAnsi/ansiColorClass/hasAnsiCodes stay (styled-segment parser)
apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
→ imports stripAnsi from '@hermes/shared/ansi'
apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
private SGR-only stripAnsi → deleted; imports the shared one
Tests: the TUI 'ANSI sanitizers' cases move from
ui-tui/src/__tests__/text.test.ts to apps/shared/src/ansi.test.ts, plus
one invariant: an OSC-8 hyperlink + DCS string + SGR + partial CSI tail
strips to exactly the visible text with no ESC/BEL left.
Behavior change: desktop chat system messages (use-prompt-actions) and
inline-diff chrome (stripInlineDiffChrome) now also lose OSC hyperlink
payloads, DCS strings, truncated CSI tails and C0 control bytes that the
weaker regexes let through. TUI behavior is unchanged.
43 lines
2.0 KiB
TypeScript
43 lines
2.0 KiB
TypeScript
// ANSI escape stripping shared by every TS surface (TUI, desktop, web).
|
|
// Covers CSI (complete and truncated tails), OSC (hyperlinks, titles), DCS/SOS/PM/APC
|
|
// strings, multi-byte non-CSI ESC sequences, stray ESC bytes and C0 controls — a
|
|
// weaker SGR-only regex leaves `]8;;url\x07` payloads and `[12;` tails visible.
|
|
|
|
const ESC = String.fromCharCode(27)
|
|
const BEL = String.fromCharCode(7)
|
|
const ANSI_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
|
|
const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g')
|
|
const ANSI_INCOMPLETE_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*(?=${ESC}|\\n|$)`, 'g')
|
|
const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
|
|
const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
|
|
const ANSI_NON_CSI_ESC_SEQ_RE = new RegExp(`${ESC}(?!\\[|\\]|P|X|\\^|_)[ -/]*[0-~]`, 'g')
|
|
const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g')
|
|
// eslint-disable-next-line no-control-regex -- intentionally strips C0/C1 control chars
|
|
const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g
|
|
|
|
/** Remove every escape sequence and control byte, returning plain visible text. */
|
|
export const stripAnsi = (s: string) =>
|
|
s
|
|
.replace(ANSI_OSC_RE, '')
|
|
.replace(ANSI_STRING_RE, '')
|
|
.replace(ANSI_INCOMPLETE_CSI_RE, '')
|
|
.replace(ANSI_CSI_RE, '')
|
|
.replace(ANSI_INCOMPLETE_CSI_RE, '')
|
|
.replace(ANSI_NON_CSI_ESC_SEQ_RE, '')
|
|
.replace(ANSI_STRAY_ESC_RE, '')
|
|
.replace(CONTROL_RE, '')
|
|
|
|
/** Like stripAnsi but keeps SGR (`m`) sequences so a styled renderer can colorize. */
|
|
export const sanitizeAnsiForRender = (s: string) =>
|
|
s
|
|
.replace(ANSI_OSC_RE, '')
|
|
.replace(ANSI_STRING_RE, '')
|
|
.replace(ANSI_INCOMPLETE_CSI_RE, '')
|
|
.replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : ''))
|
|
.replace(ANSI_INCOMPLETE_CSI_RE, '')
|
|
.replace(ANSI_NON_CSI_ESC_SEQ_RE, '')
|
|
.replace(ANSI_STRAY_ESC_RE, '')
|
|
.replace(CONTROL_RE, '')
|
|
|
|
export const hasAnsi = (s: string) => s.includes(ESC)
|