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.
This commit is contained in:
teknium1
2026-09-12 19:35:36 -07:00
committed by Teknium
parent 172b2a722b
commit a3d259019b
22 changed files with 127 additions and 117 deletions

View File

@@ -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'

View File

@@ -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<string, unknown>, result: Record<string, unkn
return isDataImage || isRemoteImage ? candidate : ''
}
function stripAnsi(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
}
export function stripInlineDiffChrome(value: string): string {
return value
? stripAnsi(value)

View File

@@ -173,14 +173,3 @@ export function hasAnsiCodes(input: string): boolean {
// eslint-disable-next-line no-control-regex
return /\x1b\[/.test(input)
}
/** Remove all ANSI escape sequences, returning plain text. Use when output is
* rendered as text (e.g. chat system messages) rather than styled segments —
* otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */
export function stripAnsi(input: string): string {
if (!input) {
return input
}
return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '')
}

View File

@@ -18,7 +18,9 @@
"paths": {
"@/*": ["./src/*"],
"@hermes/plugin-sdk": ["./src/sdk/index.ts"],
"@hermes/shared/ansi": ["../shared/src/ansi.ts"],
"@hermes/shared/billing": ["../shared/src/billing-types.ts"],
"@hermes/shared/i18n": ["../shared/src/i18n.ts"],
"@hermes/shared/translucency": ["../shared/src/translucency.ts"],
"@hermes/shared": ["../shared/src/index.ts"]
}

View File

@@ -5,19 +5,18 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./ansi": "./src/ansi.ts",
"./billing": "./src/billing-types.ts",
"./billing-policy": "./src/billing-policy.ts",
"./charge-settlement": "./src/charge-settlement.ts",
"./format": "./src/format.ts",
"./fuzzy": "./src/fuzzy.ts",
"./gateway-events": "./src/gateway-events.ts",
"./i18n": "./src/i18n.ts",
"./json-rpc-channel": "./src/json-rpc-channel.ts",
"./model-search-text": "./src/model-search-text.ts",
<<<<<<< HEAD
"./reconnect-backoff": "./src/reconnect-backoff.ts",
=======
"./reasoning-effort": "./src/reasoning-effort.ts",
>>>>>>> 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",

View File

@@ -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)
})
})

42
apps/shared/src/ansi.ts Normal file
View File

@@ -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)

View File

@@ -1,3 +1,4 @@
export { hasAnsi, sanitizeAnsiForRender, stripAnsi } from './ansi'
export { backendScopeKey, backendScopePrefix, LOCAL_CONNECTION_ID, registryBackendScopeKey } from './backend-scope'
export {
BILLING_REFUSAL_POLICY,

View File

@@ -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<typeof StatusRule>

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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', () => {

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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 =

View File

@@ -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 () => {

View File

@@ -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)', () => {

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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()