From a3d259019b7d3168c10b53c347376fcccfe13b51 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:35:36 -0700 Subject: [PATCH] refactor(ts): one stripAnsi in @hermes/shared (TUI's OSC/DCS/partial-CSI coverage); desktop adopts it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../session/hooks/use-prompt-actions/index.ts | 2 +- .../assistant-ui/tool/fallback-model/index.ts | 6 +- apps/desktop/src/lib/ansi.ts | 11 ---- apps/desktop/tsconfig.json | 2 + apps/shared/package.json | 7 +-- apps/shared/src/ansi.test.ts | 61 +++++++++++++++++++ apps/shared/src/ansi.ts | 42 +++++++++++++ apps/shared/src/index.ts | 1 + .../__tests__/appChromeBlockedTimers.test.tsx | 2 +- ui-tui/src/__tests__/billingStepUp.test.tsx | 2 +- ui-tui/src/__tests__/markdown.test.ts | 2 +- ui-tui/src/__tests__/messages.test.ts | 2 +- ui-tui/src/__tests__/petPane.test.tsx | 2 +- .../src/__tests__/streamingMarkdown.test.ts | 2 +- .../__tests__/subscriptionOverlay.test.tsx | 2 +- ui-tui/src/__tests__/text.test.ts | 43 ------------- .../__tests__/thinkingLiveCollapse.test.tsx | 2 +- .../thinkingMoaReferenceVisibility.test.tsx | 2 +- .../__tests__/widgetGridComponent.test.tsx | 2 +- ui-tui/src/app/createGatewayEventHandler.ts | 3 +- ui-tui/src/components/messageLine.tsx | 10 +-- ui-tui/src/lib/text.ts | 36 +---------- 22 files changed, 127 insertions(+), 117 deletions(-) create mode 100644 apps/shared/src/ansi.test.ts create mode 100644 apps/shared/src/ansi.ts diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 88283501fe..6831eecb12 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -1,11 +1,11 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' import { JsonRpcGatewayError } from '@hermes/shared' +import { stripAnsi } from '@hermes/shared/ansi' import { useStore } from '@nanostores/react' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { transcribeAudio } from '@/hermes' import { useI18n } from '@/i18n' -import { stripAnsi } from '@/lib/ansi' import { type ChatMessage, textPart } from '@/lib/chat-messages' import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index e3b0059582..dea05eb861 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -1,3 +1,5 @@ +import { stripAnsi } from '@hermes/shared/ansi' + import { type ToolTitleKey, translateNow } from '@/i18n' import { normalizeExternalUrl } from '@/lib/external-link' import { summarizeShellCommand } from '@/lib/summarize-command' @@ -768,10 +770,6 @@ function toolImageUrl(args: Record, result: Record>>>>>> 914084147a68 (refactor(ts): one compactNumber and one reasoning-effort value set in @hermes/shared) + "./reconnect-backoff": "./src/reconnect-backoff.ts", "./skin": "./src/skin.ts" }, "types": "./src/index.ts", diff --git a/apps/shared/src/ansi.test.ts b/apps/shared/src/ansi.test.ts new file mode 100644 index 0000000000..c8c041cc8b --- /dev/null +++ b/apps/shared/src/ansi.test.ts @@ -0,0 +1,61 @@ +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) + }) +}) diff --git a/apps/shared/src/ansi.ts b/apps/shared/src/ansi.ts new file mode 100644 index 0000000000..67eebfb0bf --- /dev/null +++ b/apps/shared/src/ansi.ts @@ -0,0 +1,42 @@ +// 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) diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index ea0da12a0f..b86de221b9 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -1,3 +1,4 @@ +export { hasAnsi, sanitizeAnsiForRender, stripAnsi } from './ansi' export { backendScopeKey, backendScopePrefix, LOCAL_CONNECTION_ID, registryBackendScopeKey } from './backend-scope' export { BILLING_REFUSAL_POLICY, diff --git a/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx b/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx index c94ea908cc..8074884aeb 100644 --- a/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx +++ b/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx @@ -1,6 +1,7 @@ import { PassThrough } from 'stream' import { renderSync } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,7 +13,6 @@ import { StatusRule } from '../components/appChrome.js' import { AppLayout } from '../components/appLayout.js' import type { GatewayClient } from '../gatewayClient.js' import { DEFAULT_VOICE_RECORD_KEY } from '../lib/platform.js' -import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' type StatusRuleProps = React.ComponentProps diff --git a/ui-tui/src/__tests__/billingStepUp.test.tsx b/ui-tui/src/__tests__/billingStepUp.test.tsx index 4c54c99495..c4a98b489c 100644 --- a/ui-tui/src/__tests__/billingStepUp.test.tsx +++ b/ui-tui/src/__tests__/billingStepUp.test.tsx @@ -1,6 +1,7 @@ import { PassThrough } from 'stream' import { renderSync } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import React from 'react' import { describe, expect, it, vi } from 'vitest' @@ -14,7 +15,6 @@ vi.mock('@hermes/ink', async importOriginal => { import type { BillingOverlayState } from '../app/interfaces.js' import { BillingOverlay } from '../components/billingOverlay.js' import type { BillingStateResponse } from '../gatewayTypes.js' -import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' const t = DEFAULT_THEME diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index 89b611c9fd..3452d9afdf 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -1,13 +1,13 @@ import { PassThrough } from 'stream' import { Box, renderSync } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import chalk from 'chalk' import React from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js' import { __resetLinkTitleCache, fetchLinkTitle } from '../lib/externalLink.js' -import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME, LIGHT_THEME } from '../theme.js' afterEach(() => { diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts index c85aaf8759..84b9884b27 100644 --- a/ui-tui/src/__tests__/messages.test.ts +++ b/ui-tui/src/__tests__/messages.test.ts @@ -1,6 +1,7 @@ import { PassThrough } from 'stream' import { renderSync } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import React from 'react' import { describe, expect, it } from 'vitest' @@ -8,7 +9,6 @@ import { fmtMsgTimestamp, MessageLine } from '../components/messageLine.js' import { MAX_HISTORY } from '../config/limits.js' import { toTranscriptMessages } from '../domain/messages.js' import { appendTranscriptMessage, capTranscriptHistory, upsert } from '../lib/messages.js' -import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' describe('toTranscriptMessages', () => { diff --git a/ui-tui/src/__tests__/petPane.test.tsx b/ui-tui/src/__tests__/petPane.test.tsx index 212f95f3d3..bfcbea2bcb 100644 --- a/ui-tui/src/__tests__/petPane.test.tsx +++ b/ui-tui/src/__tests__/petPane.test.tsx @@ -1,12 +1,12 @@ import { PassThrough } from 'stream' import { Box, renderSync } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import React from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { usePet } from '../app/usePet.js' import { PetPane } from '../components/appLayout.js' -import { stripAnsi } from '../lib/text.js' vi.mock('../app/usePet.js', () => ({ usePet: vi.fn() diff --git a/ui-tui/src/__tests__/streamingMarkdown.test.ts b/ui-tui/src/__tests__/streamingMarkdown.test.ts index 0c134a0c0b..f05523fd20 100644 --- a/ui-tui/src/__tests__/streamingMarkdown.test.ts +++ b/ui-tui/src/__tests__/streamingMarkdown.test.ts @@ -1,12 +1,12 @@ import { PassThrough } from 'stream' import { Box, renderSync } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import React from 'react' import { describe, expect, it } from 'vitest' import { Md } from '../components/markdown.js' import { advanceScan, createScanState, findStableBoundary } from '../components/streamingMarkdown.js' -import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' const BEL = String.fromCharCode(7) diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index 397520c7ed..e9b2e41eb1 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -1,6 +1,7 @@ import { PassThrough } from 'stream' import { renderSync } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import React from 'react' import { describe, expect, it, vi } from 'vitest' @@ -24,7 +25,6 @@ vi.mock('@hermes/ink', async importOriginal => { import type { SubscriptionOverlayState } from '../app/interfaces.js' import { SubscriptionOverlay } from '../components/subscriptionOverlay.js' import type { SubscriptionStateResponse } from '../gatewayTypes.js' -import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' const t = DEFAULT_THEME diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index fba3109299..fc1d1004c2 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -7,15 +7,12 @@ import { edgePreview, estimateRows, estimateTokensRough, - hasAnsi, isToolTrailResultLine, lastCotTrailIndex, parseToolTrailResultLine, pasteTokenLabel, sameToolTrailGroup, - sanitizeAnsiForRender, splitToolDuration, - stripAnsi, thinkingPreview } from '../lib/text.js' @@ -126,46 +123,6 @@ describe('estimateTokensRough', () => { }) }) -describe('ANSI sanitizers', () => { - const ESC = String.fromCharCode(27) - const BEL = String.fromCharCode(7) - - 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('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`) - }) - - 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') - }) - - it('detects non-CSI escape prefixes too', () => { - expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true) - }) -}) - describe('thinkingPreview', () => { it('adds paragraph breaks before markdown thinking headings', () => { const raw = diff --git a/ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx b/ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx index 0209f02fe3..38e8ca4fd5 100644 --- a/ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx +++ b/ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx @@ -1,11 +1,11 @@ import { PassThrough } from 'stream' import { renderSync } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import React from 'react' import { describe, expect, it } from 'vitest' import { ToolTrail } from '../components/thinking.js' -import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' const flushEffects = async () => { diff --git a/ui-tui/src/__tests__/thinkingMoaReferenceVisibility.test.tsx b/ui-tui/src/__tests__/thinkingMoaReferenceVisibility.test.tsx index 91b332c000..5e138d1ace 100644 --- a/ui-tui/src/__tests__/thinkingMoaReferenceVisibility.test.tsx +++ b/ui-tui/src/__tests__/thinkingMoaReferenceVisibility.test.tsx @@ -1,11 +1,11 @@ import { PassThrough } from 'stream' import { renderSync } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import React from 'react' import { describe, expect, it } from 'vitest' import { ToolTrail } from '../components/thinking.js' -import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' describe('ToolTrail — MoA reference panel visibility (#64701)', () => { diff --git a/ui-tui/src/__tests__/widgetGridComponent.test.tsx b/ui-tui/src/__tests__/widgetGridComponent.test.tsx index 9891b5bbb9..2f05274d0b 100644 --- a/ui-tui/src/__tests__/widgetGridComponent.test.tsx +++ b/ui-tui/src/__tests__/widgetGridComponent.test.tsx @@ -1,12 +1,12 @@ import { PassThrough } from 'stream' import { renderSync, Text } from '@hermes/ink' +import { stripAnsi } from '@hermes/shared/ansi' import React, { useState } from 'react' import { describe, expect, it } from 'vitest' import { GridStreamsDemo, STREAM_DEFS } from '../components/gridStreamsDemo.js' import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from '../components/widgetGrid.js' -import { stripAnsi } from '../lib/text.js' import { GRID_STREAM_COUNT, type GridTestState } from '../sdk/apps/gridTestState.js' import { DEFAULT_THEME } from '../theme.js' diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 98a46ce619..33a7ed8c8d 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -2,6 +2,7 @@ import { execFile } from 'child_process' import { forceRedraw, onTerminalBackground, onTerminalForeground } from '@hermes/ink' import type { SubagentStatus, Usage } from '@hermes/shared/gateway-events' +import { stripAnsi } from '@hermes/shared/ansi' import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' @@ -21,7 +22,7 @@ import { openExternalUrl } from '../lib/openExternalUrl.js' import { rpcErrorMessage } from '../lib/rpc.js' import { topLevelSubagents } from '../lib/subagentTree.js' import { isPaintableHex, setTerminalBackground, setTerminalForeground } from '../lib/terminalModes.js' -import { formatAbandonedClarify, formatAbandonedClarifyBatch, formatToolCall, stripAnsi } from '../lib/text.js' +import { formatAbandonedClarify, formatAbandonedClarifyBatch, formatToolCall } from '../lib/text.js' import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js' import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme, themeToneHex } from '../theme.js' import type { Msg, SessionInfo, SubagentProgress } from '../types.js' diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 7417450e35..fb64a9d692 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,4 +1,5 @@ import { Ansi, Box, NoSelect, Text } from '@hermes/ink' +import { hasAnsi, sanitizeAnsiForRender, stripAnsi } from '@hermes/shared/ansi' import { memo, useState } from 'react' import { TERMUX_TUI_MODE } from '../config/env.js' @@ -9,14 +10,7 @@ import { sectionMode } from '../domain/details.js' import { userDisplay } from '../domain/messages.js' import { ROLE } from '../domain/roles.js' import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js' -import { - boundedLiveRenderText, - compactPreview, - hasAnsi, - isPasteBackedText, - sanitizeAnsiForRender, - stripAnsi -} from '../lib/text.js' +import { boundedLiveRenderText, compactPreview, isPasteBackedText } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js' diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 18c11d8263..96c0ca05de 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,3 +1,4 @@ +import { stripAnsi } from '@hermes/shared/ansi' import { compactNumber } from '@hermes/shared/format' import { @@ -10,43 +11,8 @@ import { import { VERBS } from '../content/verbs.js' import type { ThinkingMode } from '../types.js' -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 const WS_RE = /\s+/g -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, '') - -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) - const renderEstimateLine = (line: string) => { const trimmed = line.trim()