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/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) { setStatus('idle'); return }
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}
@@ -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
- return [...BUILTIN_COMMANDS, ...userSkills]
46
- }, [skills])
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 (
@@ -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')