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.
62 lines
2.1 KiB
TypeScript
62 lines
2.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { hasAnsi, sanitizeAnsiForRender, stripAnsi } from './ansi'
|
|
|
|
const ESC = String.fromCharCode(27)
|
|
const BEL = String.fromCharCode(7)
|
|
|
|
describe('stripAnsi', () => {
|
|
it('strips CSI/OSC/control bytes from plain previews', () => {
|
|
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}C${ESC}[?25lD`
|
|
|
|
expect(stripAnsi(sample)).toBe('ABCD')
|
|
})
|
|
|
|
it('strips incomplete CSI prefixes and carriage returns', () => {
|
|
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[CD\rE`
|
|
|
|
expect(stripAnsi(sample)).toBe('ABDE')
|
|
})
|
|
|
|
it('strips multi-byte non-CSI ESC sequences without leaving trailing bytes', () => {
|
|
const sample = `A${ESC}(0B${ESC}%GC${ESC})0D`
|
|
|
|
expect(stripAnsi(sample)).toBe('ABCD')
|
|
expect(sanitizeAnsiForRender(sample)).toBe('ABCD')
|
|
})
|
|
|
|
// Desktop's former SGR-only stripper left OSC-8 hyperlink payloads, DCS strings and
|
|
// truncated CSI tails visible in chat system messages; every surface now gets the
|
|
// full coverage.
|
|
it('leaves only visible text when OSC-8 hyperlinks, DCS strings, partial CSI and SGR mix', () => {
|
|
const sample = `${ESC}]8;;https://example.com${BEL}link${ESC}]8;;${BEL} ${ESC}Pq#0${ESC}\\${ESC}[1;32mok${ESC}[0m${ESC}[12;`
|
|
|
|
const stripped = stripAnsi(sample)
|
|
|
|
expect(stripped).toBe('link ok')
|
|
expect(stripped).not.toContain(ESC)
|
|
expect(stripped).not.toContain(BEL)
|
|
})
|
|
})
|
|
|
|
describe('sanitizeAnsiForRender', () => {
|
|
it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => {
|
|
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC`
|
|
|
|
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`)
|
|
})
|
|
|
|
it('keeps valid SGR while removing dangling CSI and carriage returns', () => {
|
|
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[39mC\rD`
|
|
|
|
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`)
|
|
})
|
|
})
|
|
|
|
describe('hasAnsi', () => {
|
|
it('detects non-CSI escape prefixes too', () => {
|
|
expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true)
|
|
expect(hasAnsi('plain')).toBe(false)
|
|
})
|
|
})
|