miii-cli 0.2.0 → 0.2.2
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/README.md +43 -8
- package/package.json +6 -1
- package/.claude/settings.local.json +0 -28
- package/CONTRIBUTING.md +0 -55
- package/Makefile +0 -13
- package/install.sh +0 -6
- package/mii-cli.gif +0 -0
- package/src/config.ts +0 -32
- package/src/files/ops.ts +0 -89
- package/src/index.ts +0 -11
- package/src/init.ts +0 -41
- package/src/llm/ollama.ts +0 -110
- package/src/llm/stream.ts +0 -55
- package/src/parser/stream-parser.ts +0 -196
- package/src/sessions.ts +0 -54
- package/src/skills/loader.ts +0 -144
- package/src/tools/index.ts +0 -151
- package/src/tui/App.tsx +0 -355
- package/src/tui/InputBar.tsx +0 -378
- package/src/tui/components/AtPicker.tsx +0 -49
- package/src/tui/components/CommandPalette.tsx +0 -50
- package/src/tui/components/InputArea.tsx +0 -297
- package/src/tui/components/MessageList.tsx +0 -219
- package/src/tui/components/ModelPicker.tsx +0 -134
- package/src/tui/components/StatusBar.tsx +0 -36
- package/src/tui/printer.ts +0 -130
- package/src/types.ts +0 -26
- package/src/workers/context.worker.ts +0 -66
- package/src/workers/diff.worker.ts +0 -20
- package/src/workers/spawn.ts +0 -19
- package/tsconfig.json +0 -18
package/src/tui/App.tsx
DELETED
|
@@ -1,355 +0,0 @@
|
|
|
1
|
-
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
|
-
import { Box, Text, useStdout, useInput } from 'ink'
|
|
3
|
-
import { StatusBar, Divider } from './components/StatusBar.js'
|
|
4
|
-
import { MessageList } from './components/MessageList.js'
|
|
5
|
-
import { InputArea } from './components/InputArea.js'
|
|
6
|
-
import { ModelPicker } from './components/ModelPicker.js'
|
|
7
|
-
import { chat } from '../llm/stream.js'
|
|
8
|
-
import { listModels, pullModel } from '../llm/ollama.js'
|
|
9
|
-
import type { OllamaModel } from '../llm/ollama.js'
|
|
10
|
-
import { StreamParser, extractBareToolCall } from '../parser/stream-parser.js'
|
|
11
|
-
import { tools, getSystemPrompt } from '../tools/index.js'
|
|
12
|
-
import { readFile, guardPath } from '../files/ops.js'
|
|
13
|
-
import type { SkillLoader } from '../skills/loader.js'
|
|
14
|
-
import type { Message, Status, ChatMessage, Config } from '../types.js'
|
|
15
|
-
import { generateId } from '../types.js'
|
|
16
|
-
|
|
17
|
-
interface Props {
|
|
18
|
-
config: Config
|
|
19
|
-
skills: SkillLoader
|
|
20
|
-
cwd: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const MAX_TOOL_DEPTH = 6
|
|
24
|
-
|
|
25
|
-
function expandAtRefs(text: string, cwd: string): { displayText: string; contextPrefix: string } {
|
|
26
|
-
const refs = [...text.matchAll(/@([\w./\-]+)/g)]
|
|
27
|
-
if (!refs.length) return { displayText: text, contextPrefix: '' }
|
|
28
|
-
const parts: string[] = []
|
|
29
|
-
for (const m of refs) {
|
|
30
|
-
try {
|
|
31
|
-
const safePath = guardPath(m[1], cwd)
|
|
32
|
-
const content = readFile(safePath)
|
|
33
|
-
parts.push(`<file path="${m[1]}">\n${content}\n</file>`)
|
|
34
|
-
} catch {}
|
|
35
|
-
}
|
|
36
|
-
return { displayText: text, contextPrefix: parts.length ? parts.join('\n\n') + '\n\n' : '' }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function App({ config, skills, cwd }: Props) {
|
|
40
|
-
const { stdout } = useStdout()
|
|
41
|
-
|
|
42
|
-
const [messages, setMessages] = useState<Message[]>([{
|
|
43
|
-
id: 'welcome',
|
|
44
|
-
role: 'system',
|
|
45
|
-
content: `local AI coding assistant · ${config.provider}/${config.model} · cwd: ${cwd}`,
|
|
46
|
-
timestamp: Date.now(),
|
|
47
|
-
}])
|
|
48
|
-
const [status, setStatus] = useState<Status>('idle')
|
|
49
|
-
const [tick, setTick] = useState(0)
|
|
50
|
-
const [currentModel, setCurrentModel] = useState(config.model)
|
|
51
|
-
const [scrollOffset, setScrollOffset] = useState(0)
|
|
52
|
-
|
|
53
|
-
// model picker
|
|
54
|
-
const [pickerOpen, setPickerOpen] = useState(false)
|
|
55
|
-
const [pickerModels, setPickerModels] = useState<OllamaModel[]>([])
|
|
56
|
-
const [pickerLoading, setPickerLoading] = useState(false)
|
|
57
|
-
const [pickerError, setPickerError] = useState<string | undefined>()
|
|
58
|
-
const [pullState, setPullState] = useState<{ name: string; status: string; pct: number | undefined } | undefined>()
|
|
59
|
-
|
|
60
|
-
const [systemPrompt, setSystemPrompt] = useState(() => getSystemPrompt(`\n- CWD: ${cwd}`))
|
|
61
|
-
const systemPromptRef = useRef(systemPrompt)
|
|
62
|
-
const currentModelRef = useRef(currentModel)
|
|
63
|
-
const abortRef = useRef<AbortController | null>(null)
|
|
64
|
-
const pullAbortRef = useRef<AbortController | null>(null)
|
|
65
|
-
const messagesRef = useRef(messages)
|
|
66
|
-
const approvalResolveRef = useRef<((ok: boolean) => void) | null>(null)
|
|
67
|
-
const [pendingApproval, setPendingApproval] = useState<{
|
|
68
|
-
toolName: string
|
|
69
|
-
path: string
|
|
70
|
-
content?: string
|
|
71
|
-
} | null>(null)
|
|
72
|
-
const pendingApprovalRef = useRef(pendingApproval)
|
|
73
|
-
|
|
74
|
-
useEffect(() => { systemPromptRef.current = systemPrompt }, [systemPrompt])
|
|
75
|
-
useEffect(() => { currentModelRef.current = currentModel }, [currentModel])
|
|
76
|
-
useEffect(() => { messagesRef.current = messages }, [messages])
|
|
77
|
-
useEffect(() => { pendingApprovalRef.current = pendingApproval }, [pendingApproval])
|
|
78
|
-
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
if (status === 'idle') return
|
|
81
|
-
const t = setInterval(() => setTick(n => n + 1), 80)
|
|
82
|
-
return () => clearInterval(t)
|
|
83
|
-
}, [status])
|
|
84
|
-
|
|
85
|
-
// Scroll keybindings — PageUp/PageDn scroll message history
|
|
86
|
-
const SCROLL_STEP = 5
|
|
87
|
-
useInput((_input, key) => {
|
|
88
|
-
// approvalResolveRef is set synchronously in requestApproval — no useEffect needed
|
|
89
|
-
if (approvalResolveRef.current) {
|
|
90
|
-
const resolve = approvalResolveRef.current
|
|
91
|
-
if (_input === 'y' || _input === 'Y') {
|
|
92
|
-
approvalResolveRef.current = null
|
|
93
|
-
setPendingApproval(null)
|
|
94
|
-
resolve(true)
|
|
95
|
-
} else if (_input === 'n' || _input === 'N' || key.escape) {
|
|
96
|
-
approvalResolveRef.current = null
|
|
97
|
-
setPendingApproval(null)
|
|
98
|
-
resolve(false)
|
|
99
|
-
}
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
if (pickerOpen) return
|
|
103
|
-
if (key.pageUp) {
|
|
104
|
-
setScrollOffset(n => Math.min(n + SCROLL_STEP, Math.max(0, messages.length - 1)))
|
|
105
|
-
}
|
|
106
|
-
if (key.pageDown) {
|
|
107
|
-
setScrollOffset(n => Math.max(0, n - SCROLL_STEP))
|
|
108
|
-
}
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
const cols = stdout.columns ?? 80
|
|
112
|
-
const rows = stdout.rows ?? 24
|
|
113
|
-
|
|
114
|
-
const APPROVAL_TOOLS = new Set(['delete_file'])
|
|
115
|
-
|
|
116
|
-
const requestApproval = useCallback((toolName: string, args: Record<string, unknown>): Promise<boolean> => {
|
|
117
|
-
return new Promise((resolve) => {
|
|
118
|
-
approvalResolveRef.current = resolve
|
|
119
|
-
setPendingApproval({
|
|
120
|
-
toolName,
|
|
121
|
-
path: ((args.path ?? args.from) as string) ?? '',
|
|
122
|
-
content: args.content as string | undefined,
|
|
123
|
-
})
|
|
124
|
-
})
|
|
125
|
-
}, [])
|
|
126
|
-
|
|
127
|
-
function addMsg(role: Message['role'], content: string, id?: string): string {
|
|
128
|
-
const mid = id ?? generateId()
|
|
129
|
-
setMessages(prev => [...prev, { id: mid, role, content, timestamp: Date.now() }])
|
|
130
|
-
return mid
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function buildContext(extra?: ChatMessage): ChatMessage[] {
|
|
134
|
-
const ctx: ChatMessage[] = [{ role: 'system', content: systemPromptRef.current }]
|
|
135
|
-
for (const m of messagesRef.current) {
|
|
136
|
-
if (m.role === 'tool') ctx.push({ role: 'user', content: `[tool result]\n${m.content}` })
|
|
137
|
-
else if (m.role === 'user' || m.role === 'assistant') ctx.push({ role: m.role, content: m.content })
|
|
138
|
-
}
|
|
139
|
-
if (extra) ctx.push(extra)
|
|
140
|
-
return ctx
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const runLoop = useCallback(async (contextMsgs: ChatMessage[], depth = 0) => {
|
|
144
|
-
if (depth >= MAX_TOOL_DEPTH) { setStatus('idle'); return }
|
|
145
|
-
setStatus('thinking')
|
|
146
|
-
|
|
147
|
-
const assistantId = generateId()
|
|
148
|
-
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }])
|
|
149
|
-
|
|
150
|
-
abortRef.current = new AbortController()
|
|
151
|
-
|
|
152
|
-
await chat({
|
|
153
|
-
provider: config.provider,
|
|
154
|
-
model: currentModelRef.current,
|
|
155
|
-
baseUrl: config.baseUrl,
|
|
156
|
-
apiKey: config.apiKey,
|
|
157
|
-
messages: contextMsgs,
|
|
158
|
-
signal: abortRef.current.signal,
|
|
159
|
-
|
|
160
|
-
async onDone(fullText) {
|
|
161
|
-
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: fullText } : m))
|
|
162
|
-
|
|
163
|
-
const pendingTools: Array<{ name: string; args: Record<string, unknown> }> = []
|
|
164
|
-
const parser = new StreamParser()
|
|
165
|
-
for (const item of [...parser.feed(fullText), ...parser.flush()]) {
|
|
166
|
-
if (item.type === 'tool_call') pendingTools.push({ name: item.toolName, args: item.toolArgs })
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (!pendingTools.length) {
|
|
170
|
-
const bare = extractBareToolCall(fullText)
|
|
171
|
-
if (bare) {
|
|
172
|
-
pendingTools.push(bare)
|
|
173
|
-
} else {
|
|
174
|
-
if (fullText.includes('{"name"')) {
|
|
175
|
-
addMsg('tool', 'tool_call parse failed — could not extract tool call from model output')
|
|
176
|
-
}
|
|
177
|
-
setStatus('idle')
|
|
178
|
-
return
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
setStatus('tool')
|
|
183
|
-
const next: ChatMessage[] = [...contextMsgs, { role: 'assistant', content: fullText }]
|
|
184
|
-
|
|
185
|
-
for (const tc of pendingTools) {
|
|
186
|
-
const tool = tools.find(t => t.name === tc.name)
|
|
187
|
-
const toolId = generateId()
|
|
188
|
-
if (tool) {
|
|
189
|
-
if (APPROVAL_TOOLS.has(tc.name)) {
|
|
190
|
-
const approved = await requestApproval(tc.name, tc.args)
|
|
191
|
-
if (!approved) {
|
|
192
|
-
const cancelled = `[${tc.name}] cancelled by user`
|
|
193
|
-
setMessages(prev => [...prev, { id: toolId, role: 'tool', content: cancelled, timestamp: Date.now() }])
|
|
194
|
-
next.push({ role: 'user', content: `Tool ${tc.name} was cancelled by user.` })
|
|
195
|
-
continue
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
const result = await tool.execute(tc.args)
|
|
200
|
-
setMessages(prev => [...prev, { id: toolId, role: 'tool', content: `[${tc.name}]\n${result}`, timestamp: Date.now() }])
|
|
201
|
-
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` })
|
|
202
|
-
} catch (e) {
|
|
203
|
-
const err = `Tool ${tc.name} error: ${e}`
|
|
204
|
-
setMessages(prev => [...prev, { id: toolId, role: 'tool', content: err, timestamp: Date.now() }])
|
|
205
|
-
next.push({ role: 'user', content: err })
|
|
206
|
-
}
|
|
207
|
-
} else {
|
|
208
|
-
const unk = `Unknown tool: ${tc.name}`
|
|
209
|
-
setMessages(prev => [...prev, { id: toolId, role: 'tool', content: unk, timestamp: Date.now() }])
|
|
210
|
-
next.push({ role: 'user', content: unk })
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
await runLoop(next, depth + 1)
|
|
215
|
-
},
|
|
216
|
-
|
|
217
|
-
onError(err) {
|
|
218
|
-
setMessages(prev => prev.filter(m => m.id !== assistantId))
|
|
219
|
-
addMsg('system', `error: ${err.message}`)
|
|
220
|
-
setStatus('idle')
|
|
221
|
-
},
|
|
222
|
-
})
|
|
223
|
-
}, [config])
|
|
224
|
-
|
|
225
|
-
// Model picker
|
|
226
|
-
const openPicker = useCallback(async () => {
|
|
227
|
-
setPickerOpen(true)
|
|
228
|
-
setPickerLoading(true)
|
|
229
|
-
setPickerError(undefined)
|
|
230
|
-
try {
|
|
231
|
-
setPickerModels(await listModels(config.baseUrl))
|
|
232
|
-
} catch (e) {
|
|
233
|
-
setPickerError(String(e))
|
|
234
|
-
} finally {
|
|
235
|
-
setPickerLoading(false)
|
|
236
|
-
}
|
|
237
|
-
}, [config.baseUrl])
|
|
238
|
-
|
|
239
|
-
const handleModelSelect = useCallback((name: string) => {
|
|
240
|
-
setCurrentModel(name)
|
|
241
|
-
setPickerOpen(false)
|
|
242
|
-
addMsg('system', `model → ${name}`)
|
|
243
|
-
}, [])
|
|
244
|
-
|
|
245
|
-
const handleModelPull = useCallback(async (name: string) => {
|
|
246
|
-
setPullState({ name, status: 'starting...', pct: undefined })
|
|
247
|
-
pullAbortRef.current = new AbortController()
|
|
248
|
-
try {
|
|
249
|
-
await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal)
|
|
250
|
-
setPickerModels(await listModels(config.baseUrl))
|
|
251
|
-
setPullState(undefined)
|
|
252
|
-
setCurrentModel(name)
|
|
253
|
-
setPickerOpen(false)
|
|
254
|
-
addMsg('system', `pulled ${name} → active`)
|
|
255
|
-
} catch (e) {
|
|
256
|
-
setPullState(undefined)
|
|
257
|
-
setPickerError(`pull failed: ${e}`)
|
|
258
|
-
}
|
|
259
|
-
}, [config.baseUrl])
|
|
260
|
-
|
|
261
|
-
const handleSubmit = useCallback(async (text: string) => {
|
|
262
|
-
setScrollOffset(0) // snap to bottom on new message
|
|
263
|
-
if (text.trim() === '/models') { await openPicker(); return }
|
|
264
|
-
|
|
265
|
-
if (text.startsWith('/')) {
|
|
266
|
-
const [cmd, ...rest] = text.slice(1).split(' ')
|
|
267
|
-
const skill = skills.get(cmd)
|
|
268
|
-
if (skill) {
|
|
269
|
-
if (skill.name === 'list') {
|
|
270
|
-
addMsg('system', skills.list().map(s => `/${s.ns === 'default' ? '' : s.ns + ':'}${s.name} — ${s.description}`).join('\n'))
|
|
271
|
-
return
|
|
272
|
-
}
|
|
273
|
-
if (skill.execute) {
|
|
274
|
-
const ctx = {
|
|
275
|
-
messages: messagesRef.current.map(m => ({ role: m.role, content: m.content })),
|
|
276
|
-
appendMessage: (role: string, content: string) => addMsg(role as Message['role'], content),
|
|
277
|
-
setSystemPrompt: (p: string) => setSystemPrompt(p),
|
|
278
|
-
getSystemPrompt: () => systemPromptRef.current,
|
|
279
|
-
}
|
|
280
|
-
const result = await skill.execute(rest.join(' '), ctx)
|
|
281
|
-
if (result) addMsg('system', result)
|
|
282
|
-
return
|
|
283
|
-
}
|
|
284
|
-
if (skill.prompt) {
|
|
285
|
-
addMsg('user', skill.prompt)
|
|
286
|
-
await runLoop(buildContext({ role: 'user', content: skill.prompt }))
|
|
287
|
-
return
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
addMsg('system', `unknown skill: /${cmd}. Try /list`)
|
|
291
|
-
return
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Expand @file references
|
|
295
|
-
const { displayText, contextPrefix } = expandAtRefs(text, cwd)
|
|
296
|
-
addMsg('user', displayText)
|
|
297
|
-
const llmContent = contextPrefix + text
|
|
298
|
-
await runLoop(buildContext({ role: 'user', content: llmContent }))
|
|
299
|
-
}, [skills, runLoop, openPicker])
|
|
300
|
-
|
|
301
|
-
const handleAbort = useCallback(() => {
|
|
302
|
-
abortRef.current?.abort()
|
|
303
|
-
setStatus('idle')
|
|
304
|
-
}, [])
|
|
305
|
-
|
|
306
|
-
const skillList = skills.list()
|
|
307
|
-
|
|
308
|
-
return (
|
|
309
|
-
<Box flexDirection="column" height={rows}>
|
|
310
|
-
<StatusBar model={currentModel} provider={config.provider} status={status} tick={tick} />
|
|
311
|
-
<Divider cols={cols} />
|
|
312
|
-
{pickerOpen ? (
|
|
313
|
-
<ModelPicker
|
|
314
|
-
models={pickerModels}
|
|
315
|
-
current={currentModel}
|
|
316
|
-
loading={pickerLoading}
|
|
317
|
-
error={pickerError}
|
|
318
|
-
pull={pullState}
|
|
319
|
-
onSelect={handleModelSelect}
|
|
320
|
-
onPull={handleModelPull}
|
|
321
|
-
onClose={() => { setPickerOpen(false); setPullState(undefined) }}
|
|
322
|
-
/>
|
|
323
|
-
) : (
|
|
324
|
-
<MessageList
|
|
325
|
-
messages={messages}
|
|
326
|
-
rows={rows - 8}
|
|
327
|
-
cols={cols}
|
|
328
|
-
scrollOffset={scrollOffset}
|
|
329
|
-
streaming={false}
|
|
330
|
-
thinkingTick={status === 'thinking' ? tick : undefined}
|
|
331
|
-
/>
|
|
332
|
-
)}
|
|
333
|
-
<Divider cols={cols} />
|
|
334
|
-
{pendingApproval && (
|
|
335
|
-
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginBottom={1}>
|
|
336
|
-
<Text color="yellow" bold>Allow {pendingApproval.toolName}?</Text>
|
|
337
|
-
<Text> path: <Text color="cyan">{pendingApproval.path}</Text></Text>
|
|
338
|
-
{pendingApproval.content && (
|
|
339
|
-
<Text color="gray" dimColor>
|
|
340
|
-
{pendingApproval.content.split('\n').slice(0, 12).join('\n')}
|
|
341
|
-
</Text>
|
|
342
|
-
)}
|
|
343
|
-
<Text color="green">[y] approve <Text color="red">[n] cancel</Text></Text>
|
|
344
|
-
</Box>
|
|
345
|
-
)}
|
|
346
|
-
<InputArea
|
|
347
|
-
status={status}
|
|
348
|
-
skills={skillList}
|
|
349
|
-
cwd={cwd}
|
|
350
|
-
onSubmit={handleSubmit}
|
|
351
|
-
onAbort={handleAbort}
|
|
352
|
-
/>
|
|
353
|
-
</Box>
|
|
354
|
-
)
|
|
355
|
-
}
|