@andypai/orb 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -0
  3. package/assets/orb-logo.svg +75 -0
  4. package/assets/orb-terminal-session.svg +72 -0
  5. package/assets/orb-wordmark.svg +77 -0
  6. package/package.json +76 -0
  7. package/prompts/anthropic.md +2 -0
  8. package/prompts/base.md +1 -0
  9. package/prompts/openai.md +7 -0
  10. package/prompts/voice.md +12 -0
  11. package/src/cli.ts +9 -0
  12. package/src/config.ts +270 -0
  13. package/src/index.ts +82 -0
  14. package/src/pipeline/adapters/anthropic.ts +111 -0
  15. package/src/pipeline/adapters/openai.ts +202 -0
  16. package/src/pipeline/adapters/types.ts +16 -0
  17. package/src/pipeline/adapters/utils.ts +131 -0
  18. package/src/pipeline/frames.ts +113 -0
  19. package/src/pipeline/observer.ts +36 -0
  20. package/src/pipeline/observers/metrics.ts +95 -0
  21. package/src/pipeline/pipeline.ts +43 -0
  22. package/src/pipeline/processor.ts +57 -0
  23. package/src/pipeline/processors/agent.ts +38 -0
  24. package/src/pipeline/processors/tts.ts +120 -0
  25. package/src/pipeline/task.ts +239 -0
  26. package/src/pipeline/transports/terminal-text.ts +24 -0
  27. package/src/pipeline/transports/types.ts +33 -0
  28. package/src/services/auth-utils.ts +149 -0
  29. package/src/services/global-config.ts +363 -0
  30. package/src/services/openai-auth.ts +18 -0
  31. package/src/services/prompts.ts +76 -0
  32. package/src/services/provider-defaults.ts +97 -0
  33. package/src/services/session.ts +204 -0
  34. package/src/services/streaming-tts.ts +483 -0
  35. package/src/services/tts.ts +309 -0
  36. package/src/setup.ts +234 -0
  37. package/src/types/index.ts +108 -0
  38. package/src/ui/App.tsx +142 -0
  39. package/src/ui/components/ActivityTimeline.tsx +60 -0
  40. package/src/ui/components/AsciiOrb.tsx +92 -0
  41. package/src/ui/components/ConversationRail.tsx +44 -0
  42. package/src/ui/components/Footer.tsx +61 -0
  43. package/src/ui/components/InputPrompt.tsx +88 -0
  44. package/src/ui/components/MicroOrb.tsx +25 -0
  45. package/src/ui/components/TTSErrorBanner.tsx +36 -0
  46. package/src/ui/components/TurnRow.tsx +71 -0
  47. package/src/ui/components/WelcomeSplash.tsx +78 -0
  48. package/src/ui/hooks/useAnimationFrame.ts +33 -0
  49. package/src/ui/hooks/useConversation.ts +195 -0
  50. package/src/ui/hooks/useKeyboardShortcuts.ts +57 -0
  51. package/src/ui/hooks/usePipeline.ts +83 -0
  52. package/src/ui/hooks/useTerminalSize.ts +37 -0
  53. package/src/ui/utils/markdown.ts +89 -0
  54. package/src/ui/utils/model-label.ts +20 -0
  55. package/src/ui/utils/text.ts +18 -0
  56. package/src/ui/utils/tool-format.ts +40 -0
@@ -0,0 +1,33 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ interface UseAnimationFrameOptions {
4
+ fps?: number
5
+ active?: boolean
6
+ }
7
+
8
+ /**
9
+ * Custom hook for frame-based animation with configurable FPS.
10
+ * Returns an incrementing frame counter that triggers re-renders at the specified rate.
11
+ * Uses setInterval for Node.js compatibility (no requestAnimationFrame in terminal).
12
+ */
13
+ export function useAnimationFrame({
14
+ fps = 30,
15
+ active = true,
16
+ }: UseAnimationFrameOptions = {}): number {
17
+ const [frame, setFrame] = useState(0)
18
+
19
+ useEffect(() => {
20
+ if (!active) return
21
+
22
+ const intervalMs = Math.floor(1000 / fps)
23
+ const intervalId = setInterval(() => {
24
+ setFrame((f) => f + 1)
25
+ }, intervalMs)
26
+
27
+ return () => {
28
+ clearInterval(intervalId)
29
+ }
30
+ }, [fps, active])
31
+
32
+ return frame
33
+ }
@@ -0,0 +1,195 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ import type { OutboundFrame } from '../../pipeline/transports/types'
4
+ import type { RunResult } from '../../pipeline/task'
5
+ import { saveSession } from '../../services/session'
6
+ import {
7
+ ANTHROPIC_MODELS,
8
+ type AgentSession,
9
+ type AnthropicModel,
10
+ type AppConfig,
11
+ type AppState,
12
+ type HistoryEntry,
13
+ type LlmModelId,
14
+ type SavedSession,
15
+ type TTSErrorType,
16
+ } from '../../types'
17
+
18
+ interface UseConversationConfig {
19
+ config: AppConfig
20
+ initialSession?: SavedSession | null
21
+ taskState: AppState
22
+ }
23
+
24
+ export function useConversation({ config, initialSession, taskState }: UseConversationConfig) {
25
+ const sessionMatchesProvider = initialSession?.llmProvider === config.llmProvider
26
+ const initialHistory = initialSession?.history ?? []
27
+ const initialModel =
28
+ (sessionMatchesProvider ? initialSession?.llmModel : undefined) ?? config.llmModel
29
+ const initialAgentSession = sessionMatchesProvider ? initialSession?.agentSession : undefined
30
+
31
+ const [completedTurns, setCompletedTurns] = useState<HistoryEntry[]>(initialHistory)
32
+ const [liveTurn, setLiveTurn] = useState<HistoryEntry | null>(null)
33
+ const [ttsError, setTtsError] = useState<{ type: TTSErrorType; message: string } | null>(null)
34
+ const [activeModel, setActiveModel] = useState<LlmModelId>(initialModel)
35
+
36
+ const liveTurnRef = useRef<HistoryEntry | null>(null)
37
+ const activeEntryIdRef = useRef<string | null>(null)
38
+ const agentSessionRef = useRef<AgentSession | undefined>(initialAgentSession)
39
+ const pendingSaveRef = useRef(false)
40
+
41
+ /** Update both ref (synchronous, for archive guards) and state (async, for render). */
42
+ const updateLiveTurn = useCallback((turn: HistoryEntry | null) => {
43
+ liveTurnRef.current = turn
44
+ setLiveTurn(turn)
45
+ }, [])
46
+
47
+ const getHistorySnapshot = useCallback(
48
+ () => [...completedTurns, ...(liveTurnRef.current ? [liveTurnRef.current] : [])],
49
+ [completedTurns],
50
+ )
51
+
52
+ const persistSession = useCallback(
53
+ async (modelOverride?: LlmModelId, historyOverride?: HistoryEntry[]) => {
54
+ const history = historyOverride ?? getHistorySnapshot()
55
+ const payload: SavedSession = {
56
+ version: 2,
57
+ projectPath: config.projectPath,
58
+ llmProvider: config.llmProvider,
59
+ llmModel: modelOverride ?? activeModel,
60
+ agentSession: agentSessionRef.current,
61
+ lastModified: new Date().toISOString(),
62
+ history,
63
+ }
64
+
65
+ try {
66
+ await saveSession(payload)
67
+ } catch (err) {
68
+ console.warn('Failed to save session:', err)
69
+ }
70
+ },
71
+ [activeModel, config.llmProvider, config.projectPath, getHistorySnapshot],
72
+ )
73
+
74
+ useEffect(() => {
75
+ if (!pendingSaveRef.current) return
76
+ if (taskState !== 'idle') return
77
+ pendingSaveRef.current = false
78
+ void persistSession()
79
+ }, [completedTurns, persistSession, taskState])
80
+
81
+ const startEntry = useCallback((query: string) => {
82
+ const trimmed = query.trim()
83
+ if (!trimmed) return null
84
+
85
+ // Archive any existing live turn (safety net for edge cases)
86
+ const existingLiveTurn = liveTurnRef.current
87
+ if (existingLiveTurn !== null) {
88
+ liveTurnRef.current = null
89
+ setCompletedTurns((prev) => [...prev, existingLiveTurn])
90
+ }
91
+
92
+ const entryId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
93
+ const newTurn: HistoryEntry = {
94
+ id: entryId,
95
+ question: trimmed,
96
+ toolCalls: [],
97
+ answer: '',
98
+ error: null,
99
+ }
100
+
101
+ activeEntryIdRef.current = entryId
102
+ updateLiveTurn(newTurn)
103
+ setTtsError(null)
104
+
105
+ return { entryId, query: trimmed }
106
+ }, [])
107
+
108
+ const handleFrame = useCallback(
109
+ (frame: OutboundFrame) => {
110
+ if (!liveTurnRef.current) return
111
+ const cur = liveTurnRef.current
112
+
113
+ switch (frame.kind) {
114
+ case 'agent-text-delta':
115
+ updateLiveTurn({ ...cur, answer: frame.accumulatedText })
116
+ break
117
+
118
+ case 'agent-text-complete':
119
+ updateLiveTurn({ ...cur, answer: frame.text })
120
+ break
121
+
122
+ case 'tool-call-start':
123
+ updateLiveTurn({ ...cur, toolCalls: [...cur.toolCalls, frame.toolCall] })
124
+ break
125
+
126
+ case 'tool-call-result':
127
+ updateLiveTurn({
128
+ ...cur,
129
+ toolCalls: cur.toolCalls.map((tc) =>
130
+ tc.index === frame.toolIndex
131
+ ? { ...tc, status: frame.status, result: frame.result }
132
+ : tc,
133
+ ),
134
+ })
135
+ break
136
+
137
+ case 'agent-error':
138
+ updateLiveTurn({ ...cur, error: frame.error.message })
139
+ break
140
+
141
+ case 'tts-error':
142
+ setTtsError({ type: frame.errorType, message: frame.message })
143
+ break
144
+ }
145
+ },
146
+ [updateLiveTurn],
147
+ )
148
+
149
+ const handleRunComplete = useCallback(
150
+ (result: RunResult) => {
151
+ // Guard: only archive the turn this run belongs to.
152
+ // If startEntry already replaced it with a new turn, skip.
153
+ if (liveTurnRef.current === null) return
154
+ if (activeEntryIdRef.current !== result.entryId) return
155
+
156
+ if (result.session) {
157
+ agentSessionRef.current = result.session
158
+ }
159
+
160
+ const turnToArchive = liveTurnRef.current
161
+ activeEntryIdRef.current = null
162
+ setCompletedTurns((prev) => [...prev, turnToArchive])
163
+ updateLiveTurn(null)
164
+
165
+ if (!result.cancelled) {
166
+ pendingSaveRef.current = true
167
+ }
168
+ },
169
+ [updateLiveTurn],
170
+ )
171
+
172
+ const cycleModel = useCallback(() => {
173
+ if (config.llmProvider !== 'anthropic') return
174
+
175
+ const currentIndex = ANTHROPIC_MODELS.indexOf(activeModel as AnthropicModel)
176
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % ANTHROPIC_MODELS.length
177
+ const nextModel = ANTHROPIC_MODELS[nextIndex] ?? ANTHROPIC_MODELS[0]
178
+ setActiveModel(nextModel)
179
+
180
+ void persistSession(nextModel, getHistorySnapshot())
181
+ }, [activeModel, config.llmProvider, getHistorySnapshot, persistSession])
182
+
183
+ return {
184
+ liveTurn,
185
+ activeModel,
186
+ completedTurns,
187
+ handleFrame,
188
+ handleRunComplete,
189
+ initialAgentSession,
190
+ initialModel,
191
+ startEntry,
192
+ ttsError,
193
+ cycleModel,
194
+ }
195
+ }
@@ -0,0 +1,57 @@
1
+ import { useInput } from 'ink'
2
+
3
+ import type { AppState } from '../../types'
4
+
5
+ interface UseKeyboardShortcutsConfig {
6
+ canCycleModel: boolean
7
+ onCancel(): void
8
+ onCycleModel(): void
9
+ onToggleDetailMode(): void
10
+ state: AppState
11
+ }
12
+
13
+ export function useKeyboardShortcuts({
14
+ canCycleModel,
15
+ onCancel,
16
+ onCycleModel,
17
+ onToggleDetailMode,
18
+ state,
19
+ }: UseKeyboardShortcutsConfig) {
20
+ useInput(
21
+ (input, key) => {
22
+ if (key.escape || (key.ctrl && input === 's')) {
23
+ onCancel()
24
+ }
25
+ },
26
+ { isActive: state !== 'idle' },
27
+ )
28
+
29
+ useInput(
30
+ (input, key) => {
31
+ if (key.ctrl && input === 'o') {
32
+ onToggleDetailMode()
33
+ }
34
+ },
35
+ { isActive: true },
36
+ )
37
+
38
+ useInput(
39
+ (input, key) => {
40
+ const isShiftTab =
41
+ (key.shift && key.tab) || input === '\u001b[Z' || (key.shift && input === '\t')
42
+ if (isShiftTab) {
43
+ onCycleModel()
44
+ }
45
+ },
46
+ { isActive: canCycleModel },
47
+ )
48
+
49
+ useInput(
50
+ (input, key) => {
51
+ if (key.ctrl && input === 'c') {
52
+ process.exit(0)
53
+ }
54
+ },
55
+ { isActive: true },
56
+ )
57
+ }
@@ -0,0 +1,83 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+
3
+ import { createPipelineTask } from '../../pipeline/task'
4
+ import type { RunResult, TaskState } from '../../pipeline/task'
5
+ import { createTerminalTextTransport } from '../../pipeline/transports/terminal-text'
6
+ import type { OutboundFrame, Transport } from '../../pipeline/transports/types'
7
+ import type { AgentSession, AppConfig } from '../../types'
8
+
9
+ interface PendingRun {
10
+ entryId: string
11
+ query: string
12
+ }
13
+
14
+ interface UsePipelineConfig {
15
+ config: AppConfig
16
+ activeModel: string
17
+ initialModel: string
18
+ initialSession?: AgentSession
19
+ onFrame(frame: OutboundFrame): void
20
+ onRunComplete(result: RunResult): void
21
+ onStateChange(state: TaskState): void
22
+ startEntry(query: string): PendingRun | null
23
+ }
24
+
25
+ export function usePipeline({
26
+ config,
27
+ activeModel,
28
+ initialModel,
29
+ initialSession,
30
+ onFrame,
31
+ onRunComplete,
32
+ onStateChange,
33
+ startEntry,
34
+ }: UsePipelineConfig) {
35
+ const [state, setState] = useState<TaskState>('idle')
36
+ const activeConfig = useMemo(() => ({ ...config, llmModel: activeModel }), [config, activeModel])
37
+
38
+ const { task, transport } = useMemo(() => {
39
+ const nextTransport = createTerminalTextTransport()
40
+ const nextTask = createPipelineTask({
41
+ appConfig: { ...config, llmModel: initialModel },
42
+ session: initialSession,
43
+ transport: nextTransport,
44
+ })
45
+
46
+ return {
47
+ task: nextTask,
48
+ transport: nextTransport as Transport,
49
+ }
50
+ }, []) as { task: ReturnType<typeof createPipelineTask>; transport: Transport }
51
+
52
+ useEffect(() => {
53
+ task.updateConfig(activeConfig)
54
+ }, [task, activeConfig])
55
+
56
+ useEffect(
57
+ () =>
58
+ task.onStateChange((nextState) => {
59
+ setState(nextState)
60
+ onStateChange(nextState)
61
+ }),
62
+ [onStateChange, task],
63
+ )
64
+
65
+ useEffect(() => transport.onOutbound(onFrame), [onFrame, transport])
66
+
67
+ const cancel = useCallback(() => {
68
+ task.cancel()
69
+ }, [task])
70
+
71
+ const submit = useCallback(
72
+ async (query: string) => {
73
+ const pendingRun = startEntry(query)
74
+ if (!pendingRun) return
75
+
76
+ const result = await task.run(pendingRun.query, pendingRun.entryId)
77
+ onRunComplete(result)
78
+ },
79
+ [onRunComplete, startEntry, task],
80
+ )
81
+
82
+ return { cancel, state, submit }
83
+ }
@@ -0,0 +1,37 @@
1
+ import { useState, useEffect } from 'react'
2
+
3
+ interface TerminalSize {
4
+ columns: number
5
+ rows: number
6
+ }
7
+
8
+ const DEFAULT_SIZE: TerminalSize = { columns: 80, rows: 24 }
9
+
10
+ function getCurrentSize(): TerminalSize {
11
+ if (!process.stdout.isTTY) {
12
+ return DEFAULT_SIZE
13
+ }
14
+ return {
15
+ columns: process.stdout.columns ?? DEFAULT_SIZE.columns,
16
+ rows: process.stdout.rows ?? DEFAULT_SIZE.rows,
17
+ }
18
+ }
19
+
20
+ export function useTerminalSize(): TerminalSize {
21
+ const [size, setSize] = useState<TerminalSize>(getCurrentSize)
22
+
23
+ useEffect(() => {
24
+ if (!process.stdout.isTTY) return
25
+
26
+ const handleResize = () => {
27
+ setSize(getCurrentSize())
28
+ }
29
+
30
+ process.stdout.on('resize', handleResize)
31
+ return () => {
32
+ process.stdout.off('resize', handleResize)
33
+ }
34
+ }, [])
35
+
36
+ return size
37
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Strip markdown formatting for clean terminal display.
3
+ * Keeps code blocks/inline code visible since they're meaningful in a code context.
4
+ */
5
+ export function stripMarkdown(text: string): string {
6
+ const preserved: string[] = []
7
+
8
+ function preserve(input: string, pattern: RegExp): string {
9
+ return input.replace(pattern, (match) => {
10
+ const token = `@@PRESERVE_${preserved.length}@@`
11
+ preserved.push(match)
12
+ return token
13
+ })
14
+ }
15
+
16
+ function restore(input: string): string {
17
+ return input.replace(/@@PRESERVE_(\d+)@@/g, (_, index) => preserved[Number(index)] ?? '')
18
+ }
19
+
20
+ const withoutCode = preserve(preserve(text, /```[\s\S]*?```/g), /`[^`]*`/g)
21
+
22
+ const stripped = withoutCode
23
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
24
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // Bold **
25
+ .replace(/__([^_]+)__/g, '$1') // Bold __
26
+ .replace(/\*([^*]+)\*/g, '$1') // Italic *
27
+ .replace(/(?<!\w)_([^_]+)_(?!\w)/g, '$1') // Italic _
28
+ .replace(/~~([^~]+)~~/g, '$1') // Strikethrough
29
+ .replace(/^#{1,6}\s*/gm, '') // Headers
30
+ .replace(/^\s*>+\s?/gm, '') // Blockquotes
31
+ .replace(/^\s*[-*]\s+/gm, '') // Unordered lists
32
+ .replace(/^\s*\d+\.\s+/gm, '') // Ordered lists
33
+ .replace(/^[-*]{3,}\s*$/gm, '') // Horizontal rules
34
+
35
+ return restore(stripped)
36
+ }
37
+
38
+ /**
39
+ * Replace delimited sections with a placeholder.
40
+ * Handles both paired delimiters (open/close) and single-character delimiters.
41
+ */
42
+ function replaceDelimited(
43
+ input: string,
44
+ openDelimiter: string,
45
+ closeDelimiter: string,
46
+ replacement: string,
47
+ ): string {
48
+ let result = ''
49
+ let cursor = 0
50
+
51
+ while (cursor < input.length) {
52
+ const start = input.indexOf(openDelimiter, cursor)
53
+ if (start === -1) {
54
+ result += input.slice(cursor)
55
+ break
56
+ }
57
+
58
+ const searchFrom = start + openDelimiter.length
59
+ const end = input.indexOf(closeDelimiter, searchFrom)
60
+
61
+ result += input.slice(cursor, start) + replacement
62
+
63
+ if (end === -1) {
64
+ result += input.slice(searchFrom)
65
+ break
66
+ }
67
+
68
+ cursor = end + closeDelimiter.length
69
+ }
70
+
71
+ return result
72
+ }
73
+
74
+ /**
75
+ * Clean text for speech synthesis.
76
+ * Removes code blocks/inline code (replacing with spoken placeholders),
77
+ * strips markdown, and collapses whitespace.
78
+ */
79
+ export function cleanTextForSpeech(text: string): string {
80
+ const withoutCodeBlocks = replaceDelimited(text, '```', '```', ' code block ')
81
+ const withoutInlineCode = replaceDelimited(withoutCodeBlocks, '`', '`', ' code ')
82
+
83
+ return withoutInlineCode
84
+ .split('\n')
85
+ .map((line) => stripMarkdown(line))
86
+ .join('\n')
87
+ .replace(/\s+/g, ' ')
88
+ .trim()
89
+ }
@@ -0,0 +1,20 @@
1
+ import type { AnthropicModel, LlmModelId, LlmProvider } from '../../types'
2
+ import { ANTHROPIC_MODELS } from '../../types'
3
+
4
+ export const MODEL_LABELS: Record<AnthropicModel, string> = {
5
+ 'claude-haiku-4-5-20251001': 'Haiku',
6
+ 'claude-sonnet-4-6': 'Sonnet 4.6',
7
+ 'claude-opus-4-6': 'Opus 4.6',
8
+ 'claude-opus-4-5-20251101': 'Opus 4.5',
9
+ 'claude-sonnet-4-5-20250929': 'Sonnet 4.5',
10
+ 'claude-opus-4-1-20250805': 'Opus 4.1',
11
+ 'claude-opus-4-20250514': 'Opus 4',
12
+ 'claude-sonnet-4-20250514': 'Sonnet 4',
13
+ 'claude-3-haiku-20240307': 'Haiku 3',
14
+ }
15
+
16
+ export function formatModelLabel(provider: LlmProvider, model: LlmModelId): string {
17
+ if (provider !== 'anthropic') return model
18
+ if (!ANTHROPIC_MODELS.includes(model as AnthropicModel)) return model
19
+ return MODEL_LABELS[model as AnthropicModel] ?? model
20
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Truncate text to a maximum number of lines, keeping the last N lines (tail).
3
+ * Useful for streaming content where the most recent output is most relevant.
4
+ */
5
+ export function truncateLines(
6
+ text: string,
7
+ maxLines: number,
8
+ ): { text: string; truncatedCount: number } {
9
+ const lines = text.split('\n')
10
+ if (lines.length <= maxLines) {
11
+ return { text, truncatedCount: 0 }
12
+ }
13
+ const truncated = lines.slice(-maxLines)
14
+ return {
15
+ text: truncated.join('\n'),
16
+ truncatedCount: lines.length - maxLines,
17
+ }
18
+ }
@@ -0,0 +1,40 @@
1
+ export const TOOL_INPUT_KEYS: Record<string, string> = {
2
+ Glob: 'pattern',
3
+ Grep: 'pattern',
4
+ Read: 'file_path',
5
+ Bash: 'command',
6
+ LS: 'path',
7
+ bash: 'command',
8
+ readFile: 'path',
9
+ writeFile: 'path',
10
+ }
11
+
12
+ export function truncate(text: string, maxLen: number, mode: 'start' | 'end' = 'end'): string {
13
+ if (text.length <= maxLen) return text
14
+ if (mode === 'start') {
15
+ return '...' + text.slice(-(maxLen - 3))
16
+ }
17
+ return text.slice(0, maxLen - 3) + '...'
18
+ }
19
+
20
+ export function formatToolInput(name: string, input: Record<string, unknown>): string {
21
+ const key = TOOL_INPUT_KEYS[name] ?? Object.keys(input)[0]
22
+ if (!key || input[key] === undefined) {
23
+ if ('value' in input && input.value !== undefined) {
24
+ return truncate(String(input.value), 40, 'end')
25
+ }
26
+ return ''
27
+ }
28
+
29
+ const value = String(input[key])
30
+ const truncateMode = name === 'Read' ? 'start' : 'end'
31
+ return truncate(value, 40, truncateMode)
32
+ }
33
+
34
+ export const STATUS_CONFIG = {
35
+ running: { icon: '⠋', color: 'yellow' },
36
+ error: { icon: '✗', color: 'red' },
37
+ complete: { icon: '✓', color: 'green' },
38
+ } as const
39
+
40
+ export type ToolStatus = keyof typeof STATUS_CONFIG