ethagent 0.2.0 → 1.0.0

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +30 -8
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,193 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { Text, Box } from 'ink'
3
+ import { eyeGradientColor, theme } from './theme.js'
4
+
5
+ const glyphs = {
6
+ ethagent: {
7
+ eth: `░░░░░░░╗░░░░░░░░╗░░╗ ░░╗
8
+ ░░╔════╝╚══░░╔══╝░░║ ░░║
9
+ ░░░░░╗ ░░║ ░░░░░░░║
10
+ ░░╔══╝ ░░║ ░░╔══░░║
11
+ ░░░░░░░╗ ░░║ ░░║ ░░║
12
+ ╚══════╝ ╚═╝ ╚═╝ ╚═╝`,
13
+ a: [
14
+ ` █████╗ `,
15
+ `██╔══██╗`,
16
+ `███████║`,
17
+ `██╔══██║`,
18
+ `██║ ██║`,
19
+ `╚═╝ ╚═╝`,
20
+ ].join('\n'),
21
+ g: [
22
+ ` ██████╗ `,
23
+ `██╔════╝ `,
24
+ `██║ ███╗`,
25
+ `██║ ██║`,
26
+ `╚██████╔╝`,
27
+ ` ╚═════╝ `,
28
+ ].join('\n'),
29
+ e: [
30
+ `███████╗`,
31
+ `██╔════╝`,
32
+ `█████╗ `,
33
+ `██╔══╝ `,
34
+ `███████╗`,
35
+ `╚══════╝`,
36
+ ].join('\n'),
37
+ n: [
38
+ `███╗ ██╗`,
39
+ `████╗ ██║`,
40
+ `██╔██╗ ██║`,
41
+ `██║╚██╗██║`,
42
+ `██║ ╚████║`,
43
+ `╚═╝ ╚═══╝`,
44
+ ].join('\n'),
45
+ t: [
46
+ `████████╗`,
47
+ `╚══██╔══╝`,
48
+ ` ██║ `,
49
+ ` ██║ `,
50
+ ` ██║ `,
51
+ ` ╚═╝ `,
52
+ ].join('\n'),
53
+ },
54
+ eyes: `
55
+ -+:
56
+ :=- -%@@@%.
57
+ *@@@@@#- *@@-
58
+ +@@. +@
59
+ @@= -#=-+++=+:
60
+ #% .:===-: -@* +@@@@%
61
+ *@-+@@@@@: %@@+ @@@=#@
62
+ *@= @@@@@@@- .@.@@@@@@@ :
63
+ @@+=@@@@@@@@@@@@: .% *@@@@@*-=
64
+ #:-@ -@@@@@@@@@-+% @ -@@@- #
65
+ : #+ @@@@@@@- -% =# =
66
+ -@: *@ .+%%
67
+ :%#: --
68
+ .-:
69
+ `,
70
+ tagline: ' privacy-first AI agent with a portable Ethereum identity ',
71
+ ellipsis: '…',
72
+ frame: {
73
+ topLeft: '╔═',
74
+ topRight: '╗',
75
+ side: '║',
76
+ bottomLeft: '╚═',
77
+ bottomRight: '╝',
78
+ horizontal: '═',
79
+ },
80
+ } as const
81
+
82
+ const ethagentGlyphOrder = ['eth', 'a', 'g', 'e', 'n', 't'] as const
83
+
84
+ const Eyes = () => {
85
+ const lines = glyphs.eyes.split('\n')
86
+ return (
87
+ <Box flexDirection="column">
88
+ {lines.map((line, li) => {
89
+ const glyphPositions = [...line]
90
+ .map((char, index) => ({ char, index }))
91
+ .filter(entry => entry.char.trim().length > 0)
92
+ .map(entry => entry.index)
93
+ const firstGlyph = glyphPositions[0] ?? 0
94
+ const lastGlyph = glyphPositions[glyphPositions.length - 1] ?? firstGlyph
95
+ const span = Math.max(lastGlyph - firstGlyph, 1)
96
+
97
+ return (
98
+ <Text key={li}>
99
+ {[...line].map((char, ci) => {
100
+ if (!char.trim()) {
101
+ return <Text key={ci}>{char}</Text>
102
+ }
103
+ const t = (ci - firstGlyph) / span
104
+ return <Text key={ci} color={eyeGradientColor(t)}>{char}</Text>
105
+ })}
106
+ </Text>
107
+ )
108
+ })}
109
+ </Box>
110
+ )
111
+ }
112
+
113
+ type SplashProps = {
114
+ contextLine?: string
115
+ tipLine?: string
116
+ compact?: boolean
117
+ }
118
+
119
+ export const BrandSplash: React.FC<SplashProps> = ({ contextLine, tipLine, compact }) => {
120
+ const [width, setWidth] = useState<number>(() => process.stdout.columns ?? 80)
121
+
122
+ useEffect(() => {
123
+ const stdout = process.stdout
124
+ const handleResize = () => setWidth(stdout.columns ?? 80)
125
+ stdout.on('resize', handleResize)
126
+ return () => {
127
+ stdout.off('resize', handleResize)
128
+ }
129
+ }, [])
130
+
131
+ const renderCompact = compact ?? width < 72
132
+
133
+ if (renderCompact) {
134
+ return (
135
+ <Box flexDirection="column" alignSelf="flex-start" padding={1}>
136
+ <Eyes />
137
+ <Text bold color={theme.accentPrimary}>ethagent</Text>
138
+ <Text color={theme.dim}>{glyphs.tagline.trim()}</Text>
139
+ {contextLine ? <Text color={theme.dim}>{contextLine}</Text> : null}
140
+ {tipLine ? <Text color={theme.dim}>{tipLine}</Text> : null}
141
+ </Box>
142
+ )
143
+ }
144
+
145
+ const logoLines = ethagentGlyphOrder.map(key => glyphs.ethagent[key].split('\n'))
146
+ const rowCount = Math.max(...logoLines.map(lines => lines.length))
147
+
148
+ const w = 69
149
+ const topPad = Math.max(0, w - glyphs.tagline.length - 1)
150
+
151
+ const bottomInline = contextLine ? ` ${truncateToFit(contextLine, w - 4)} ` : ''
152
+ const bottomPad = Math.max(0, w - bottomInline.length - 1)
153
+
154
+ return (
155
+ <Box flexDirection="column" alignSelf="flex-start" padding={1}>
156
+ <Eyes />
157
+ <Text>
158
+ <Text color={theme.border}>{glyphs.frame.topLeft}</Text>
159
+ <Text color={theme.dim}>{glyphs.tagline}</Text>
160
+ <Text color={theme.border}>{glyphs.frame.horizontal.repeat(topPad)}{glyphs.frame.topRight}</Text>
161
+ </Text>
162
+ {Array.from({ length: rowCount }, (_, i) => (
163
+ <Box key={i}>
164
+ <Text color={theme.border}>{glyphs.frame.side}</Text>
165
+ {logoLines.map((lines, index) => (
166
+ <Text key={ethagentGlyphOrder[index]} color={theme.border}>{lines[i] ?? ''}</Text>
167
+ ))}
168
+ <Text color={theme.border}>{glyphs.frame.side}</Text>
169
+ </Box>
170
+ ))}
171
+ {bottomInline ? (
172
+ <Text>
173
+ <Text color={theme.border}>{glyphs.frame.bottomLeft}</Text>
174
+ <Text color={theme.accentMint}>{bottomInline}</Text>
175
+ <Text color={theme.border}>{glyphs.frame.horizontal.repeat(bottomPad)}{glyphs.frame.bottomRight}</Text>
176
+ </Text>
177
+ ) : (
178
+ <Text color={theme.border}>{glyphs.frame.bottomLeft.slice(0, 1) + glyphs.frame.horizontal.repeat(w) + glyphs.frame.bottomRight}</Text>
179
+ )}
180
+ {tipLine ? (
181
+ <Box marginTop={1}>
182
+ <Text color={theme.dim}>{tipLine}</Text>
183
+ </Box>
184
+ ) : null}
185
+ </Box>
186
+ )
187
+ }
188
+
189
+ function truncateToFit(text: string, max: number): string {
190
+ if (text.length <= max) return text
191
+ if (max <= 1) return text.slice(0, Math.max(0, max))
192
+ return text.slice(0, max - 1) + glyphs.ellipsis
193
+ }
@@ -0,0 +1,34 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { theme, gradientColor, eyeGradientColor } from './theme.js'
4
+
5
+ type ProgressBarProps = {
6
+ progress: number
7
+ width?: number
8
+ label?: string
9
+ suffix?: string
10
+ variant?: 'default' | 'rainbow'
11
+ }
12
+
13
+ export const ProgressBar: React.FC<ProgressBarProps> = ({ progress, width = 40, label, suffix, variant = 'default' }) => {
14
+ const p = Math.max(0, Math.min(1, progress))
15
+ const filled = Math.round(p * width)
16
+ const empty = Math.max(0, width - filled)
17
+ const colorFor = variant === 'rainbow' ? eyeGradientColor : gradientColor
18
+ const cells: React.ReactElement[] = []
19
+ for (let i = 0; i < filled; i++) {
20
+ cells.push(
21
+ <Text key={`f-${i}`} color={colorFor(i / Math.max(width - 1, 1))}>█</Text>,
22
+ )
23
+ }
24
+ for (let i = 0; i < empty; i++) {
25
+ cells.push(<Text key={`e-${i}`} color={theme.border}>░</Text>)
26
+ }
27
+ return (
28
+ <Box>
29
+ {label ? <Text color={theme.dim}>{label} </Text> : null}
30
+ <Text>{cells}</Text>
31
+ <Text color={theme.dim}> {Math.round(p * 100)}%{suffix ? ` · ${suffix}` : ''}</Text>
32
+ </Box>
33
+ )
34
+ }
@@ -0,0 +1,143 @@
1
+ import React, { useState } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { theme } from './theme.js'
4
+ import { useAppInput } from '../app/input/AppInputProvider.js'
5
+
6
+ export type SelectOption<T> = {
7
+ value: T
8
+ label: string
9
+ subtext?: string
10
+ hint?: string
11
+ disabled?: boolean
12
+ role?: 'section' | 'group' | 'notice' | 'option' | 'utility'
13
+ prefix?: string
14
+ labelColor?: string
15
+ subtextColor?: string
16
+ hintColor?: string
17
+ bold?: boolean
18
+ indent?: number
19
+ }
20
+
21
+ type SelectProps<T> = {
22
+ label?: string
23
+ options: Array<SelectOption<T>>
24
+ initialIndex?: number
25
+ maxVisible?: number
26
+ hintLayout?: 'below' | 'inline'
27
+ onSubmit: (value: T) => void
28
+ onCancel?: () => void
29
+ onHighlight?: (value: T) => void
30
+ }
31
+
32
+ export function Select<T>({
33
+ label,
34
+ options,
35
+ initialIndex = 0,
36
+ maxVisible,
37
+ hintLayout = 'below',
38
+ onSubmit,
39
+ onCancel,
40
+ onHighlight,
41
+ }: SelectProps<T>) {
42
+ const firstEnabled = Math.max(0, options.findIndex(isSelectableOption))
43
+ const start = isSelectableOption(options[initialIndex]) ? initialIndex : firstEnabled
44
+ const [index, setIndex] = useState(start === -1 ? 0 : start)
45
+ const visibleCount = Math.max(1, maxVisible ?? options.length)
46
+ const windowStart = Math.max(0, Math.min(
47
+ index - Math.floor(visibleCount / 2),
48
+ Math.max(0, options.length - visibleCount),
49
+ ))
50
+ const windowEnd = Math.min(options.length, windowStart + visibleCount)
51
+ const visibleOptions = options.slice(windowStart, windowEnd)
52
+ const hasAbove = windowStart > 0
53
+ const hasBelow = windowEnd < options.length
54
+ const usesInlineSections = hintLayout === 'inline' && options.some(option => option.role === 'section' || option.role === 'group')
55
+
56
+ const moveBy = (delta: number) => {
57
+ if (options.length === 0) return
58
+ let next = index
59
+ for (let i = 0; i < options.length; i += 1) {
60
+ next = (next + delta + options.length) % options.length
61
+ const candidate = options[next]
62
+ if (isSelectableOption(candidate)) {
63
+ setIndex(next)
64
+ onHighlight?.(candidate.value)
65
+ return
66
+ }
67
+ }
68
+ }
69
+
70
+ useAppInput((input, key) => {
71
+ if (key.upArrow || input === 'k') moveBy(-1)
72
+ else if (key.downArrow || input === 'j') moveBy(1)
73
+ else if (key.return) {
74
+ const selected = options[index]
75
+ if (isSelectableOption(selected)) onSubmit(selected.value)
76
+ } else if (key.escape) {
77
+ onCancel?.()
78
+ }
79
+ })
80
+
81
+ return (
82
+ <Box flexDirection="column">
83
+ {label ? <Text color={theme.dim}>{label}</Text> : null}
84
+ {hasAbove ? (
85
+ <Text color={theme.dim}>{`^ ${windowStart} earlier item${windowStart === 1 ? '' : 's'}`}</Text>
86
+ ) : null}
87
+ {visibleOptions.map((option, visibleIndex) => {
88
+ const absoluteIndex = windowStart + visibleIndex
89
+ const isActive = absoluteIndex === index
90
+ const selectable = isSelectableOption(option)
91
+ const cursor = !selectable ? ' ' : isActive ? '>' : ' '
92
+ const isSection = option.role === 'section' || option.role === 'group'
93
+ const prefix = option.prefix && !isSection ? `${option.prefix} ` : ''
94
+ const rowIndent = option.indent ?? (usesInlineSections ? isSection ? 1 : 3 : 0)
95
+ const prefixColor = option.disabled
96
+ ? option.labelColor ?? theme.border
97
+ : isActive && selectable
98
+ ? theme.accentPrimary
99
+ : option.labelColor ?? theme.dim
100
+ const labelColor = isSection
101
+ ? option.labelColor ?? theme.dim
102
+ : isActive && selectable
103
+ ? theme.accentPrimary
104
+ : option.labelColor ?? (option.disabled ? theme.dim : theme.text)
105
+ const hintColor = isActive && selectable
106
+ ? theme.textSubtle
107
+ : option.hintColor ?? theme.dim
108
+ const subtextColor = option.subtextColor ?? theme.dim
109
+ const bold = option.bold ?? (isSection || (isActive && selectable))
110
+ const inlineHint = Boolean(option.hint && hintLayout === 'inline' && !isSection)
111
+ const belowHint = Boolean(option.hint && (!inlineHint || isSection))
112
+ return (
113
+ <Box key={absoluteIndex} flexDirection="column">
114
+ <Box flexDirection="row" marginLeft={rowIndent}>
115
+ <Text color={prefixColor}>{cursor} </Text>
116
+ {prefix ? <Text color={prefixColor}>{prefix}</Text> : null}
117
+ <Text color={labelColor} bold={bold}>{option.label}</Text>
118
+ {inlineHint ? <Text color={hintColor}> {option.hint}</Text> : null}
119
+ </Box>
120
+ {option.subtext ? (
121
+ <Box marginLeft={2 + rowIndent}>
122
+ <Text color={subtextColor}>{option.subtext}</Text>
123
+ </Box>
124
+ ) : null}
125
+ {belowHint ? (
126
+ <Box marginLeft={2 + rowIndent}>
127
+ <Text color={hintColor}>{option.hint}</Text>
128
+ </Box>
129
+ ) : null}
130
+ </Box>
131
+ )
132
+ })}
133
+ {hasBelow ? (
134
+ <Text color={theme.dim}>{`v ${options.length - windowEnd} more item${options.length - windowEnd === 1 ? '' : 's'}`}</Text>
135
+ ) : null}
136
+ </Box>
137
+ )
138
+ }
139
+
140
+ function isSelectableOption<T>(option: SelectOption<T> | undefined): option is SelectOption<T> & { value: T } {
141
+ if (!option || option.disabled) return false
142
+ return option.role !== 'section' && option.role !== 'group' && option.role !== 'notice'
143
+ }
@@ -0,0 +1,269 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import { Text } from 'ink'
3
+ import { theme } from './theme.js'
4
+
5
+ export const SPINNER_VERBS: string[] = [
6
+ 'accomplishing',
7
+ 'actioning',
8
+ 'actualizing',
9
+ 'architecting',
10
+ 'baking',
11
+ 'beaming',
12
+ "beboppin'",
13
+ 'befuddling',
14
+ 'billowing',
15
+ 'blanching',
16
+ 'bloviating',
17
+ 'boogieing',
18
+ 'boondoggling',
19
+ 'booping',
20
+ 'bootstrapping',
21
+ 'brewing',
22
+ 'bunning',
23
+ 'burrowing',
24
+ 'calculating',
25
+ 'canoodling',
26
+ 'caramelizing',
27
+ 'cascading',
28
+ 'catapulting',
29
+ 'cerebrating',
30
+ 'channeling',
31
+ 'choreographing',
32
+ 'churning',
33
+ 'coalescing',
34
+ 'cogitating',
35
+ 'combobulating',
36
+ 'composing',
37
+ 'computing',
38
+ 'concocting',
39
+ 'considering',
40
+ 'contemplating',
41
+ 'cooking',
42
+ 'crafting',
43
+ 'creating',
44
+ 'crunching',
45
+ 'crystallizing',
46
+ 'cultivating',
47
+ 'deciphering',
48
+ 'deliberating',
49
+ 'determining',
50
+ 'dilly-dallying',
51
+ 'discombobulating',
52
+ 'doing',
53
+ 'doodling',
54
+ 'drizzling',
55
+ 'ebbing',
56
+ 'effecting',
57
+ 'elucidating',
58
+ 'embellishing',
59
+ 'enchanting',
60
+ 'envisioning',
61
+ 'evaporating',
62
+ 'fermenting',
63
+ 'fiddle-faddling',
64
+ 'finagling',
65
+ 'flambéing',
66
+ 'flibbertigibbeting',
67
+ 'flowing',
68
+ 'flummoxing',
69
+ 'fluttering',
70
+ 'forging',
71
+ 'forming',
72
+ 'frolicking',
73
+ 'frosting',
74
+ 'gallivanting',
75
+ 'galloping',
76
+ 'garnishing',
77
+ 'generating',
78
+ 'gesticulating',
79
+ 'germinating',
80
+ 'grooving',
81
+ 'gusting',
82
+ 'harmonizing',
83
+ 'hashing',
84
+ 'hatching',
85
+ 'herding',
86
+ 'honking',
87
+ 'hullaballooing',
88
+ 'hyperspacing',
89
+ 'ideating',
90
+ 'imagining',
91
+ 'improvising',
92
+ 'incubating',
93
+ 'inferring',
94
+ 'infusing',
95
+ 'ionizing',
96
+ 'jitterbugging',
97
+ 'julienning',
98
+ 'kneading',
99
+ 'leavening',
100
+ 'levitating',
101
+ 'lollygagging',
102
+ 'manifesting',
103
+ 'marinating',
104
+ 'meandering',
105
+ 'metamorphosing',
106
+ 'misting',
107
+ 'moonwalking',
108
+ 'moseying',
109
+ 'mulling',
110
+ 'mustering',
111
+ 'musing',
112
+ 'nebulizing',
113
+ 'nesting',
114
+ 'noodling',
115
+ 'nucleating',
116
+ 'orbiting',
117
+ 'orchestrating',
118
+ 'osmosing',
119
+ 'perambulating',
120
+ 'percolating',
121
+ 'perusing',
122
+ 'philosophising',
123
+ 'photosynthesizing',
124
+ 'pollinating',
125
+ 'pondering',
126
+ 'pontificating',
127
+ 'pouncing',
128
+ 'precipitating',
129
+ 'prestidigitating',
130
+ 'processing',
131
+ 'proofing',
132
+ 'propagating',
133
+ 'puttering',
134
+ 'puzzling',
135
+ 'quantumizing',
136
+ 'razzle-dazzling',
137
+ 'razzmatazzing',
138
+ 'recombobulating',
139
+ 'reticulating',
140
+ 'roosting',
141
+ 'ruminating',
142
+ 'sautéing',
143
+ 'scampering',
144
+ 'schlepping',
145
+ 'scurrying',
146
+ 'seasoning',
147
+ 'shenaniganing',
148
+ 'shimmying',
149
+ 'simmering',
150
+ 'skedaddling',
151
+ 'sketching',
152
+ 'slithering',
153
+ 'smooshing',
154
+ 'sock-hopping',
155
+ 'spelunking',
156
+ 'spinning',
157
+ 'sprouting',
158
+ 'stewing',
159
+ 'sublimating',
160
+ 'swirling',
161
+ 'swooping',
162
+ 'symbioting',
163
+ 'synthesizing',
164
+ 'tempering',
165
+ 'thinking',
166
+ 'thundering',
167
+ 'tinkering',
168
+ 'tomfoolering',
169
+ 'topsy-turvying',
170
+ 'transfiguring',
171
+ 'transmuting',
172
+ 'twisting',
173
+ 'undulating',
174
+ 'unfurling',
175
+ 'unravelling',
176
+ 'vibing',
177
+ 'waddling',
178
+ 'wandering',
179
+ 'warping',
180
+ 'whatchamacalliting',
181
+ 'whirlpooling',
182
+ 'whirring',
183
+ 'whisking',
184
+ 'wibbling',
185
+ 'working',
186
+ 'wrangling',
187
+ 'zesting',
188
+ 'zigzagging',
189
+ ]
190
+
191
+ export function pickVerb(): string {
192
+ const idx = Math.floor(Math.random() * SPINNER_VERBS.length)
193
+ return SPINNER_VERBS[idx] ?? 'thinking'
194
+ }
195
+
196
+ type SpinnerProps = {
197
+ active?: boolean
198
+ hint?: string
199
+ label?: string
200
+ verb?: string
201
+ color?: string
202
+ startedAt?: number
203
+ showElapsed?: boolean
204
+ }
205
+
206
+ const FRAMES = ['.', 'o', 'O', 'o']
207
+
208
+ export const Spinner: React.FC<SpinnerProps> = ({
209
+ active = true,
210
+ hint: rawHint,
211
+ label,
212
+ verb,
213
+ color = theme.accentSecondary,
214
+ startedAt,
215
+ showElapsed = true,
216
+ }) => {
217
+ const stickyVerbRef = useRef<string | null>(null)
218
+ const internalStartedAtRef = useRef<number>(Date.now())
219
+ const [frame, setFrame] = useState(0)
220
+
221
+ useEffect(() => {
222
+ if (!active) {
223
+ stickyVerbRef.current = null
224
+ internalStartedAtRef.current = Date.now()
225
+ return
226
+ }
227
+
228
+ if (label === undefined && stickyVerbRef.current === null) {
229
+ stickyVerbRef.current = verb ?? pickVerb()
230
+ }
231
+ }, [active, verb, label])
232
+
233
+ useEffect(() => {
234
+ if (!active) return
235
+ internalStartedAtRef.current = startedAt ?? Date.now()
236
+ }, [active, startedAt, label, verb])
237
+
238
+ useEffect(() => {
239
+ if (!active) return
240
+ const timer = setInterval(() => {
241
+ setFrame(prev => (prev + 1) % FRAMES.length)
242
+ }, 120)
243
+ return () => clearInterval(timer)
244
+ }, [active])
245
+
246
+ if (!active) return null
247
+
248
+ const autoLabel = stickyVerbRef.current ?? verb ?? 'thinking'
249
+ const text = label ?? `${autoLabel}…`
250
+ const glyph = FRAMES[frame] ?? 'o'
251
+ const elapsed = showElapsed ? formatElapsedSeconds(Date.now() - (startedAt ?? internalStartedAtRef.current)) : null
252
+ const renderedHint = [rawHint, elapsed].filter(Boolean).join(' · ')
253
+ const hint = renderedHint
254
+
255
+ return (
256
+ <Text>
257
+ <Text color={color}>{glyph}</Text>
258
+ <Text color={theme.dim}> {text}</Text>
259
+ {hint ? <Text color={theme.dim}> · {hint}</Text> : null}
260
+ </Text>
261
+ )
262
+ }
263
+
264
+ function formatElapsedSeconds(milliseconds: number): string {
265
+ const seconds = Math.max(0, Math.floor(milliseconds / 1000))
266
+ if (seconds < 60) return `${seconds}s`
267
+ const minutes = Math.floor(seconds / 60)
268
+ return `${minutes}:${(seconds % 60).toString().padStart(2, '0')}`
269
+ }
@@ -0,0 +1,47 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { theme } from './theme.js'
4
+
5
+ type SurfaceTone = 'primary' | 'muted' | 'error'
6
+
7
+ type SurfaceProps = {
8
+ title: string
9
+ subtitle?: React.ReactNode
10
+ footer?: React.ReactNode
11
+ tone?: SurfaceTone
12
+ children?: React.ReactNode
13
+ }
14
+
15
+ const toneColor: Record<SurfaceTone, string> = {
16
+ primary: theme.accentPrimary,
17
+ muted: theme.border,
18
+ error: '#e87070',
19
+ }
20
+
21
+ export const Surface: React.FC<SurfaceProps> = ({
22
+ title,
23
+ subtitle,
24
+ footer,
25
+ tone = 'primary',
26
+ children,
27
+ }) => {
28
+ const borderColor = toneColor[tone]
29
+ return (
30
+ <Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={2} paddingY={0}>
31
+ <Box flexDirection="column">
32
+ <Text color={borderColor} bold>{title}</Text>
33
+ {subtitle ? (
34
+ typeof subtitle === 'string'
35
+ ? <Text color={theme.dim}>{subtitle}</Text>
36
+ : subtitle
37
+ ) : null}
38
+ </Box>
39
+ {children ? <Box flexDirection="column" marginTop={1}>{children}</Box> : null}
40
+ {footer ? (
41
+ <Box marginTop={1} borderTop={false}>
42
+ <Text color={theme.dim}>{footer}</Text>
43
+ </Box>
44
+ ) : null}
45
+ </Box>
46
+ )
47
+ }