ethagent 0.2.1 → 1.0.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 +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- 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
|
+
}
|