2026-04-12 17:39:17 -05:00
|
|
|
import { INTERPOLATION_RE, LONG_MSG } from '../constants.js'
|
2026-04-12 20:08:12 -05:00
|
|
|
import type { ThinkingMode } from '../types.js'
|
2026-04-02 20:39:52 -05:00
|
|
|
|
2026-04-05 18:50:41 -05:00
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
const ANSI_RE = /\x1b\[[0-9;]*m/g
|
|
|
|
|
|
|
|
|
|
export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
|
|
|
|
|
|
2026-04-09 15:13:43 -05:00
|
|
|
export const hasAnsi = (s: string) => s.includes('\x1b[') || s.includes('\x1b]')
|
2026-04-05 18:50:41 -05:00
|
|
|
|
2026-04-06 18:38:13 -05:00
|
|
|
const renderEstimateLine = (line: string) => {
|
|
|
|
|
const trimmed = line.trim()
|
|
|
|
|
|
|
|
|
|
if (trimmed.startsWith('|')) {
|
|
|
|
|
return trimmed
|
|
|
|
|
.split('|')
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map(cell => cell.trim())
|
|
|
|
|
.join(' ')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return line
|
2026-04-11 17:15:36 -05:00
|
|
|
.replace(/!\[(.*?)\]\(([^)\s]+)\)/g, '[image: $1]')
|
2026-04-06 18:38:13 -05:00
|
|
|
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1')
|
|
|
|
|
.replace(/`([^`]+)`/g, '$1')
|
|
|
|
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
2026-04-11 17:15:36 -05:00
|
|
|
.replace(/__(.+?)__/g, '$1')
|
2026-04-06 18:38:13 -05:00
|
|
|
.replace(/\*(.+?)\*/g, '$1')
|
2026-04-11 17:15:36 -05:00
|
|
|
.replace(/_(.+?)_/g, '$1')
|
|
|
|
|
.replace(/~~(.+?)~~/g, '$1')
|
|
|
|
|
.replace(/==(.+?)==/g, '$1')
|
|
|
|
|
.replace(/\[\^([^\]]+)\]/g, '[$1]')
|
|
|
|
|
.replace(/^#{1,6}\s+/, '')
|
|
|
|
|
.replace(/^\s*[-*+]\s+\[( |x|X)\]\s+/, (_m, checked: string) => `• [${checked.toLowerCase() === 'x' ? 'x' : ' '}] `)
|
|
|
|
|
.replace(/^\s*[-*+]\s+/, '• ')
|
2026-04-06 18:38:13 -05:00
|
|
|
.replace(/^\s*(\d+)\.\s+/, '$1. ')
|
2026-04-11 17:15:36 -05:00
|
|
|
.replace(/^\s*(?:>\s*)+/, '│ ')
|
2026-04-06 18:38:13 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:39:52 -05:00
|
|
|
export const compactPreview = (s: string, max: number) => {
|
|
|
|
|
const one = s.replace(/\s+/g, ' ').trim()
|
|
|
|
|
|
|
|
|
|
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 20:08:12 -05:00
|
|
|
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => {
|
|
|
|
|
const text = reasoning.replace(/\n/g, ' ').trim()
|
|
|
|
|
|
|
|
|
|
return !text || mode === 'collapsed' ? '' : mode === 'full' ? text : compactPreview(text, max)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 12:54:48 -05:00
|
|
|
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
|
|
|
|
|
|
2026-04-12 17:39:17 -05:00
|
|
|
export const toolTrailLabel = (name: string) =>
|
|
|
|
|
name
|
|
|
|
|
.split('_')
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map(p => p[0]!.toUpperCase() + p.slice(1))
|
|
|
|
|
.join(' ') || name
|
2026-04-11 13:14:32 -05:00
|
|
|
|
2026-04-12 17:39:17 -05:00
|
|
|
export const formatToolCall = (name: string, context = '') => {
|
|
|
|
|
const preview = compactPreview(context, 64)
|
|
|
|
|
|
|
|
|
|
return preview ? `${toolTrailLabel(name)}("${preview}")` : toolTrailLabel(name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string): string => {
|
|
|
|
|
const detail = compactPreview(note ?? '', 72)
|
|
|
|
|
|
|
|
|
|
return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}`
|
2026-04-11 06:35:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 18:33:25 -05:00
|
|
|
/** Tool completed / failed row in the inline trail (not CoT prose). */
|
|
|
|
|
export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
|
|
|
|
|
|
2026-04-12 17:39:17 -05:00
|
|
|
export const parseToolTrailResultLine = (line: string) => {
|
|
|
|
|
if (!isToolTrailResultLine(line)) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mark = line.endsWith(' ✗') ? '✗' : '✓'
|
|
|
|
|
const body = line.slice(0, -2)
|
|
|
|
|
const [call, detail] = body.split(' :: ', 2)
|
|
|
|
|
|
|
|
|
|
if (detail != null) {
|
|
|
|
|
return { call, detail, mark }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const legacy = body.indexOf(': ')
|
|
|
|
|
|
|
|
|
|
if (legacy > 0) {
|
|
|
|
|
return { call: body.slice(0, legacy), detail: body.slice(legacy + 2), mark }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { call: body, detail: '', mark }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:06:39 -05:00
|
|
|
/** Ephemeral status lines that should vanish once the next phase starts. */
|
2026-04-12 16:33:25 -05:00
|
|
|
export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…'
|
2026-04-12 16:06:39 -05:00
|
|
|
|
2026-04-09 00:36:53 -05:00
|
|
|
/** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */
|
|
|
|
|
export const sameToolTrailGroup = (label: string, entry: string) =>
|
2026-04-12 17:39:17 -05:00
|
|
|
entry === `${label} ✓` ||
|
|
|
|
|
entry === `${label} ✗` ||
|
|
|
|
|
entry.startsWith(`${label}(`) ||
|
|
|
|
|
entry.startsWith(`${label} ::`) ||
|
|
|
|
|
entry.startsWith(`${label}:`)
|
2026-04-09 00:36:53 -05:00
|
|
|
|
2026-04-09 18:33:25 -05:00
|
|
|
/** Index of the last non-result trail line, or -1. */
|
|
|
|
|
export const lastCotTrailIndex = (trail: readonly string[]) => {
|
|
|
|
|
for (let i = trail.length - 1; i >= 0; i--) {
|
|
|
|
|
if (!isToolTrailResultLine(trail[i]!)) {
|
|
|
|
|
return i
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 18:51:17 -05:00
|
|
|
export const THINKING_COT_MAX = 160
|
|
|
|
|
|
2026-04-06 18:38:13 -05:00
|
|
|
export const estimateRows = (text: string, w: number, compact = false) => {
|
2026-04-11 17:15:36 -05:00
|
|
|
let fence: { char: '`' | '~'; len: number } | null = null
|
2026-04-06 18:38:13 -05:00
|
|
|
let rows = 0
|
|
|
|
|
|
|
|
|
|
for (const raw of text.split('\n')) {
|
|
|
|
|
const line = stripAnsi(raw)
|
2026-04-11 17:15:36 -05:00
|
|
|
const maybeFence = line.match(/^\s*(`{3,}|~{3,})(.*)$/)
|
2026-04-06 18:38:13 -05:00
|
|
|
|
2026-04-11 17:15:36 -05:00
|
|
|
if (maybeFence) {
|
|
|
|
|
const marker = maybeFence[1]!
|
|
|
|
|
const lang = maybeFence[2]!.trim()
|
|
|
|
|
|
|
|
|
|
if (!fence) {
|
|
|
|
|
fence = {
|
|
|
|
|
char: marker[0] as '`' | '~',
|
|
|
|
|
len: marker.length
|
|
|
|
|
}
|
2026-04-06 18:38:13 -05:00
|
|
|
|
|
|
|
|
if (lang) {
|
|
|
|
|
rows += Math.ceil((`─ ${lang}`.length || 1) / w)
|
|
|
|
|
}
|
2026-04-11 17:15:36 -05:00
|
|
|
} else if (marker[0] === fence.char && marker.length >= fence.len) {
|
|
|
|
|
fence = null
|
2026-04-06 18:38:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 17:15:36 -05:00
|
|
|
const inCode = Boolean(fence)
|
2026-04-06 18:38:13 -05:00
|
|
|
const trimmed = line.trim()
|
|
|
|
|
|
|
|
|
|
if (!inCode && trimmed.startsWith('|') && /^[|\s:-]+$/.test(trimmed)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rendered = inCode ? line : renderEstimateLine(line)
|
|
|
|
|
|
|
|
|
|
if (compact && !rendered.trim()) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rows += Math.ceil((rendered.length || 1) / w)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Math.max(1, rows)
|
|
|
|
|
}
|
2026-04-02 20:39:52 -05:00
|
|
|
|
|
|
|
|
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
|
|
|
|
|
|
2026-04-09 00:46:35 -05:00
|
|
|
const COMPACT_NUMBER = new Intl.NumberFormat('en-US', {
|
|
|
|
|
maximumFractionDigits: 1,
|
|
|
|
|
notation: 'compact'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
export const fmtK = (n: number) => COMPACT_NUMBER.format(n)
|
2026-04-02 20:39:52 -05:00
|
|
|
|
|
|
|
|
export const hasInterpolation = (s: string) => {
|
|
|
|
|
INTERPOLATION_RE.lastIndex = 0
|
|
|
|
|
|
|
|
|
|
return INTERPOLATION_RE.test(s)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
|
|
|
|
|
|
|
|
|
|
export const userDisplay = (text: string): string => {
|
|
|
|
|
if (text.length <= LONG_MSG) {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const first = text.split('\n')[0]?.trim() ?? ''
|
|
|
|
|
const words = first.split(/\s+/).filter(Boolean)
|
|
|
|
|
const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80)
|
|
|
|
|
|
|
|
|
|
return `${prefix || '(message)'} [long message]`
|
|
|
|
|
}
|
2026-04-11 11:29:08 -05:00
|
|
|
|
|
|
|
|
export const isPasteBackedText = (text: string): boolean =>
|
|
|
|
|
/\[\[paste:\d+\]\]|\[paste #\d+ (?:attached|excerpt)\]/.test(text)
|