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.
Files changed (98) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/Makefile +13 -0
  3. package/README.md +182 -0
  4. package/dist/config.d.ts +2 -0
  5. package/dist/config.js +24 -0
  6. package/dist/config.js.map +1 -0
  7. package/dist/files/ops.d.ts +11 -0
  8. package/dist/files/ops.js +66 -0
  9. package/dist/files/ops.js.map +1 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +12 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/init.d.ts +1 -0
  14. package/dist/init.js +32 -0
  15. package/dist/init.js.map +1 -0
  16. package/dist/llm/ollama.d.ts +9 -0
  17. package/dist/llm/ollama.js +51 -0
  18. package/dist/llm/ollama.js.map +1 -0
  19. package/dist/llm/stream.d.ts +12 -0
  20. package/dist/llm/stream.js +129 -0
  21. package/dist/llm/stream.js.map +1 -0
  22. package/dist/parser/stream-parser.d.ts +17 -0
  23. package/dist/parser/stream-parser.js +54 -0
  24. package/dist/parser/stream-parser.js.map +1 -0
  25. package/dist/sessions.d.ts +9 -0
  26. package/dist/sessions.js +48 -0
  27. package/dist/sessions.js.map +1 -0
  28. package/dist/skills/loader.d.ts +23 -0
  29. package/dist/skills/loader.js +91 -0
  30. package/dist/skills/loader.js.map +1 -0
  31. package/dist/tools/index.d.ts +8 -0
  32. package/dist/tools/index.js +79 -0
  33. package/dist/tools/index.js.map +1 -0
  34. package/dist/tui/App.d.ts +9 -0
  35. package/dist/tui/App.js +259 -0
  36. package/dist/tui/App.js.map +1 -0
  37. package/dist/tui/InputBar.d.ts +10 -0
  38. package/dist/tui/InputBar.js +289 -0
  39. package/dist/tui/InputBar.js.map +1 -0
  40. package/dist/tui/components/AtPicker.d.ts +8 -0
  41. package/dist/tui/components/AtPicker.js +19 -0
  42. package/dist/tui/components/AtPicker.js.map +1 -0
  43. package/dist/tui/components/CommandPalette.d.ts +8 -0
  44. package/dist/tui/components/CommandPalette.js +25 -0
  45. package/dist/tui/components/CommandPalette.js.map +1 -0
  46. package/dist/tui/components/InputArea.d.ts +11 -0
  47. package/dist/tui/components/InputArea.js +268 -0
  48. package/dist/tui/components/InputArea.js.map +1 -0
  49. package/dist/tui/components/MessageList.d.ts +10 -0
  50. package/dist/tui/components/MessageList.js +98 -0
  51. package/dist/tui/components/MessageList.js.map +1 -0
  52. package/dist/tui/components/ModelPicker.d.ts +18 -0
  53. package/dist/tui/components/ModelPicker.js +74 -0
  54. package/dist/tui/components/ModelPicker.js.map +1 -0
  55. package/dist/tui/components/StatusBar.d.ts +12 -0
  56. package/dist/tui/components/StatusBar.js +15 -0
  57. package/dist/tui/components/StatusBar.js.map +1 -0
  58. package/dist/tui/printer.d.ts +7 -0
  59. package/dist/tui/printer.js +106 -0
  60. package/dist/tui/printer.js.map +1 -0
  61. package/dist/types.d.ts +19 -0
  62. package/dist/types.js +4 -0
  63. package/dist/types.js.map +1 -0
  64. package/dist/workers/context.worker.d.ts +1 -0
  65. package/dist/workers/context.worker.js +69 -0
  66. package/dist/workers/context.worker.js.map +1 -0
  67. package/dist/workers/diff.worker.d.ts +1 -0
  68. package/dist/workers/diff.worker.js +12 -0
  69. package/dist/workers/diff.worker.js.map +1 -0
  70. package/dist/workers/spawn.d.ts +1 -0
  71. package/dist/workers/spawn.js +18 -0
  72. package/dist/workers/spawn.js.map +1 -0
  73. package/install.sh +6 -0
  74. package/package.json +29 -0
  75. package/src/config.ts +25 -0
  76. package/src/files/ops.ts +71 -0
  77. package/src/index.ts +11 -0
  78. package/src/init.ts +39 -0
  79. package/src/llm/ollama.ts +58 -0
  80. package/src/llm/stream.ts +118 -0
  81. package/src/parser/stream-parser.ts +54 -0
  82. package/src/sessions.ts +46 -0
  83. package/src/skills/loader.ts +109 -0
  84. package/src/tools/index.ts +83 -0
  85. package/src/tui/App.tsx +308 -0
  86. package/src/tui/InputBar.tsx +347 -0
  87. package/src/tui/components/AtPicker.tsx +49 -0
  88. package/src/tui/components/CommandPalette.tsx +50 -0
  89. package/src/tui/components/InputArea.tsx +285 -0
  90. package/src/tui/components/MessageList.tsx +192 -0
  91. package/src/tui/components/ModelPicker.tsx +134 -0
  92. package/src/tui/components/StatusBar.tsx +36 -0
  93. package/src/tui/printer.ts +121 -0
  94. package/src/types.ts +25 -0
  95. package/src/workers/context.worker.ts +62 -0
  96. package/src/workers/diff.worker.ts +20 -0
  97. package/src/workers/spawn.ts +19 -0
  98. 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
+ }