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.
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
- }