@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
package/src/ui/App.tsx ADDED
@@ -0,0 +1,142 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { basename } from 'node:path'
3
+ import { Box } from 'ink'
4
+
5
+ import { type AppConfig, type AppState, type DetailMode, type SavedSession } from '../types'
6
+ import type { AnimationMode } from './components/AsciiOrb'
7
+ import { ConversationRail } from './components/ConversationRail'
8
+ import { Footer } from './components/Footer'
9
+ import { TTSErrorBanner } from './components/TTSErrorBanner'
10
+ import { WelcomeSplash } from './components/WelcomeSplash'
11
+ import { useConversation } from './hooks/useConversation'
12
+ import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
13
+ import { usePipeline } from './hooks/usePipeline'
14
+ import { useTerminalSize } from './hooks/useTerminalSize'
15
+ import { formatModelLabel } from './utils/model-label'
16
+
17
+ interface AppProps {
18
+ config: AppConfig
19
+ initialSession?: SavedSession | null
20
+ }
21
+
22
+ function mapStateToAnimationMode(state: AppState): AnimationMode {
23
+ switch (state) {
24
+ case 'speaking':
25
+ case 'processing_speaking':
26
+ return 'speaking'
27
+ case 'processing':
28
+ return 'processing'
29
+ case 'idle':
30
+ return 'idle'
31
+ }
32
+ }
33
+
34
+ const FIXED_UI_OVERHEAD = 8
35
+
36
+ export function isInputDisabled(state: AppState): boolean {
37
+ return state !== 'idle'
38
+ }
39
+
40
+ export function App({ config, initialSession }: AppProps) {
41
+ const [detailMode, setDetailMode] = useState<DetailMode>('compact')
42
+ const [state, setState] = useState<AppState>('idle')
43
+ const [splashDismissed, setSplashDismissed] = useState(false)
44
+
45
+ const conversation = useConversation({
46
+ config,
47
+ initialSession,
48
+ taskState: state,
49
+ })
50
+
51
+ const { cancel, submit } = usePipeline({
52
+ config,
53
+ activeModel: conversation.activeModel,
54
+ initialModel: conversation.initialModel,
55
+ initialSession: conversation.initialAgentSession,
56
+ onFrame: conversation.handleFrame,
57
+ onRunComplete: conversation.handleRunComplete,
58
+ onStateChange: setState,
59
+ startEntry: conversation.startEntry,
60
+ })
61
+
62
+ // ── Layout ──
63
+
64
+ const { rows: terminalRows } = useTerminalSize()
65
+
66
+ const liveToolCount = conversation.liveTurn?.toolCalls.length ?? 0
67
+ const maxAnswerLines = useMemo(
68
+ () => Math.max(5, terminalRows - FIXED_UI_OVERHEAD - liveToolCount),
69
+ [terminalRows, liveToolCount],
70
+ )
71
+
72
+ const canCycleModel = state === 'idle' && config.llmProvider === 'anthropic'
73
+
74
+ useKeyboardShortcuts({
75
+ canCycleModel,
76
+ onCancel: cancel,
77
+ onCycleModel: conversation.cycleModel,
78
+ onToggleDetailMode: () => setDetailMode((m) => (m === 'compact' ? 'expanded' : 'compact')),
79
+ state,
80
+ })
81
+
82
+ // ── Derived state ──
83
+
84
+ const animationMode = useMemo(() => mapStateToAnimationMode(state), [state])
85
+ const assistantLabel = config.llmProvider === 'anthropic' ? 'claude' : 'openai'
86
+ const inputDisabled = isInputDisabled(state)
87
+ const projectName = useMemo(
88
+ () => basename(config.projectPath) || config.projectPath,
89
+ [config.projectPath],
90
+ )
91
+ const modelLabel = useMemo(
92
+ () => formatModelLabel(config.llmProvider, conversation.activeModel),
93
+ [config.llmProvider, conversation.activeModel],
94
+ )
95
+
96
+ const showWelcome =
97
+ conversation.completedTurns.length === 0 &&
98
+ !conversation.liveTurn &&
99
+ !config.skipIntro &&
100
+ !initialSession?.history?.length &&
101
+ !splashDismissed
102
+
103
+ // ── Rendering ──
104
+
105
+ return (
106
+ <Box flexDirection="column" padding={1}>
107
+ {conversation.ttsError && (
108
+ <TTSErrorBanner type={conversation.ttsError.type} message={conversation.ttsError.message} />
109
+ )}
110
+ {showWelcome ? (
111
+ <WelcomeSplash
112
+ animationMode={animationMode}
113
+ assistantLabel={assistantLabel}
114
+ projectName={projectName}
115
+ modelLabel={modelLabel}
116
+ ttsVoice={config.ttsVoice}
117
+ ttsSpeed={config.ttsSpeed}
118
+ ttsEnabled={config.ttsEnabled}
119
+ onDismiss={() => setSplashDismissed(true)}
120
+ />
121
+ ) : (
122
+ <ConversationRail
123
+ completedTurns={conversation.completedTurns}
124
+ liveTurn={conversation.liveTurn}
125
+ detailMode={detailMode}
126
+ maxAnswerLines={maxAnswerLines}
127
+ assistantLabel={assistantLabel}
128
+ />
129
+ )}
130
+ {!showWelcome && (
131
+ <Footer
132
+ state={state}
133
+ onSubmit={submit}
134
+ inputDisabled={inputDisabled}
135
+ model={conversation.activeModel}
136
+ provider={config.llmProvider}
137
+ canCycleModel={canCycleModel}
138
+ />
139
+ )}
140
+ </Box>
141
+ )
142
+ }
@@ -0,0 +1,60 @@
1
+ import { memo } from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ import type { DetailMode, ToolCall } from '../../types'
5
+ import { formatToolInput, truncate, STATUS_CONFIG } from '../utils/tool-format'
6
+ import { useTerminalSize } from '../hooks/useTerminalSize'
7
+
8
+ interface ActivityTimelineProps {
9
+ toolCalls: ToolCall[]
10
+ detailMode: DetailMode
11
+ isLive?: boolean
12
+ }
13
+
14
+ function getMaxArgLen(columns: number): number {
15
+ if (columns < 60) return 20
16
+ if (columns < 80) return 30
17
+ return 40
18
+ }
19
+
20
+ export const ActivityTimeline = memo(function ActivityTimeline({
21
+ toolCalls,
22
+ detailMode,
23
+ isLive = false,
24
+ }: ActivityTimelineProps) {
25
+ const { columns } = useTerminalSize()
26
+ const maxArgLen = getMaxArgLen(columns)
27
+ const showDetails = detailMode === 'expanded' && isLive
28
+
29
+ if (toolCalls.length === 0) return null
30
+
31
+ return (
32
+ <Box flexDirection="column" paddingLeft={2}>
33
+ {toolCalls.map((call) => {
34
+ const { icon, color: iconColor } = STATUS_CONFIG[call.status]
35
+ const inputStr = formatToolInput(call.name, call.input)
36
+ const truncatedInput = inputStr ? truncate(inputStr, maxArgLen) : ''
37
+ const detailText =
38
+ showDetails && call.result && call.status !== 'running'
39
+ ? truncate(call.result, 120)
40
+ : null
41
+
42
+ return (
43
+ <Box key={call.id} flexDirection="column">
44
+ <Text>
45
+ <Text color={iconColor}>{icon}</Text>
46
+ <Text> </Text>
47
+ <Text color="cyan">{call.name}</Text>
48
+ {truncatedInput && <Text color="gray"> {truncatedInput}</Text>}
49
+ </Text>
50
+ {detailText && (
51
+ <Text color={call.status === 'error' ? 'red' : 'gray'} dimColor>
52
+ {' '}↳ {detailText}
53
+ </Text>
54
+ )}
55
+ </Box>
56
+ )
57
+ })}
58
+ </Box>
59
+ )
60
+ })
@@ -0,0 +1,92 @@
1
+ import { memo, useMemo } from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ import { useAnimationFrame } from '../hooks/useAnimationFrame'
5
+
6
+ export type AnimationMode = 'idle' | 'processing' | 'speaking'
7
+
8
+ interface AsciiOrbProps {
9
+ mode?: AnimationMode
10
+ width?: number
11
+ height?: number
12
+ }
13
+
14
+ // Amp-style dense character set (sparse to dense)
15
+ const CHARS = ' .:=-+*#@'
16
+
17
+ // Mode-specific animation parameters
18
+ const MODE_CONFIG = {
19
+ idle: { fps: 10, amplitude: 0.1, speed: 0.05, color: 'cyan' },
20
+ processing: { fps: 15, amplitude: 0.2, speed: 0.15, color: 'yellow' },
21
+ speaking: { fps: 20, amplitude: 0.25, speed: 0.3, color: 'magenta' },
22
+ } as const
23
+
24
+ /**
25
+ * Renders an animated ASCII orb with radial distance-based character density.
26
+ * The orb appears 3D through careful character selection based on distance from center.
27
+ */
28
+ function AsciiOrbComponent({ mode = 'idle', width = 30, height = 11 }: AsciiOrbProps) {
29
+ const config = MODE_CONFIG[mode]
30
+ const frame = useAnimationFrame({ fps: config.fps, active: true })
31
+
32
+ const lines = useMemo(() => {
33
+ const result: string[] = []
34
+ const centerX = width / 2
35
+ const centerY = height / 2
36
+
37
+ // Terminal characters are ~2x taller than wide
38
+ // To make circle appear round, scale down vertical distance contribution
39
+ const aspectRatio = 2.0
40
+
41
+ for (let y = 0; y < height; y++) {
42
+ let line = ''
43
+
44
+ for (let x = 0; x < width; x++) {
45
+ // Normalized coordinates (-1 to 1)
46
+ const nx = (x - centerX) / centerX
47
+ const ny = (y - centerY) / centerY / aspectRatio
48
+
49
+ // Distance from center (0 at center, 1 at edge)
50
+ const dist = Math.sqrt(nx * nx + ny * ny)
51
+
52
+ // Animation: apply mode-specific oscillation
53
+ let animatedDist: number
54
+
55
+ if (mode === 'speaking') {
56
+ // Bouncy, energetic oscillation with compound sine waves
57
+ const bounce = Math.sin(frame * config.speed) * Math.sin(frame * config.speed * 0.33)
58
+ animatedDist = dist + bounce * config.amplitude
59
+ } else {
60
+ // Simple breathing/pulse
61
+ animatedDist = dist + Math.sin(frame * config.speed) * config.amplitude
62
+ }
63
+
64
+ // Map distance to character (closer = denser)
65
+ if (animatedDist > 1) {
66
+ line += ' '
67
+ } else {
68
+ // Invert so center is dense, edges are sparse
69
+ const normalized = 1 - animatedDist
70
+ const charIndex = Math.floor(normalized * (CHARS.length - 1))
71
+ line += CHARS[Math.min(charIndex, CHARS.length - 1)]
72
+ }
73
+ }
74
+
75
+ result.push(line)
76
+ }
77
+
78
+ return result
79
+ }, [frame, mode, width, height, config])
80
+
81
+ return (
82
+ <Box flexDirection="column" alignItems="center">
83
+ {lines.map((line, i) => (
84
+ <Text key={`orb-line-${i}`} color={config.color}>
85
+ {line}
86
+ </Text>
87
+ ))}
88
+ </Box>
89
+ )
90
+ }
91
+
92
+ export const AsciiOrb = memo(AsciiOrbComponent)
@@ -0,0 +1,44 @@
1
+ import { memo } from 'react'
2
+ import { Box, Static } from 'ink'
3
+
4
+ import type { DetailMode, HistoryEntry } from '../../types'
5
+ import { TurnRow } from './TurnRow'
6
+
7
+ interface ConversationRailProps {
8
+ completedTurns: HistoryEntry[]
9
+ liveTurn: HistoryEntry | null
10
+ detailMode: DetailMode
11
+ maxAnswerLines?: number
12
+ assistantLabel: string
13
+ }
14
+
15
+ export const ConversationRail = memo(function ConversationRail({
16
+ completedTurns,
17
+ liveTurn,
18
+ detailMode,
19
+ maxAnswerLines,
20
+ assistantLabel,
21
+ }: ConversationRailProps) {
22
+ return (
23
+ <Box flexDirection="column">
24
+ <Static items={completedTurns}>
25
+ {(turn) => (
26
+ <Box key={turn.id} marginBottom={1}>
27
+ <TurnRow turn={turn} detailMode="compact" assistantLabel={assistantLabel} />
28
+ </Box>
29
+ )}
30
+ </Static>
31
+ {liveTurn && (
32
+ <Box marginBottom={1}>
33
+ <TurnRow
34
+ turn={liveTurn}
35
+ detailMode={detailMode}
36
+ isLive
37
+ maxAnswerLines={maxAnswerLines}
38
+ assistantLabel={assistantLabel}
39
+ />
40
+ </Box>
41
+ )}
42
+ </Box>
43
+ )
44
+ })
@@ -0,0 +1,61 @@
1
+ import { memo } from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ import type { AppState, LlmModelId, LlmProvider } from '../../types'
5
+ import { useTerminalSize } from '../hooks/useTerminalSize'
6
+ import { formatModelLabel } from '../utils/model-label'
7
+ import { InputPrompt } from './InputPrompt'
8
+ import { MicroOrb } from './MicroOrb'
9
+
10
+ interface FooterProps {
11
+ state: AppState
12
+ onSubmit: (value: string) => void
13
+ inputDisabled: boolean
14
+ model: LlmModelId
15
+ provider: LlmProvider
16
+ canCycleModel: boolean
17
+ }
18
+
19
+ export const Footer = memo(function Footer({
20
+ state,
21
+ onSubmit,
22
+ inputDisabled,
23
+ model,
24
+ provider,
25
+ canCycleModel,
26
+ }: FooterProps) {
27
+ const { columns } = useTerminalSize()
28
+ const modelLabel = formatModelLabel(provider, model)
29
+
30
+ const showModel = columns >= 60
31
+ const showAllHints = columns >= 80
32
+
33
+ return (
34
+ <Box flexDirection="column" marginTop={1}>
35
+ <Box gap={1}>
36
+ <MicroOrb state={state} />
37
+ <InputPrompt onSubmit={onSubmit} disabled={inputDisabled} inline />
38
+ </Box>
39
+ <Box gap={2}>
40
+ {showModel && (
41
+ <Text color="gray" dimColor>
42
+ [{modelLabel}]
43
+ </Text>
44
+ )}
45
+ {showAllHints && canCycleModel && (
46
+ <Text color="gray" dimColor>
47
+ ⇧Tab model
48
+ </Text>
49
+ )}
50
+ {showAllHints && (
51
+ <Text color="gray" dimColor>
52
+ ^O detail
53
+ </Text>
54
+ )}
55
+ <Text color="gray" dimColor>
56
+ ^C
57
+ </Text>
58
+ </Box>
59
+ </Box>
60
+ )
61
+ })
@@ -0,0 +1,88 @@
1
+ import React, { memo, useState, useEffect } from 'react'
2
+ import { Box, Text, useInput } from 'ink'
3
+
4
+ interface InputPromptProps {
5
+ onSubmit: (value: string) => void
6
+ disabled: boolean
7
+ inline?: boolean
8
+ }
9
+
10
+ function Cursor({ visible }: { visible: boolean }): React.ReactNode {
11
+ if (!visible) return null
12
+ return <Text backgroundColor="white"> </Text>
13
+ }
14
+
15
+ export const InputPrompt = memo(function InputPrompt({
16
+ onSubmit,
17
+ disabled,
18
+ inline = false,
19
+ }: InputPromptProps): React.ReactNode {
20
+ const [value, setValue] = useState('')
21
+ const [cursorVisible, setCursorVisible] = useState(true)
22
+
23
+ useEffect(() => {
24
+ if (disabled) return
25
+ const interval = setInterval(() => setCursorVisible((v) => !v), 530)
26
+ return () => clearInterval(interval)
27
+ }, [disabled])
28
+
29
+ useInput(
30
+ (input, key) => {
31
+ if (key.return) {
32
+ const trimmed = value.trim()
33
+ if (trimmed) {
34
+ onSubmit(trimmed)
35
+ setValue('')
36
+ }
37
+ return
38
+ }
39
+
40
+ if (key.tab || input === '\u001b[Z') {
41
+ return
42
+ }
43
+
44
+ if (key.backspace || key.delete) {
45
+ setValue((v) => v.slice(0, -1))
46
+ return
47
+ }
48
+
49
+ // Ctrl+W - delete previous word
50
+ if (key.ctrl && input === 'w') {
51
+ setValue((v) => v.replace(/\S+\s*$/, ''))
52
+ return
53
+ }
54
+
55
+ // Ctrl+U - delete entire line
56
+ if (key.ctrl && input === 'u') {
57
+ setValue('')
58
+ return
59
+ }
60
+
61
+ if (input && !key.ctrl && !key.meta) {
62
+ setValue((v) => v + input)
63
+ }
64
+ },
65
+ { isActive: !disabled },
66
+ )
67
+
68
+ const promptColor = disabled ? 'gray' : 'cyan'
69
+
70
+ const content = disabled ? (
71
+ <>
72
+ <Text color={promptColor}>❯ </Text>
73
+ <Text color="gray">{value || '...'}</Text>
74
+ </>
75
+ ) : (
76
+ <>
77
+ <Text color={promptColor}>❯ </Text>
78
+ <Text>
79
+ {value}
80
+ <Cursor visible={cursorVisible} />
81
+ </Text>
82
+ </>
83
+ )
84
+
85
+ if (inline) return <Box>{content}</Box>
86
+
87
+ return <Box marginTop={1}>{content}</Box>
88
+ })
@@ -0,0 +1,25 @@
1
+ import { memo } from 'react'
2
+ import { Text } from 'ink'
3
+
4
+ import type { AppState } from '../../types'
5
+ import { useAnimationFrame } from '../hooks/useAnimationFrame'
6
+
7
+ const BRAILLE_FRAMES = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] as const
8
+
9
+ interface MicroOrbProps {
10
+ state: AppState
11
+ }
12
+
13
+ export const MicroOrb = memo(function MicroOrb({ state }: MicroOrbProps) {
14
+ const isAnimating = state !== 'idle'
15
+ const frame = useAnimationFrame({ fps: 8, active: isAnimating })
16
+
17
+ if (!isAnimating) {
18
+ return <Text color="green">●</Text>
19
+ }
20
+
21
+ const color = state === 'speaking' || state === 'processing_speaking' ? 'magenta' : 'yellow'
22
+ const char = BRAILLE_FRAMES[frame % BRAILLE_FRAMES.length]
23
+
24
+ return <Text color={color}>{char}</Text>
25
+ })
@@ -0,0 +1,36 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import type { TTSErrorType } from '../../types'
4
+
5
+ interface TTSErrorBannerProps {
6
+ type: TTSErrorType
7
+ message: string
8
+ }
9
+
10
+ interface ErrorConfig {
11
+ icon: string
12
+ hint: string
13
+ }
14
+
15
+ const ERROR_CONFIG: Record<TTSErrorType, ErrorConfig> = {
16
+ command_not_found: {
17
+ icon: '⚠',
18
+ hint: 'Voice output is unavailable',
19
+ },
20
+ audio_playback: { icon: '🔇', hint: 'Audio playback failed' },
21
+ generation_failed: { icon: '🔇', hint: 'Voice synthesis failed' },
22
+ }
23
+
24
+ export function TTSErrorBanner({ type, message }: TTSErrorBannerProps): React.ReactNode {
25
+ const { icon, hint } = ERROR_CONFIG[type]
26
+ const showMessage = Boolean(message)
27
+
28
+ return (
29
+ <Box marginBottom={1}>
30
+ <Text color="yellow">
31
+ {icon} {hint}
32
+ {showMessage && <Text color="gray"> ({message})</Text>}
33
+ </Text>
34
+ </Box>
35
+ )
36
+ }
@@ -0,0 +1,71 @@
1
+ import { memo, useMemo } from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ import type { DetailMode, HistoryEntry } from '../../types'
5
+ import { stripMarkdown } from '../utils/markdown'
6
+ import { truncateLines } from '../utils/text'
7
+ import { ActivityTimeline } from './ActivityTimeline'
8
+
9
+ interface TurnRowProps {
10
+ turn: HistoryEntry
11
+ detailMode: DetailMode
12
+ isLive?: boolean
13
+ maxAnswerLines?: number
14
+ assistantLabel: string
15
+ }
16
+
17
+ export const TurnRow = memo(function TurnRow({
18
+ turn,
19
+ detailMode,
20
+ isLive = false,
21
+ maxAnswerLines,
22
+ assistantLabel,
23
+ }: TurnRowProps) {
24
+ const hasAnswer = Boolean(turn.answer || turn.error)
25
+
26
+ const { displayContent, truncatedCount } = useMemo(() => {
27
+ if (!turn.answer && !turn.error) return { displayContent: '', truncatedCount: 0 }
28
+
29
+ const rawContent = turn.answer ? stripMarkdown(turn.answer) : `Error: ${turn.error}`
30
+ if (!maxAnswerLines || !turn.answer) {
31
+ return { displayContent: rawContent, truncatedCount: 0 }
32
+ }
33
+ const result = truncateLines(rawContent, maxAnswerLines)
34
+ return { displayContent: result.text, truncatedCount: result.truncatedCount }
35
+ }, [turn.answer, turn.error, maxAnswerLines])
36
+
37
+ return (
38
+ <Box flexDirection="column">
39
+ <Text>
40
+ <Text color="cyan" bold>
41
+ you:{' '}
42
+ </Text>
43
+ <Text wrap="wrap">{turn.question}</Text>
44
+ </Text>
45
+
46
+ {turn.toolCalls.length > 0 && (
47
+ <ActivityTimeline toolCalls={turn.toolCalls} detailMode={detailMode} isLive={isLive} />
48
+ )}
49
+
50
+ {truncatedCount > 0 && <Text dimColor>{`⋮ (${truncatedCount} lines above)`}</Text>}
51
+
52
+ {isLive && !hasAnswer ? (
53
+ <Text>
54
+ <Text color="green" bold>
55
+ {assistantLabel}:{' '}
56
+ </Text>
57
+ <Text dimColor>…</Text>
58
+ </Text>
59
+ ) : hasAnswer ? (
60
+ <Text>
61
+ <Text color="green" bold>
62
+ {assistantLabel}:{' '}
63
+ </Text>
64
+ <Text wrap="wrap" color={turn.error ? 'red' : undefined}>
65
+ {displayContent}
66
+ </Text>
67
+ </Text>
68
+ ) : null}
69
+ </Box>
70
+ )
71
+ })
@@ -0,0 +1,78 @@
1
+ import { Box, Text, useInput } from 'ink'
2
+
3
+ import { AsciiOrb, type AnimationMode } from './AsciiOrb'
4
+
5
+ interface WelcomeSplashProps {
6
+ animationMode?: AnimationMode
7
+ assistantLabel?: string
8
+ projectName?: string
9
+ modelLabel?: string
10
+ ttsVoice?: string
11
+ ttsSpeed?: number
12
+ ttsEnabled?: boolean
13
+ onDismiss?: () => void
14
+ }
15
+
16
+ function formatConfigSummary(
17
+ modelLabel?: string,
18
+ ttsVoice?: string,
19
+ ttsSpeed?: number,
20
+ ttsEnabled?: boolean,
21
+ ): string | null {
22
+ const parts: string[] = []
23
+ if (modelLabel) parts.push(modelLabel.toLowerCase())
24
+ if (ttsEnabled && ttsVoice) {
25
+ parts.push(ttsVoice)
26
+ if (ttsSpeed != null) parts.push(`x${ttsSpeed}`)
27
+ }
28
+ return parts.length > 0 ? parts.join(' · ') : null
29
+ }
30
+
31
+ function spaceLetters(name: string): string {
32
+ return name.split('').join(' ')
33
+ }
34
+
35
+ export function WelcomeSplash({
36
+ animationMode = 'idle',
37
+ assistantLabel = 'claude',
38
+ projectName,
39
+ modelLabel,
40
+ ttsVoice,
41
+ ttsSpeed,
42
+ ttsEnabled,
43
+ onDismiss,
44
+ }: WelcomeSplashProps) {
45
+ const configSummary = formatConfigSummary(modelLabel, ttsVoice, ttsSpeed, ttsEnabled)
46
+
47
+ useInput((_input, key) => {
48
+ if (key.return) onDismiss?.()
49
+ })
50
+
51
+ return (
52
+ <Box flexDirection="column" alignItems="center">
53
+ {projectName && (
54
+ <>
55
+ <Text color="gray" dimColor>
56
+ {spaceLetters(projectName)}
57
+ </Text>
58
+ <Text> </Text>
59
+ </>
60
+ )}
61
+ <AsciiOrb mode={animationMode} />
62
+ <Text> </Text>
63
+ <Text color="gray">talk to {assistantLabel}</Text>
64
+ <Text> </Text>
65
+ <Text color="gray" dimColor>
66
+ press enter to continue
67
+ </Text>
68
+ {configSummary && (
69
+ <>
70
+ <Text> </Text>
71
+ <Text color="gray" dimColor>
72
+ {configSummary}
73
+ </Text>
74
+ </>
75
+ )}
76
+ </Box>
77
+ )
78
+ }