miii-cli 0.2.1 → 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.
@@ -1,381 +0,0 @@
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 { chat } 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
-
26
- const THINKING_PHRASES = [
27
- 'oh wow, a question. let me pretend to care…',
28
- 'consulting the void…',
29
- 'making something up, just a sec…',
30
- 'definitely not hallucinating right now…',
31
- 'running 47 mental tabs…',
32
- 'staring into the abyss (it blinked)…',
33
- 'calculating your fate, no pressure…',
34
- 'doing the thinking you pay me for…',
35
- 'processing your questionable life choices…',
36
- 'summoning coherent thoughts, rarely works…',
37
- ]
38
- const SPARKLE = ['✦', '✧', '✶', '✷', '✸', '✹']
39
-
40
- function buildAtContext(text: string): string {
41
- const refs = [...text.matchAll(/@([\w./\-]+)/g)]
42
- if (!refs.length) return ''
43
- const parts: string[] = []
44
- for (const m of refs) {
45
- try {
46
- const content = readFile(m[1])
47
- if (content) parts.push(`<file path="${m[1]}">\n${content}\n</file>`)
48
- } catch {}
49
- }
50
- return parts.length ? parts.join('\n\n') + '\n\n' : ''
51
- }
52
-
53
- export function InputBar({ config, skills, cwd, session }: Props) {
54
- const { stdout } = useStdout()
55
- const cols = stdout.columns ?? 80
56
-
57
- const [status, setStatus] = useState<Status>('idle')
58
- const [tick, setTick] = useState(0)
59
- const [currentModel, setCurrentModel] = useState(config.model)
60
- const [sessionName, setSessionName] = useState(session)
61
- const [currentTool, setCurrentTool] = useState<string | undefined>()
62
- const [planningMode, setPlanningMode] = useState(false)
63
-
64
- // picker opens on mount — force model selection every launch
65
- const [pickerOpen, setPickerOpen] = useState(true)
66
- const [pickerModels, setPickerModels] = useState<OllamaModel[]>([])
67
- const [pickerLoading, setPickerLoading] = useState(false)
68
- const [pickerError, setPickerError] = useState<string | undefined>()
69
- const [pullState, setPullState] = useState<{ name: string; status: string; pct: number | undefined } | undefined>()
70
-
71
- const abortRef = useRef<AbortController | null>(null)
72
- const pullAbortRef = useRef<AbortController | null>(null)
73
- const systemPromptRef = useRef(getSystemPrompt(`\n- CWD: ${cwd}`))
74
- const currentModelRef = useRef(currentModel)
75
- const sessionNameRef = useRef(sessionName)
76
- const historyRef = useRef<ChatMessage[]>([])
77
-
78
- useEffect(() => { currentModelRef.current = currentModel }, [currentModel])
79
- useEffect(() => { sessionNameRef.current = sessionName }, [sessionName])
80
-
81
- // mount: load session history + fetch models for initial picker
82
- useEffect(() => {
83
- const history = loadSession(session)
84
- historyRef.current = history
85
- if (history.length) {
86
- printer.systemMsg(`resumed "${session}" — ${history.length} messages`)
87
- }
88
- setPickerLoading(true)
89
- listModels(config.baseUrl)
90
- .then(m => { setPickerModels(m); setPickerLoading(false) })
91
- .catch(e => { setPickerError(String(e)); setPickerLoading(false) })
92
- }, [])
93
-
94
- useEffect(() => {
95
- if (status === 'idle') return
96
- const t = setInterval(() => setTick(n => n + 1), 80)
97
- return () => clearInterval(t)
98
- }, [status])
99
-
100
- function buildContext(extra?: ChatMessage): ChatMessage[] {
101
- const ctx: ChatMessage[] = [{ role: 'system', content: systemPromptRef.current }]
102
- ctx.push(...historyRef.current)
103
- if (extra) ctx.push(extra)
104
- return ctx
105
- }
106
-
107
- const runLoop = useCallback(async (contextMsgs: ChatMessage[], depth = 0) => {
108
- if (depth >= MAX_TOOL_DEPTH) { setStatus('idle'); return }
109
- setStatus('thinking')
110
-
111
- abortRef.current = new AbortController()
112
-
113
- await chat({
114
- provider: config.provider,
115
- model: currentModelRef.current,
116
- baseUrl: config.baseUrl,
117
- messages: contextMsgs,
118
- signal: abortRef.current.signal,
119
-
120
- async onDone(fullText) {
121
- const pendingTools: Array<{ name: string; args: Record<string, unknown> }> = []
122
- const parser = new StreamParser()
123
- for (const item of [...parser.feed(fullText), ...parser.flush()]) {
124
- if (item.type === 'tool_call') pendingTools.push({ name: item.toolName, args: item.toolArgs })
125
- }
126
-
127
- printer.assistantMsg(fullText)
128
- historyRef.current.push({ role: 'assistant', content: fullText })
129
- saveSession(sessionNameRef.current, historyRef.current)
130
-
131
- if (!pendingTools.length) { setStatus('idle'); return }
132
-
133
- setStatus('tool')
134
- const next: ChatMessage[] = [...contextMsgs, { role: 'assistant', content: fullText }]
135
-
136
- for (const tc of pendingTools) {
137
- const tool = tools.find(t => t.name === tc.name)
138
- setCurrentTool(tc.name)
139
- if (tool) {
140
- try {
141
- const result = await tool.execute(tc.args)
142
- printer.toolMsg(tc.name, result)
143
- next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` })
144
- } catch (e) {
145
- const err = `Tool ${tc.name} error: ${e}`
146
- printer.errorMsg(err)
147
- next.push({ role: 'user', content: err })
148
- }
149
- } else {
150
- printer.errorMsg(`unknown tool: ${tc.name}`)
151
- next.push({ role: 'user', content: `unknown tool: ${tc.name}` })
152
- }
153
- }
154
- setCurrentTool(undefined)
155
-
156
- await runLoop(next, depth + 1)
157
- },
158
-
159
- onError(err) {
160
- if (err.name !== 'AbortError') printer.errorMsg(err.message)
161
- setStatus('idle')
162
- },
163
- })
164
- }, [config])
165
-
166
- // ─── model picker ──────────────────────────────────────────────────────────
167
-
168
- const openPicker = useCallback(async () => {
169
- setPickerOpen(true)
170
- setPickerLoading(true)
171
- setPickerError(undefined)
172
- try { setPickerModels(await listModels(config.baseUrl)) }
173
- catch (e) { setPickerError(String(e)) }
174
- finally { setPickerLoading(false) }
175
- }, [config.baseUrl])
176
-
177
- const handleModelSelect = useCallback((name: string) => {
178
- setCurrentModel(name)
179
- currentModelRef.current = name
180
- setPickerOpen(false)
181
- printer.systemMsg(`model → ${name}`)
182
- }, [])
183
-
184
- const handleModelPull = useCallback(async (name: string) => {
185
- setPullState({ name, status: 'starting...', pct: undefined })
186
- pullAbortRef.current = new AbortController()
187
- try {
188
- await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal)
189
- setPickerModels(await listModels(config.baseUrl))
190
- setPullState(undefined)
191
- setCurrentModel(name)
192
- currentModelRef.current = name
193
- setPickerOpen(false)
194
- printer.systemMsg(`pulled ${name} → active`)
195
- } catch (e) {
196
- setPullState(undefined)
197
- setPickerError(`pull failed: ${e}`)
198
- }
199
- }, [config.baseUrl])
200
-
201
- // ─── submit ────────────────────────────────────────────────────────────────
202
-
203
- const handleSubmit = useCallback(async (text: string) => {
204
- const cmd = text.trim()
205
-
206
- if (cmd === '/models') { await openPicker(); return }
207
-
208
- if (cmd === '/new') {
209
- saveSession(sessionNameRef.current, historyRef.current)
210
- const newName = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
211
- historyRef.current = []
212
- setSessionName(newName)
213
- setPlanningMode(false)
214
- systemPromptRef.current = getSystemPrompt(`\n- CWD: ${cwd}`)
215
- printer.systemMsg(`new session → ${newName}`)
216
- return
217
- }
218
-
219
- if (cmd === '/clear') {
220
- historyRef.current = []
221
- saveSession(sessionNameRef.current, [])
222
- setPlanningMode(false)
223
- systemPromptRef.current = getSystemPrompt(`\n- CWD: ${cwd}`)
224
- printer.systemMsg('chat cleared')
225
- return
226
- }
227
-
228
- if (cmd === '/exit') { process.exit(0) }
229
-
230
- if (cmd === '/plan' || cmd.startsWith('/plan ')) {
231
- const topic = cmd.slice(5).trim()
232
- setPlanningMode(true)
233
- systemPromptRef.current = getSystemPrompt(
234
- `\n- CWD: ${cwd}\n- MODE: Planning assistant. Help the user plan step by step. Ask clarifying questions. Suggest concrete next steps. Use plain text only — no markdown, no headers, no bold, no bullets with asterisks, no backtick blocks. Use numbered lists and plain indentation for structure.`
235
- )
236
- const msg = topic
237
- ? `I want to plan: ${topic}`
238
- : 'I want to start planning. Help me think through my goals step by step.'
239
- printer.userMsg(msg)
240
- historyRef.current.push({ role: 'user', content: msg })
241
- saveSession(sessionNameRef.current, historyRef.current)
242
- await runLoop(buildContext())
243
- return
244
- }
245
-
246
- if (cmd === '/plan:done') {
247
- setPlanningMode(false)
248
- systemPromptRef.current = getSystemPrompt(`\n- CWD: ${cwd}`)
249
- printer.systemMsg('planning mode off')
250
- return
251
- }
252
-
253
- if (cmd.startsWith('/plan:')) {
254
- const subCmd = cmd.slice(6)
255
- const subPrompts: Record<string, string> = {
256
- next: 'What are the next concrete steps I should take?',
257
- breakdown: 'Can you break this down into specific subtasks?',
258
- review: 'Please review and critique our plan so far. What are we missing?',
259
- }
260
- const msg = subPrompts[subCmd]
261
- if (msg) {
262
- printer.userMsg(msg)
263
- historyRef.current.push({ role: 'user', content: msg })
264
- saveSession(sessionNameRef.current, historyRef.current)
265
- await runLoop(buildContext())
266
- return
267
- }
268
- }
269
-
270
- if (cmd === '/sessions') {
271
- const sessions = listSessions()
272
- if (!sessions.length) { printer.systemMsg('no saved sessions'); return }
273
- printer.systemMsg(sessions.map(s =>
274
- `${s.name === sessionNameRef.current ? '▶ ' : ' '}${s.name} (${s.messageCount} msgs)`
275
- ).join('\n'))
276
- return
277
- }
278
-
279
- if (cmd.startsWith('/session')) {
280
- const arg = cmd.slice(8).trim()
281
- if (!arg) {
282
- printer.systemMsg(`current: ${sessionNameRef.current}`)
283
- return
284
- }
285
- saveSession(sessionNameRef.current, historyRef.current)
286
- historyRef.current = loadSession(arg)
287
- setSessionName(arg)
288
- printer.systemMsg(`session → ${arg} (${historyRef.current.length} messages)`)
289
- return
290
- }
291
-
292
- if (text.startsWith('/')) {
293
- const [slashCmd, ...rest] = text.slice(1).split(' ')
294
- const skill = skills.get(slashCmd)
295
- if (skill) {
296
- if (skill.name === 'list') {
297
- printer.systemMsg(skills.list().map(s =>
298
- `/${s.ns === 'default' ? '' : s.ns + ':'}${s.name} — ${s.description}`
299
- ).join('\n'))
300
- return
301
- }
302
- if (skill.execute) {
303
- const ctx = {
304
- messages: historyRef.current.map(m => ({ role: m.role, content: m.content })),
305
- appendMessage: (_role: string, content: string) => printer.systemMsg(content),
306
- setSystemPrompt: (p: string) => { systemPromptRef.current = p },
307
- getSystemPrompt: () => systemPromptRef.current,
308
- }
309
- const result = await skill.execute(rest.join(' '), ctx)
310
- if (result) printer.systemMsg(result)
311
- return
312
- }
313
- if (skill.prompt) {
314
- printer.userMsg(skill.prompt)
315
- historyRef.current.push({ role: 'user', content: skill.prompt })
316
- await runLoop(buildContext())
317
- return
318
- }
319
- }
320
- printer.systemMsg(`unknown command: /${slashCmd} — try /list`)
321
- return
322
- }
323
-
324
- const contextPrefix = buildAtContext(text)
325
- printer.userMsg(text)
326
- historyRef.current.push({ role: 'user', content: contextPrefix + text })
327
- saveSession(sessionNameRef.current, historyRef.current)
328
- await runLoop(buildContext())
329
- }, [skills, runLoop, openPicker])
330
-
331
- const handleAbort = useCallback(() => {
332
- abortRef.current?.abort()
333
- setStatus('idle')
334
- }, [])
335
-
336
- const skillList = skills.list()
337
-
338
- // ─── render ────────────────────────────────────────────────────────────────
339
-
340
- return (
341
- <Box flexDirection="column">
342
- {pickerOpen ? (
343
- <>
344
- <ModelPicker
345
- models={pickerModels}
346
- current={currentModel}
347
- loading={pickerLoading}
348
- error={pickerError}
349
- pull={pullState}
350
- onSelect={handleModelSelect}
351
- onPull={handleModelPull}
352
- onClose={() => { setPickerOpen(false); setPullState(undefined) }}
353
- />
354
- <Divider cols={cols} />
355
- </>
356
- ) : (status === 'thinking' || status === 'tool') ? (
357
- <>
358
- <Box flexDirection="column" paddingX={1}>
359
- <Text bold color="green">miii</Text>
360
- <Box paddingLeft={2}>
361
- {status === 'thinking'
362
- ? <><Text color="yellow">{SPARKLE[tick % SPARKLE.length]} </Text><Text color="gray" dimColor italic>{THINKING_PHRASES[Math.floor(tick / 62) % THINKING_PHRASES.length]}</Text></>
363
- : <Text color="yellow" dimColor>⚙ running {currentTool ?? 'tool'}…</Text>
364
- }
365
- </Box>
366
- </Box>
367
- <Divider cols={cols} />
368
- </>
369
- ) : null}
370
-
371
- <InputArea
372
- status={status}
373
- skills={skillList}
374
- cwd={cwd}
375
- planningMode={planningMode}
376
- onSubmit={handleSubmit}
377
- onAbort={handleAbort}
378
- />
379
- </Box>
380
- )
381
- }
@@ -1,49 +0,0 @@
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
- }
@@ -1,50 +0,0 @@
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
- }