@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.
- package/LICENSE +21 -0
- package/README.md +349 -0
- package/assets/orb-logo.svg +75 -0
- package/assets/orb-terminal-session.svg +72 -0
- package/assets/orb-wordmark.svg +77 -0
- package/package.json +76 -0
- package/prompts/anthropic.md +2 -0
- package/prompts/base.md +1 -0
- package/prompts/openai.md +7 -0
- package/prompts/voice.md +12 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +270 -0
- package/src/index.ts +82 -0
- package/src/pipeline/adapters/anthropic.ts +111 -0
- package/src/pipeline/adapters/openai.ts +202 -0
- package/src/pipeline/adapters/types.ts +16 -0
- package/src/pipeline/adapters/utils.ts +131 -0
- package/src/pipeline/frames.ts +113 -0
- package/src/pipeline/observer.ts +36 -0
- package/src/pipeline/observers/metrics.ts +95 -0
- package/src/pipeline/pipeline.ts +43 -0
- package/src/pipeline/processor.ts +57 -0
- package/src/pipeline/processors/agent.ts +38 -0
- package/src/pipeline/processors/tts.ts +120 -0
- package/src/pipeline/task.ts +239 -0
- package/src/pipeline/transports/terminal-text.ts +24 -0
- package/src/pipeline/transports/types.ts +33 -0
- package/src/services/auth-utils.ts +149 -0
- package/src/services/global-config.ts +363 -0
- package/src/services/openai-auth.ts +18 -0
- package/src/services/prompts.ts +76 -0
- package/src/services/provider-defaults.ts +97 -0
- package/src/services/session.ts +204 -0
- package/src/services/streaming-tts.ts +483 -0
- package/src/services/tts.ts +309 -0
- package/src/setup.ts +234 -0
- package/src/types/index.ts +108 -0
- package/src/ui/App.tsx +142 -0
- package/src/ui/components/ActivityTimeline.tsx +60 -0
- package/src/ui/components/AsciiOrb.tsx +92 -0
- package/src/ui/components/ConversationRail.tsx +44 -0
- package/src/ui/components/Footer.tsx +61 -0
- package/src/ui/components/InputPrompt.tsx +88 -0
- package/src/ui/components/MicroOrb.tsx +25 -0
- package/src/ui/components/TTSErrorBanner.tsx +36 -0
- package/src/ui/components/TurnRow.tsx +71 -0
- package/src/ui/components/WelcomeSplash.tsx +78 -0
- package/src/ui/hooks/useAnimationFrame.ts +33 -0
- package/src/ui/hooks/useConversation.ts +195 -0
- package/src/ui/hooks/useKeyboardShortcuts.ts +57 -0
- package/src/ui/hooks/usePipeline.ts +83 -0
- package/src/ui/hooks/useTerminalSize.ts +37 -0
- package/src/ui/utils/markdown.ts +89 -0
- package/src/ui/utils/model-label.ts +20 -0
- package/src/ui/utils/text.ts +18 -0
- 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
|
+
}
|