miii-cli 0.1.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.
- package/.claude/settings.local.json +18 -0
- package/Makefile +13 -0
- package/README.md +182 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +24 -0
- package/dist/config.js.map +1 -0
- package/dist/files/ops.d.ts +11 -0
- package/dist/files/ops.js +66 -0
- package/dist/files/ops.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +32 -0
- package/dist/init.js.map +1 -0
- package/dist/llm/ollama.d.ts +9 -0
- package/dist/llm/ollama.js +51 -0
- package/dist/llm/ollama.js.map +1 -0
- package/dist/llm/stream.d.ts +12 -0
- package/dist/llm/stream.js +129 -0
- package/dist/llm/stream.js.map +1 -0
- package/dist/parser/stream-parser.d.ts +17 -0
- package/dist/parser/stream-parser.js +54 -0
- package/dist/parser/stream-parser.js.map +1 -0
- package/dist/sessions.d.ts +9 -0
- package/dist/sessions.js +48 -0
- package/dist/sessions.js.map +1 -0
- package/dist/skills/loader.d.ts +23 -0
- package/dist/skills/loader.js +91 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +79 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tui/App.d.ts +9 -0
- package/dist/tui/App.js +259 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/InputBar.d.ts +10 -0
- package/dist/tui/InputBar.js +289 -0
- package/dist/tui/InputBar.js.map +1 -0
- package/dist/tui/components/AtPicker.d.ts +8 -0
- package/dist/tui/components/AtPicker.js +19 -0
- package/dist/tui/components/AtPicker.js.map +1 -0
- package/dist/tui/components/CommandPalette.d.ts +8 -0
- package/dist/tui/components/CommandPalette.js +25 -0
- package/dist/tui/components/CommandPalette.js.map +1 -0
- package/dist/tui/components/InputArea.d.ts +11 -0
- package/dist/tui/components/InputArea.js +268 -0
- package/dist/tui/components/InputArea.js.map +1 -0
- package/dist/tui/components/MessageList.d.ts +10 -0
- package/dist/tui/components/MessageList.js +98 -0
- package/dist/tui/components/MessageList.js.map +1 -0
- package/dist/tui/components/ModelPicker.d.ts +18 -0
- package/dist/tui/components/ModelPicker.js +74 -0
- package/dist/tui/components/ModelPicker.js.map +1 -0
- package/dist/tui/components/StatusBar.d.ts +12 -0
- package/dist/tui/components/StatusBar.js +15 -0
- package/dist/tui/components/StatusBar.js.map +1 -0
- package/dist/tui/printer.d.ts +7 -0
- package/dist/tui/printer.js +106 -0
- package/dist/tui/printer.js.map +1 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/workers/context.worker.d.ts +1 -0
- package/dist/workers/context.worker.js +69 -0
- package/dist/workers/context.worker.js.map +1 -0
- package/dist/workers/diff.worker.d.ts +1 -0
- package/dist/workers/diff.worker.js +12 -0
- package/dist/workers/diff.worker.js.map +1 -0
- package/dist/workers/spawn.d.ts +1 -0
- package/dist/workers/spawn.js +18 -0
- package/dist/workers/spawn.js.map +1 -0
- package/install.sh +6 -0
- package/package.json +29 -0
- package/src/config.ts +25 -0
- package/src/files/ops.ts +71 -0
- package/src/index.ts +11 -0
- package/src/init.ts +39 -0
- package/src/llm/ollama.ts +58 -0
- package/src/llm/stream.ts +118 -0
- package/src/parser/stream-parser.ts +54 -0
- package/src/sessions.ts +46 -0
- package/src/skills/loader.ts +109 -0
- package/src/tools/index.ts +83 -0
- package/src/tui/App.tsx +308 -0
- package/src/tui/InputBar.tsx +347 -0
- package/src/tui/components/AtPicker.tsx +49 -0
- package/src/tui/components/CommandPalette.tsx +50 -0
- package/src/tui/components/InputArea.tsx +285 -0
- package/src/tui/components/MessageList.tsx +192 -0
- package/src/tui/components/ModelPicker.tsx +134 -0
- package/src/tui/components/StatusBar.tsx +36 -0
- package/src/tui/printer.ts +121 -0
- package/src/types.ts +25 -0
- package/src/workers/context.worker.ts +62 -0
- package/src/workers/diff.worker.ts +20 -0
- package/src/workers/spawn.ts +19 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
|
+
import { Box, Text, useStdout } from 'ink'
|
|
3
|
+
import { InputArea } from './components/InputArea.js'
|
|
4
|
+
import { ModelPicker } from './components/ModelPicker.js'
|
|
5
|
+
import { Divider } from './components/StatusBar.js'
|
|
6
|
+
import { stream } from '../llm/stream.js'
|
|
7
|
+
import { listModels, pullModel } from '../llm/ollama.js'
|
|
8
|
+
import type { OllamaModel } from '../llm/ollama.js'
|
|
9
|
+
import { StreamParser } from '../parser/stream-parser.js'
|
|
10
|
+
import { tools, getSystemPrompt } from '../tools/index.js'
|
|
11
|
+
import { readFile } from '../files/ops.js'
|
|
12
|
+
import type { SkillLoader } from '../skills/loader.js'
|
|
13
|
+
import type { Status, ChatMessage, Config } from '../types.js'
|
|
14
|
+
import * as printer from './printer.js'
|
|
15
|
+
import { loadSession, saveSession, listSessions } from '../sessions.js'
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
config: Config
|
|
19
|
+
skills: SkillLoader
|
|
20
|
+
cwd: string
|
|
21
|
+
session: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_TOOL_DEPTH = 6
|
|
25
|
+
const THROTTLE_MS = 40
|
|
26
|
+
const PREVIEW_LINES = 8
|
|
27
|
+
|
|
28
|
+
function lastLines(text: string, n: number): string[] {
|
|
29
|
+
return text.split('\n').slice(-n)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildAtContext(text: string): string {
|
|
33
|
+
const refs = [...text.matchAll(/@([\w./\-]+)/g)]
|
|
34
|
+
if (!refs.length) return ''
|
|
35
|
+
const parts: string[] = []
|
|
36
|
+
for (const m of refs) {
|
|
37
|
+
try {
|
|
38
|
+
const content = readFile(m[1])
|
|
39
|
+
if (content) parts.push(`<file path="${m[1]}">\n${content}\n</file>`)
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
return parts.length ? parts.join('\n\n') + '\n\n' : ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function InputBar({ config, skills, cwd, session }: Props) {
|
|
46
|
+
const { stdout } = useStdout()
|
|
47
|
+
const cols = stdout.columns ?? 80
|
|
48
|
+
|
|
49
|
+
const [status, setStatus] = useState<Status>('idle')
|
|
50
|
+
const [tick, setTick] = useState(0)
|
|
51
|
+
const [currentModel, setCurrentModel] = useState(config.model)
|
|
52
|
+
const [streamPreview, setStreamPreview] = useState('')
|
|
53
|
+
const [sessionName, setSessionName] = useState(session)
|
|
54
|
+
|
|
55
|
+
// picker opens on mount — force model selection every launch
|
|
56
|
+
const [pickerOpen, setPickerOpen] = useState(true)
|
|
57
|
+
const [pickerModels, setPickerModels] = useState<OllamaModel[]>([])
|
|
58
|
+
const [pickerLoading, setPickerLoading] = useState(false)
|
|
59
|
+
const [pickerError, setPickerError] = useState<string | undefined>()
|
|
60
|
+
const [pullState, setPullState] = useState<{ name: string; status: string; pct: number | undefined } | undefined>()
|
|
61
|
+
|
|
62
|
+
const abortRef = useRef<AbortController | null>(null)
|
|
63
|
+
const pullAbortRef = useRef<AbortController | null>(null)
|
|
64
|
+
const tokenBufRef = useRef('')
|
|
65
|
+
const lastRenderRef = useRef(0)
|
|
66
|
+
const systemPromptRef = useRef(getSystemPrompt(`\n- CWD: ${cwd}`))
|
|
67
|
+
const currentModelRef = useRef(currentModel)
|
|
68
|
+
const sessionNameRef = useRef(sessionName)
|
|
69
|
+
const historyRef = useRef<ChatMessage[]>([])
|
|
70
|
+
|
|
71
|
+
useEffect(() => { currentModelRef.current = currentModel }, [currentModel])
|
|
72
|
+
useEffect(() => { sessionNameRef.current = sessionName }, [sessionName])
|
|
73
|
+
|
|
74
|
+
// mount: load session history + fetch models for initial picker
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const history = loadSession(session)
|
|
77
|
+
historyRef.current = history
|
|
78
|
+
if (history.length) {
|
|
79
|
+
printer.systemMsg(`resumed "${session}" — ${history.length} messages`)
|
|
80
|
+
}
|
|
81
|
+
setPickerLoading(true)
|
|
82
|
+
listModels(config.baseUrl)
|
|
83
|
+
.then(m => { setPickerModels(m); setPickerLoading(false) })
|
|
84
|
+
.catch(e => { setPickerError(String(e)); setPickerLoading(false) })
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (status === 'idle') return
|
|
89
|
+
const t = setInterval(() => setTick(n => n + 1), 80)
|
|
90
|
+
return () => clearInterval(t)
|
|
91
|
+
}, [status])
|
|
92
|
+
|
|
93
|
+
function buildContext(extra?: ChatMessage): ChatMessage[] {
|
|
94
|
+
const ctx: ChatMessage[] = [{ role: 'system', content: systemPromptRef.current }]
|
|
95
|
+
ctx.push(...historyRef.current)
|
|
96
|
+
if (extra) ctx.push(extra)
|
|
97
|
+
return ctx
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const runLoop = useCallback(async (contextMsgs: ChatMessage[], depth = 0) => {
|
|
101
|
+
if (depth >= MAX_TOOL_DEPTH) { setStatus('idle'); return }
|
|
102
|
+
setStatus('streaming')
|
|
103
|
+
setStreamPreview('')
|
|
104
|
+
|
|
105
|
+
const parser = new StreamParser()
|
|
106
|
+
const pendingTools: Array<{ name: string; args: Record<string, unknown> }> = []
|
|
107
|
+
let fullText = ''
|
|
108
|
+
|
|
109
|
+
abortRef.current = new AbortController()
|
|
110
|
+
|
|
111
|
+
await stream({
|
|
112
|
+
provider: config.provider,
|
|
113
|
+
model: currentModelRef.current,
|
|
114
|
+
baseUrl: config.baseUrl,
|
|
115
|
+
messages: contextMsgs,
|
|
116
|
+
signal: abortRef.current.signal,
|
|
117
|
+
|
|
118
|
+
onToken(token) {
|
|
119
|
+
fullText += token
|
|
120
|
+
tokenBufRef.current += token
|
|
121
|
+
const now = Date.now()
|
|
122
|
+
if (now - lastRenderRef.current >= THROTTLE_MS) {
|
|
123
|
+
setStreamPreview(fullText)
|
|
124
|
+
tokenBufRef.current = ''
|
|
125
|
+
lastRenderRef.current = now
|
|
126
|
+
}
|
|
127
|
+
for (const item of parser.feed(token)) {
|
|
128
|
+
if (item.type === 'tool_call') pendingTools.push({ name: item.toolName, args: item.toolArgs })
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async onDone() {
|
|
133
|
+
setStreamPreview(fullText)
|
|
134
|
+
for (const item of parser.flush()) {
|
|
135
|
+
if (item.type === 'tool_call') pendingTools.push({ name: item.toolName, args: item.toolArgs })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
printer.assistantMsg(fullText)
|
|
139
|
+
setStreamPreview('')
|
|
140
|
+
|
|
141
|
+
historyRef.current.push({ role: 'assistant', content: fullText })
|
|
142
|
+
saveSession(sessionNameRef.current, historyRef.current)
|
|
143
|
+
|
|
144
|
+
if (!pendingTools.length) { setStatus('idle'); return }
|
|
145
|
+
|
|
146
|
+
setStatus('tool')
|
|
147
|
+
const next: ChatMessage[] = [...contextMsgs, { role: 'assistant', content: fullText }]
|
|
148
|
+
|
|
149
|
+
for (const tc of pendingTools) {
|
|
150
|
+
const tool = tools.find(t => t.name === tc.name)
|
|
151
|
+
if (tool) {
|
|
152
|
+
try {
|
|
153
|
+
const result = await tool.execute(tc.args)
|
|
154
|
+
printer.toolMsg(tc.name, result)
|
|
155
|
+
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` })
|
|
156
|
+
} catch (e) {
|
|
157
|
+
const err = `Tool ${tc.name} error: ${e}`
|
|
158
|
+
printer.errorMsg(err)
|
|
159
|
+
next.push({ role: 'user', content: err })
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
printer.errorMsg(`unknown tool: ${tc.name}`)
|
|
163
|
+
next.push({ role: 'user', content: `unknown tool: ${tc.name}` })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await runLoop(next, depth + 1)
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
onError(err) {
|
|
171
|
+
if (err.name !== 'AbortError') printer.errorMsg(err.message)
|
|
172
|
+
setStatus('idle')
|
|
173
|
+
setStreamPreview('')
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
}, [config])
|
|
177
|
+
|
|
178
|
+
// ─── model picker ──────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const openPicker = useCallback(async () => {
|
|
181
|
+
setPickerOpen(true)
|
|
182
|
+
setPickerLoading(true)
|
|
183
|
+
setPickerError(undefined)
|
|
184
|
+
try { setPickerModels(await listModels(config.baseUrl)) }
|
|
185
|
+
catch (e) { setPickerError(String(e)) }
|
|
186
|
+
finally { setPickerLoading(false) }
|
|
187
|
+
}, [config.baseUrl])
|
|
188
|
+
|
|
189
|
+
const handleModelSelect = useCallback((name: string) => {
|
|
190
|
+
setCurrentModel(name)
|
|
191
|
+
currentModelRef.current = name
|
|
192
|
+
setPickerOpen(false)
|
|
193
|
+
printer.systemMsg(`model → ${name}`)
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
const handleModelPull = useCallback(async (name: string) => {
|
|
197
|
+
setPullState({ name, status: 'starting...', pct: undefined })
|
|
198
|
+
pullAbortRef.current = new AbortController()
|
|
199
|
+
try {
|
|
200
|
+
await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal)
|
|
201
|
+
setPickerModels(await listModels(config.baseUrl))
|
|
202
|
+
setPullState(undefined)
|
|
203
|
+
setCurrentModel(name)
|
|
204
|
+
currentModelRef.current = name
|
|
205
|
+
setPickerOpen(false)
|
|
206
|
+
printer.systemMsg(`pulled ${name} → active`)
|
|
207
|
+
} catch (e) {
|
|
208
|
+
setPullState(undefined)
|
|
209
|
+
setPickerError(`pull failed: ${e}`)
|
|
210
|
+
}
|
|
211
|
+
}, [config.baseUrl])
|
|
212
|
+
|
|
213
|
+
// ─── submit ────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
const handleSubmit = useCallback(async (text: string) => {
|
|
216
|
+
const cmd = text.trim()
|
|
217
|
+
|
|
218
|
+
if (cmd === '/models') { await openPicker(); return }
|
|
219
|
+
|
|
220
|
+
if (cmd === '/new') {
|
|
221
|
+
saveSession(sessionNameRef.current, historyRef.current)
|
|
222
|
+
const newName = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
|
223
|
+
historyRef.current = []
|
|
224
|
+
setSessionName(newName)
|
|
225
|
+
printer.systemMsg(`new session → ${newName}`)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (cmd === '/clear') {
|
|
230
|
+
historyRef.current = []
|
|
231
|
+
saveSession(sessionNameRef.current, [])
|
|
232
|
+
printer.systemMsg('chat cleared')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (cmd === '/exit') { process.exit(0) }
|
|
237
|
+
|
|
238
|
+
if (cmd === '/sessions') {
|
|
239
|
+
const sessions = listSessions()
|
|
240
|
+
if (!sessions.length) { printer.systemMsg('no saved sessions'); return }
|
|
241
|
+
printer.systemMsg(sessions.map(s =>
|
|
242
|
+
`${s.name === sessionNameRef.current ? '▶ ' : ' '}${s.name} (${s.messageCount} msgs)`
|
|
243
|
+
).join('\n'))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (cmd.startsWith('/session')) {
|
|
248
|
+
const arg = cmd.slice(8).trim()
|
|
249
|
+
if (!arg) {
|
|
250
|
+
printer.systemMsg(`current: ${sessionNameRef.current}`)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
saveSession(sessionNameRef.current, historyRef.current)
|
|
254
|
+
historyRef.current = loadSession(arg)
|
|
255
|
+
setSessionName(arg)
|
|
256
|
+
printer.systemMsg(`session → ${arg} (${historyRef.current.length} messages)`)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (text.startsWith('/')) {
|
|
261
|
+
const [slashCmd, ...rest] = text.slice(1).split(' ')
|
|
262
|
+
const skill = skills.get(slashCmd)
|
|
263
|
+
if (skill) {
|
|
264
|
+
if (skill.name === 'list') {
|
|
265
|
+
printer.systemMsg(skills.list().map(s =>
|
|
266
|
+
`/${s.ns === 'default' ? '' : s.ns + ':'}${s.name} — ${s.description}`
|
|
267
|
+
).join('\n'))
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if (skill.execute) {
|
|
271
|
+
const ctx = {
|
|
272
|
+
messages: historyRef.current.map(m => ({ role: m.role, content: m.content })),
|
|
273
|
+
appendMessage: (_role: string, content: string) => printer.systemMsg(content),
|
|
274
|
+
setSystemPrompt: (p: string) => { systemPromptRef.current = p },
|
|
275
|
+
getSystemPrompt: () => systemPromptRef.current,
|
|
276
|
+
}
|
|
277
|
+
const result = await skill.execute(rest.join(' '), ctx)
|
|
278
|
+
if (result) printer.systemMsg(result)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (skill.prompt) {
|
|
282
|
+
printer.userMsg(skill.prompt)
|
|
283
|
+
historyRef.current.push({ role: 'user', content: skill.prompt })
|
|
284
|
+
await runLoop(buildContext())
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
printer.systemMsg(`unknown command: /${slashCmd} — try /list`)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const contextPrefix = buildAtContext(text)
|
|
293
|
+
printer.userMsg(text)
|
|
294
|
+
historyRef.current.push({ role: 'user', content: contextPrefix + text })
|
|
295
|
+
saveSession(sessionNameRef.current, historyRef.current)
|
|
296
|
+
await runLoop(buildContext())
|
|
297
|
+
}, [skills, runLoop, openPicker])
|
|
298
|
+
|
|
299
|
+
const handleAbort = useCallback(() => {
|
|
300
|
+
abortRef.current?.abort()
|
|
301
|
+
setStatus('idle')
|
|
302
|
+
setStreamPreview('')
|
|
303
|
+
tokenBufRef.current = ''
|
|
304
|
+
}, [])
|
|
305
|
+
|
|
306
|
+
const skillList = skills.list()
|
|
307
|
+
|
|
308
|
+
// ─── render ────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<Box flexDirection="column">
|
|
312
|
+
{pickerOpen ? (
|
|
313
|
+
<>
|
|
314
|
+
<ModelPicker
|
|
315
|
+
models={pickerModels}
|
|
316
|
+
current={currentModel}
|
|
317
|
+
loading={pickerLoading}
|
|
318
|
+
error={pickerError}
|
|
319
|
+
pull={pullState}
|
|
320
|
+
onSelect={handleModelSelect}
|
|
321
|
+
onPull={handleModelPull}
|
|
322
|
+
onClose={() => { setPickerOpen(false); setPullState(undefined) }}
|
|
323
|
+
/>
|
|
324
|
+
<Divider cols={cols} />
|
|
325
|
+
</>
|
|
326
|
+
) : streamPreview ? (
|
|
327
|
+
<>
|
|
328
|
+
<Box flexDirection="column" paddingX={1}>
|
|
329
|
+
<Text bold color="green">miii</Text>
|
|
330
|
+
{lastLines(streamPreview, PREVIEW_LINES).map((line, i) => (
|
|
331
|
+
<Text key={i} color="gray" wrap="wrap">{line || ' '}</Text>
|
|
332
|
+
))}
|
|
333
|
+
</Box>
|
|
334
|
+
<Divider cols={cols} />
|
|
335
|
+
</>
|
|
336
|
+
) : null}
|
|
337
|
+
|
|
338
|
+
<InputArea
|
|
339
|
+
status={status}
|
|
340
|
+
skills={skillList}
|
|
341
|
+
cwd={cwd}
|
|
342
|
+
onSubmit={handleSubmit}
|
|
343
|
+
onAbort={handleAbort}
|
|
344
|
+
/>
|
|
345
|
+
</Box>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import type { FileEntry } from '../../files/ops.js'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
files: FileEntry[]
|
|
7
|
+
query: string
|
|
8
|
+
idx: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function AtPicker({ files, query, idx }: Props) {
|
|
12
|
+
const filtered = useMemo(() => {
|
|
13
|
+
if (!query) return files.slice(0, 8)
|
|
14
|
+
return files.filter(f => f.rel.toLowerCase().includes(query.toLowerCase())).slice(0, 8)
|
|
15
|
+
}, [files, query])
|
|
16
|
+
|
|
17
|
+
if (!filtered.length) {
|
|
18
|
+
return (
|
|
19
|
+
<Box borderStyle="round" borderColor="gray" marginX={1} paddingX={1}>
|
|
20
|
+
<Text color="gray">no files match "{query}"</Text>
|
|
21
|
+
</Box>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Box flexDirection="column" borderStyle="round" borderColor="gray" marginX={1}>
|
|
27
|
+
{filtered.map((f, i) => {
|
|
28
|
+
const active = i === idx
|
|
29
|
+
const icon = f.type === 'dir' ? '/' : ' '
|
|
30
|
+
return (
|
|
31
|
+
<Box key={f.path} paddingX={1}>
|
|
32
|
+
<Text color={active ? 'cyan' : 'white'} bold={active}>
|
|
33
|
+
{active ? '▶' : ' '}
|
|
34
|
+
{icon}
|
|
35
|
+
</Text>
|
|
36
|
+
<Text color={active ? 'cyan' : f.type === 'dir' ? 'blue' : 'white'}>
|
|
37
|
+
{' '}{f.rel}
|
|
38
|
+
</Text>
|
|
39
|
+
{f.size !== undefined && (
|
|
40
|
+
<Text color="gray" dimColor>
|
|
41
|
+
{' '}{f.size > 1024 ? `${(f.size / 1024).toFixed(0)}k` : `${f.size}b`}
|
|
42
|
+
</Text>
|
|
43
|
+
)}
|
|
44
|
+
</Box>
|
|
45
|
+
)
|
|
46
|
+
})}
|
|
47
|
+
</Box>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import type { Skill } from '../../skills/loader.js'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
skills: Skill[]
|
|
7
|
+
query: string
|
|
8
|
+
idx: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CommandPalette({ skills, query, idx }: Props) {
|
|
12
|
+
const filtered = useMemo(() => {
|
|
13
|
+
const q = query.toLowerCase()
|
|
14
|
+
if (!q) return skills.slice(0, 10)
|
|
15
|
+
return skills.filter(s =>
|
|
16
|
+
s.name.includes(q) ||
|
|
17
|
+
`${s.ns}:${s.name}`.includes(q) ||
|
|
18
|
+
s.description.toLowerCase().includes(q)
|
|
19
|
+
).slice(0, 10)
|
|
20
|
+
}, [skills, query])
|
|
21
|
+
|
|
22
|
+
if (!filtered.length) {
|
|
23
|
+
return (
|
|
24
|
+
<Box borderStyle="round" borderColor="gray" marginX={1} paddingX={1}>
|
|
25
|
+
<Text color="gray">no commands match</Text>
|
|
26
|
+
</Box>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Box flexDirection="column" borderStyle="round" borderColor="gray" marginX={1}>
|
|
32
|
+
{filtered.map((s, i) => {
|
|
33
|
+
const active = i === idx
|
|
34
|
+
const isBuiltin = s.ns === 'builtin'
|
|
35
|
+
const name = (s.ns === 'default' || s.ns === 'builtin')
|
|
36
|
+
? `/${s.name}`
|
|
37
|
+
: `/${s.ns}:${s.name}`
|
|
38
|
+
return (
|
|
39
|
+
<Box key={`${s.ns}:${s.name}`} paddingX={1}>
|
|
40
|
+
<Text color={active ? 'cyan' : isBuiltin ? 'white' : 'magenta'} bold={active}>
|
|
41
|
+
{active ? '▶ ' : ' '}
|
|
42
|
+
{name.padEnd(20)}
|
|
43
|
+
</Text>
|
|
44
|
+
<Text color="gray" dimColor>{s.description}</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
)
|
|
47
|
+
})}
|
|
48
|
+
</Box>
|
|
49
|
+
)
|
|
50
|
+
}
|