Files
hermes-agent/apps/shared/src/ansi.ts
T
teknium1 a3d259019b refactor(ts): one stripAnsi in @hermes/shared (TUI's OSC/DCS/partial-CSI coverage); desktop adopts it
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.
2026-09-13 06:50:57 -07:00

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)