miii-cli 0.1.6 → 0.1.8
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 +3 -1
- package/dist/init.js +4 -0
- package/dist/init.js.map +1 -1
- package/dist/llm/ollama.d.ts +1 -0
- package/dist/llm/ollama.js +49 -0
- package/dist/llm/ollama.js.map +1 -1
- package/dist/parser/stream-parser.d.ts +4 -0
- package/dist/parser/stream-parser.js +170 -3
- package/dist/parser/stream-parser.js.map +1 -1
- package/dist/tools/index.js +42 -6
- package/dist/tools/index.js.map +1 -1
- package/dist/tui/App.js +54 -5
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/InputBar.js +41 -1
- package/dist/tui/InputBar.js.map +1 -1
- package/dist/tui/components/InputArea.d.ts +2 -1
- package/dist/tui/components/InputArea.js +14 -4
- package/dist/tui/components/InputArea.js.map +1 -1
- package/dist/tui/printer.js +9 -1
- package/dist/tui/printer.js.map +1 -1
- package/package.json +1 -1
- package/src/init.ts +5 -0
- package/src/llm/ollama.ts +52 -0
- package/src/parser/stream-parser.ts +145 -3
- package/src/tools/index.ts +40 -6
- package/src/tui/App.tsx +71 -3
- package/src/tui/InputBar.tsx +46 -0
- package/src/tui/components/InputArea.tsx +15 -3
- package/src/tui/printer.ts +10 -1
package/src/tui/App.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
|
-
import { Box, useStdout, useInput } from 'ink'
|
|
2
|
+
import { Box, Text, useStdout, useInput } from 'ink'
|
|
3
3
|
import { StatusBar, Divider } from './components/StatusBar.js'
|
|
4
4
|
import { MessageList } from './components/MessageList.js'
|
|
5
5
|
import { InputArea } from './components/InputArea.js'
|
|
@@ -7,7 +7,7 @@ import { ModelPicker } from './components/ModelPicker.js'
|
|
|
7
7
|
import { stream } from '../llm/stream.js'
|
|
8
8
|
import { listModels, pullModel } from '../llm/ollama.js'
|
|
9
9
|
import type { OllamaModel } from '../llm/ollama.js'
|
|
10
|
-
import { StreamParser } from '../parser/stream-parser.js'
|
|
10
|
+
import { StreamParser, extractBareToolCall } from '../parser/stream-parser.js'
|
|
11
11
|
import { tools, getSystemPrompt } from '../tools/index.js'
|
|
12
12
|
import { readFile, guardPath } from '../files/ops.js'
|
|
13
13
|
import type { SkillLoader } from '../skills/loader.js'
|
|
@@ -66,10 +66,18 @@ export function App({ config, skills, cwd }: Props) {
|
|
|
66
66
|
const tokenBufRef = useRef('')
|
|
67
67
|
const lastRenderRef = useRef(0)
|
|
68
68
|
const messagesRef = useRef(messages)
|
|
69
|
+
const approvalResolveRef = useRef<((ok: boolean) => void) | null>(null)
|
|
70
|
+
const [pendingApproval, setPendingApproval] = useState<{
|
|
71
|
+
toolName: string
|
|
72
|
+
path: string
|
|
73
|
+
content?: string
|
|
74
|
+
} | null>(null)
|
|
75
|
+
const pendingApprovalRef = useRef(pendingApproval)
|
|
69
76
|
|
|
70
77
|
useEffect(() => { systemPromptRef.current = systemPrompt }, [systemPrompt])
|
|
71
78
|
useEffect(() => { currentModelRef.current = currentModel }, [currentModel])
|
|
72
79
|
useEffect(() => { messagesRef.current = messages }, [messages])
|
|
80
|
+
useEffect(() => { pendingApprovalRef.current = pendingApproval }, [pendingApproval])
|
|
73
81
|
|
|
74
82
|
useEffect(() => {
|
|
75
83
|
if (status === 'idle') return
|
|
@@ -80,6 +88,20 @@ export function App({ config, skills, cwd }: Props) {
|
|
|
80
88
|
// Scroll keybindings — PageUp/PageDn scroll message history
|
|
81
89
|
const SCROLL_STEP = 5
|
|
82
90
|
useInput((_input, key) => {
|
|
91
|
+
// approvalResolveRef is set synchronously in requestApproval — no useEffect needed
|
|
92
|
+
if (approvalResolveRef.current) {
|
|
93
|
+
const resolve = approvalResolveRef.current
|
|
94
|
+
if (_input === 'y' || _input === 'Y') {
|
|
95
|
+
approvalResolveRef.current = null
|
|
96
|
+
setPendingApproval(null)
|
|
97
|
+
resolve(true)
|
|
98
|
+
} else if (_input === 'n' || _input === 'N' || key.escape) {
|
|
99
|
+
approvalResolveRef.current = null
|
|
100
|
+
setPendingApproval(null)
|
|
101
|
+
resolve(false)
|
|
102
|
+
}
|
|
103
|
+
return
|
|
104
|
+
}
|
|
83
105
|
if (pickerOpen) return
|
|
84
106
|
if (key.pageUp) {
|
|
85
107
|
setScrollOffset(n => Math.min(n + SCROLL_STEP, Math.max(0, messages.length - 1)))
|
|
@@ -92,6 +114,19 @@ export function App({ config, skills, cwd }: Props) {
|
|
|
92
114
|
const cols = stdout.columns ?? 80
|
|
93
115
|
const rows = stdout.rows ?? 24
|
|
94
116
|
|
|
117
|
+
const APPROVAL_TOOLS = new Set(['delete_file'])
|
|
118
|
+
|
|
119
|
+
const requestApproval = useCallback((toolName: string, args: Record<string, unknown>): Promise<boolean> => {
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
approvalResolveRef.current = resolve
|
|
122
|
+
setPendingApproval({
|
|
123
|
+
toolName,
|
|
124
|
+
path: ((args.path ?? args.from) as string) ?? '',
|
|
125
|
+
content: args.content as string | undefined,
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
}, [])
|
|
129
|
+
|
|
95
130
|
function addMsg(role: Message['role'], content: string, id?: string): string {
|
|
96
131
|
const mid = id ?? generateId()
|
|
97
132
|
setMessages(prev => [...prev, { id: mid, role, content, timestamp: Date.now() }])
|
|
@@ -154,7 +189,19 @@ export function App({ config, skills, cwd }: Props) {
|
|
|
154
189
|
if (item.type === 'tool_call') pendingTools.push({ name: item.toolName, args: item.toolArgs })
|
|
155
190
|
}
|
|
156
191
|
|
|
157
|
-
if (!pendingTools.length) {
|
|
192
|
+
if (!pendingTools.length) {
|
|
193
|
+
// Fallback: LLM emitted bare JSON without <tool_call> wrapper
|
|
194
|
+
const bare = extractBareToolCall(fullText)
|
|
195
|
+
if (bare) {
|
|
196
|
+
pendingTools.push(bare)
|
|
197
|
+
} else {
|
|
198
|
+
if (fullText.includes('{"name"')) {
|
|
199
|
+
addMsg('tool', 'tool_call parse failed — could not extract tool call from model output')
|
|
200
|
+
}
|
|
201
|
+
setStatus('idle')
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
}
|
|
158
205
|
|
|
159
206
|
setStatus('tool')
|
|
160
207
|
const next: ChatMessage[] = [...contextMsgs, { role: 'assistant', content: fullText }]
|
|
@@ -163,6 +210,15 @@ export function App({ config, skills, cwd }: Props) {
|
|
|
163
210
|
const tool = tools.find(t => t.name === tc.name)
|
|
164
211
|
const toolId = generateId()
|
|
165
212
|
if (tool) {
|
|
213
|
+
if (APPROVAL_TOOLS.has(tc.name)) {
|
|
214
|
+
const approved = await requestApproval(tc.name, tc.args)
|
|
215
|
+
if (!approved) {
|
|
216
|
+
const cancelled = `[${tc.name}] cancelled by user`
|
|
217
|
+
setMessages(prev => [...prev, { id: toolId, role: 'tool', content: cancelled, timestamp: Date.now() }])
|
|
218
|
+
next.push({ role: 'user', content: `Tool ${tc.name} was cancelled by user.` })
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
}
|
|
166
222
|
try {
|
|
167
223
|
const result = await tool.execute(tc.args)
|
|
168
224
|
setMessages(prev => [...prev, { id: toolId, role: 'tool', content: `[${tc.name}]\n${result}`, timestamp: Date.now() }])
|
|
@@ -298,6 +354,18 @@ export function App({ config, skills, cwd }: Props) {
|
|
|
298
354
|
/>
|
|
299
355
|
)}
|
|
300
356
|
<Divider cols={cols} />
|
|
357
|
+
{pendingApproval && (
|
|
358
|
+
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginBottom={1}>
|
|
359
|
+
<Text color="yellow" bold>Allow {pendingApproval.toolName}?</Text>
|
|
360
|
+
<Text> path: <Text color="cyan">{pendingApproval.path}</Text></Text>
|
|
361
|
+
{pendingApproval.content && (
|
|
362
|
+
<Text color="gray" dimColor>
|
|
363
|
+
{pendingApproval.content.split('\n').slice(0, 12).join('\n')}
|
|
364
|
+
</Text>
|
|
365
|
+
)}
|
|
366
|
+
<Text color="green">[y] approve <Text color="red">[n] cancel</Text></Text>
|
|
367
|
+
</Box>
|
|
368
|
+
)}
|
|
301
369
|
<InputArea
|
|
302
370
|
status={status}
|
|
303
371
|
skills={skillList}
|
package/src/tui/InputBar.tsx
CHANGED
|
@@ -51,6 +51,7 @@ export function InputBar({ config, skills, cwd, session }: Props) {
|
|
|
51
51
|
const [currentModel, setCurrentModel] = useState(config.model)
|
|
52
52
|
const [streamPreview, setStreamPreview] = useState('')
|
|
53
53
|
const [sessionName, setSessionName] = useState(session)
|
|
54
|
+
const [planningMode, setPlanningMode] = useState(false)
|
|
54
55
|
|
|
55
56
|
// picker opens on mount — force model selection every launch
|
|
56
57
|
const [pickerOpen, setPickerOpen] = useState(true)
|
|
@@ -222,6 +223,8 @@ export function InputBar({ config, skills, cwd, session }: Props) {
|
|
|
222
223
|
const newName = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
|
223
224
|
historyRef.current = []
|
|
224
225
|
setSessionName(newName)
|
|
226
|
+
setPlanningMode(false)
|
|
227
|
+
systemPromptRef.current = getSystemPrompt(`\n- CWD: ${cwd}`)
|
|
225
228
|
printer.systemMsg(`new session → ${newName}`)
|
|
226
229
|
return
|
|
227
230
|
}
|
|
@@ -229,12 +232,54 @@ export function InputBar({ config, skills, cwd, session }: Props) {
|
|
|
229
232
|
if (cmd === '/clear') {
|
|
230
233
|
historyRef.current = []
|
|
231
234
|
saveSession(sessionNameRef.current, [])
|
|
235
|
+
setPlanningMode(false)
|
|
236
|
+
systemPromptRef.current = getSystemPrompt(`\n- CWD: ${cwd}`)
|
|
232
237
|
printer.systemMsg('chat cleared')
|
|
233
238
|
return
|
|
234
239
|
}
|
|
235
240
|
|
|
236
241
|
if (cmd === '/exit') { process.exit(0) }
|
|
237
242
|
|
|
243
|
+
if (cmd === '/plan' || cmd.startsWith('/plan ')) {
|
|
244
|
+
const topic = cmd.slice(5).trim()
|
|
245
|
+
setPlanningMode(true)
|
|
246
|
+
systemPromptRef.current = getSystemPrompt(
|
|
247
|
+
`\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.`
|
|
248
|
+
)
|
|
249
|
+
const msg = topic
|
|
250
|
+
? `I want to plan: ${topic}`
|
|
251
|
+
: 'I want to start planning. Help me think through my goals step by step.'
|
|
252
|
+
printer.userMsg(msg)
|
|
253
|
+
historyRef.current.push({ role: 'user', content: msg })
|
|
254
|
+
saveSession(sessionNameRef.current, historyRef.current)
|
|
255
|
+
await runLoop(buildContext())
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (cmd === '/plan:done') {
|
|
260
|
+
setPlanningMode(false)
|
|
261
|
+
systemPromptRef.current = getSystemPrompt(`\n- CWD: ${cwd}`)
|
|
262
|
+
printer.systemMsg('planning mode off')
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (cmd.startsWith('/plan:')) {
|
|
267
|
+
const subCmd = cmd.slice(6)
|
|
268
|
+
const subPrompts: Record<string, string> = {
|
|
269
|
+
next: 'What are the next concrete steps I should take?',
|
|
270
|
+
breakdown: 'Can you break this down into specific subtasks?',
|
|
271
|
+
review: 'Please review and critique our plan so far. What are we missing?',
|
|
272
|
+
}
|
|
273
|
+
const msg = subPrompts[subCmd]
|
|
274
|
+
if (msg) {
|
|
275
|
+
printer.userMsg(msg)
|
|
276
|
+
historyRef.current.push({ role: 'user', content: msg })
|
|
277
|
+
saveSession(sessionNameRef.current, historyRef.current)
|
|
278
|
+
await runLoop(buildContext())
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
238
283
|
if (cmd === '/sessions') {
|
|
239
284
|
const sessions = listSessions()
|
|
240
285
|
if (!sessions.length) { printer.systemMsg('no saved sessions'); return }
|
|
@@ -339,6 +384,7 @@ export function InputBar({ config, skills, cwd, session }: Props) {
|
|
|
339
384
|
status={status}
|
|
340
385
|
skills={skillList}
|
|
341
386
|
cwd={cwd}
|
|
387
|
+
planningMode={planningMode}
|
|
342
388
|
onSubmit={handleSubmit}
|
|
343
389
|
onAbort={handleAbort}
|
|
344
390
|
/>
|
|
@@ -16,6 +16,14 @@ const BUILTIN_COMMANDS: Skill[] = [
|
|
|
16
16
|
{ ns: 'builtin', name: 'session', description: 'switch session /session <name>' },
|
|
17
17
|
{ ns: 'builtin', name: 'exit', description: 'exit miii' },
|
|
18
18
|
{ ns: 'builtin', name: 'list', description: 'list all loaded skills' },
|
|
19
|
+
{ ns: 'builtin', name: 'plan', description: 'start planning mode /plan [topic]' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const PLANNING_COMMANDS: Skill[] = [
|
|
23
|
+
{ ns: 'plan', name: 'next', description: 'suggest next concrete steps' },
|
|
24
|
+
{ ns: 'plan', name: 'breakdown', description: 'break current topic into subtasks' },
|
|
25
|
+
{ ns: 'plan', name: 'review', description: 'review and critique the plan so far' },
|
|
26
|
+
{ ns: 'plan', name: 'done', description: 'exit planning mode' },
|
|
19
27
|
]
|
|
20
28
|
|
|
21
29
|
type Overlay = 'none' | 'command' | 'at'
|
|
@@ -24,11 +32,12 @@ interface Props {
|
|
|
24
32
|
status: Status
|
|
25
33
|
skills: Skill[]
|
|
26
34
|
cwd: string
|
|
35
|
+
planningMode?: boolean
|
|
27
36
|
onSubmit: (text: string) => void
|
|
28
37
|
onAbort: () => void
|
|
29
38
|
}
|
|
30
39
|
|
|
31
|
-
export function InputArea({ status, skills, cwd, onSubmit, onAbort }: Props) {
|
|
40
|
+
export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort }: Props) {
|
|
32
41
|
const [lines, setLines] = useState<string[]>([''])
|
|
33
42
|
const [cursor, setCursor] = useState({ row: 0, col: 0 })
|
|
34
43
|
const [overlay, setOverlay] = useState<Overlay>('none')
|
|
@@ -42,8 +51,9 @@ export function InputArea({ status, skills, cwd, onSubmit, onAbort }: Props) {
|
|
|
42
51
|
const allCommands = useMemo(() => {
|
|
43
52
|
const builtinNames = new Set(BUILTIN_COMMANDS.map(b => b.name))
|
|
44
53
|
const userSkills = skills.filter(s => !builtinNames.has(s.name))
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
const base = [...BUILTIN_COMMANDS, ...userSkills]
|
|
55
|
+
return planningMode ? [...PLANNING_COMMANDS, ...base] : base
|
|
56
|
+
}, [skills, planningMode])
|
|
47
57
|
|
|
48
58
|
const isActive = status === 'idle'
|
|
49
59
|
const fullInput = lines.join('\n')
|
|
@@ -245,6 +255,8 @@ export function InputArea({ status, skills, cwd, onSubmit, onAbort }: Props) {
|
|
|
245
255
|
? '↑↓ navigate enter select esc close'
|
|
246
256
|
: overlay === 'at'
|
|
247
257
|
? '↑↓ navigate enter select esc close'
|
|
258
|
+
: planningMode
|
|
259
|
+
? '📋 planning mode / suggestions enter send /plan:done to exit'
|
|
248
260
|
: '@ file / command enter send ctrl+c exit'
|
|
249
261
|
|
|
250
262
|
return (
|
package/src/tui/printer.ts
CHANGED
|
@@ -18,6 +18,15 @@ function indent(text: string, pad = ' '): string {
|
|
|
18
18
|
return text.split('\n').map(l => pad + l).join('\n')
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function stripMarkdown(s: string): string {
|
|
22
|
+
return s
|
|
23
|
+
.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
|
|
24
|
+
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
25
|
+
.replace(/\*(.+?)\*/g, '$1')
|
|
26
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
27
|
+
.replace(/^#{1,6} /gm, '')
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
function formatContent(text: string): string {
|
|
22
31
|
const lines = text.split('\n')
|
|
23
32
|
let inCode = false
|
|
@@ -30,7 +39,7 @@ function formatContent(text: string): string {
|
|
|
30
39
|
} else if (inCode) {
|
|
31
40
|
out.push(' ' + yellow(line || ' '))
|
|
32
41
|
} else {
|
|
33
|
-
out.push(' ' + (line || ''))
|
|
42
|
+
out.push(' ' + stripMarkdown(line || ''))
|
|
34
43
|
}
|
|
35
44
|
}
|
|
36
45
|
return out.join('\n')
|