miii-cli 0.2.1 → 0.2.3
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/README.md +190 -83
- package/dist/config.js +0 -1
- package/dist/files/ops.js +22 -4
- package/dist/index.js +0 -1
- package/dist/init.js +0 -1
- package/dist/llm/ollama.js +0 -1
- package/dist/llm/stream.js +4 -3
- package/dist/parser/stream-parser.js +1 -13
- package/dist/sessions.js +0 -1
- package/dist/skills/loader.js +0 -1
- package/dist/tasks/compactor.js +68 -0
- package/dist/tasks/executor.js +88 -0
- package/dist/tasks/queue.js +72 -0
- package/dist/tools/index.js +108 -5
- package/dist/tui/App.js +0 -1
- package/dist/tui/InputBar.js +379 -32
- package/dist/tui/components/AtPicker.js +0 -1
- package/dist/tui/components/CommandPalette.js +4 -3
- package/dist/tui/components/InputArea.js +25 -13
- package/dist/tui/components/MessageList.js +12 -1
- package/dist/tui/components/ModelPicker.js +0 -1
- package/dist/tui/components/StatusBar.js +0 -1
- package/dist/tui/printer.js +0 -1
- package/dist/types.js +0 -1
- package/dist/workers/context.worker.js +0 -1
- package/dist/workers/spawn.js +0 -1
- package/package.json +6 -3
- package/.claude/settings.local.json +0 -28
- package/CONTRIBUTING.md +0 -55
- package/Makefile +0 -13
- package/dist/config.d.ts +0 -2
- package/dist/config.js.map +0 -1
- package/dist/files/ops.d.ts +0 -14
- package/dist/files/ops.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js.map +0 -1
- package/dist/init.d.ts +0 -1
- package/dist/init.js.map +0 -1
- package/dist/llm/ollama.d.ts +0 -10
- package/dist/llm/ollama.js.map +0 -1
- package/dist/llm/stream.d.ts +0 -12
- package/dist/llm/stream.js.map +0 -1
- package/dist/parser/stream-parser.d.ts +0 -21
- package/dist/parser/stream-parser.js.map +0 -1
- package/dist/sessions.d.ts +0 -9
- package/dist/sessions.js.map +0 -1
- package/dist/skills/loader.d.ts +0 -23
- package/dist/skills/loader.js.map +0 -1
- package/dist/tools/index.d.ts +0 -8
- package/dist/tools/index.js.map +0 -1
- package/dist/tui/App.d.ts +0 -9
- package/dist/tui/App.js.map +0 -1
- package/dist/tui/InputBar.d.ts +0 -10
- package/dist/tui/InputBar.js.map +0 -1
- package/dist/tui/components/AtPicker.d.ts +0 -8
- package/dist/tui/components/AtPicker.js.map +0 -1
- package/dist/tui/components/CommandPalette.d.ts +0 -8
- package/dist/tui/components/CommandPalette.js.map +0 -1
- package/dist/tui/components/InputArea.d.ts +0 -12
- package/dist/tui/components/InputArea.js.map +0 -1
- package/dist/tui/components/MessageList.d.ts +0 -11
- package/dist/tui/components/MessageList.js.map +0 -1
- package/dist/tui/components/ModelPicker.d.ts +0 -18
- package/dist/tui/components/ModelPicker.js.map +0 -1
- package/dist/tui/components/StatusBar.d.ts +0 -12
- package/dist/tui/components/StatusBar.js.map +0 -1
- package/dist/tui/printer.d.ts +0 -7
- package/dist/tui/printer.js.map +0 -1
- package/dist/types.d.ts +0 -20
- package/dist/types.js.map +0 -1
- package/dist/workers/context.worker.js.map +0 -1
- package/dist/workers/diff.worker.d.ts +0 -1
- package/dist/workers/diff.worker.js +0 -12
- package/dist/workers/diff.worker.js.map +0 -1
- package/dist/workers/spawn.d.ts +0 -1
- package/dist/workers/spawn.js.map +0 -1
- package/install.sh +0 -6
- package/mii-cli.gif +0 -0
- package/src/config.ts +0 -32
- package/src/files/ops.ts +0 -89
- package/src/index.ts +0 -11
- package/src/init.ts +0 -41
- package/src/llm/ollama.ts +0 -110
- package/src/llm/stream.ts +0 -55
- package/src/parser/stream-parser.ts +0 -196
- package/src/sessions.ts +0 -54
- package/src/skills/loader.ts +0 -144
- package/src/tools/index.ts +0 -151
- package/src/tui/App.tsx +0 -355
- package/src/tui/InputBar.tsx +0 -381
- package/src/tui/components/AtPicker.tsx +0 -49
- package/src/tui/components/CommandPalette.tsx +0 -50
- package/src/tui/components/InputArea.tsx +0 -297
- package/src/tui/components/MessageList.tsx +0 -219
- package/src/tui/components/ModelPicker.tsx +0 -134
- package/src/tui/components/StatusBar.tsx +0 -36
- package/src/tui/printer.ts +0 -130
- package/src/types.ts +0 -26
- package/src/workers/context.worker.ts +0 -66
- package/src/workers/diff.worker.ts +0 -20
- package/src/workers/spawn.ts +0 -19
- package/tsconfig.json +0 -18
- /package/dist/{workers/context.worker.d.ts → tasks/types.js} +0 -0
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
import React, { useState, useMemo } from 'react'
|
|
2
|
-
import { Box, Text, useInput } from 'ink'
|
|
3
|
-
import type { Key } from 'ink'
|
|
4
|
-
import type { Status } from '../../types.js'
|
|
5
|
-
import type { Skill } from '../../skills/loader.js'
|
|
6
|
-
import type { FileEntry } from '../../files/ops.js'
|
|
7
|
-
import { listFiles } from '../../files/ops.js'
|
|
8
|
-
import { CommandPalette } from './CommandPalette.js'
|
|
9
|
-
import { AtPicker } from './AtPicker.js'
|
|
10
|
-
|
|
11
|
-
const BUILTIN_COMMANDS: Skill[] = [
|
|
12
|
-
{ ns: 'builtin', name: 'new', description: 'start a fresh session (auto-named)' },
|
|
13
|
-
{ ns: 'builtin', name: 'models', description: 'switch or pull Ollama models' },
|
|
14
|
-
{ ns: 'builtin', name: 'clear', description: 'clear chat history for current session' },
|
|
15
|
-
{ ns: 'builtin', name: 'sessions', description: 'list all saved sessions' },
|
|
16
|
-
{ ns: 'builtin', name: 'session', description: 'switch session /session <name>' },
|
|
17
|
-
{ ns: 'builtin', name: 'exit', description: 'exit miii' },
|
|
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' },
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
type Overlay = 'none' | 'command' | 'at'
|
|
30
|
-
|
|
31
|
-
interface Props {
|
|
32
|
-
status: Status
|
|
33
|
-
skills: Skill[]
|
|
34
|
-
cwd: string
|
|
35
|
-
planningMode?: boolean
|
|
36
|
-
onSubmit: (text: string) => void
|
|
37
|
-
onAbort: () => void
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort }: Props) {
|
|
41
|
-
const [lines, setLines] = useState<string[]>([''])
|
|
42
|
-
const [cursor, setCursor] = useState({ row: 0, col: 0 })
|
|
43
|
-
const [overlay, setOverlay] = useState<Overlay>('none')
|
|
44
|
-
const [overlayIdx, setOverlayIdx] = useState(0)
|
|
45
|
-
|
|
46
|
-
const [files] = useState<FileEntry[]>(() => {
|
|
47
|
-
try { return listFiles(cwd, true) } catch { return [] }
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
// built-ins first, then loaded skills (deduplicated by name)
|
|
51
|
-
const allCommands = useMemo(() => {
|
|
52
|
-
const builtinNames = new Set(BUILTIN_COMMANDS.map(b => b.name))
|
|
53
|
-
const userSkills = skills.filter(s => !builtinNames.has(s.name))
|
|
54
|
-
const base = [...BUILTIN_COMMANDS, ...userSkills]
|
|
55
|
-
return planningMode ? [...PLANNING_COMMANDS, ...base] : base
|
|
56
|
-
}, [skills, planningMode])
|
|
57
|
-
|
|
58
|
-
const isActive = status === 'idle'
|
|
59
|
-
const fullInput = lines.join('\n')
|
|
60
|
-
|
|
61
|
-
const commandQuery = useMemo(() =>
|
|
62
|
-
fullInput.startsWith('/') ? fullInput.slice(1) : '',
|
|
63
|
-
[fullInput]
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
const atQuery = useMemo(() => {
|
|
67
|
-
const line = lines[cursor.row] ?? ''
|
|
68
|
-
const before = line.slice(0, cursor.col)
|
|
69
|
-
const atIdx = before.lastIndexOf('@')
|
|
70
|
-
if (atIdx === -1) return ''
|
|
71
|
-
const after = before.slice(atIdx + 1)
|
|
72
|
-
if (after.includes(' ')) return '' // space breaks @ ref
|
|
73
|
-
return after
|
|
74
|
-
}, [lines, cursor])
|
|
75
|
-
|
|
76
|
-
const filteredCommands = useMemo(() => {
|
|
77
|
-
const q = commandQuery.toLowerCase()
|
|
78
|
-
if (!q) return allCommands.slice(0, 10)
|
|
79
|
-
return allCommands.filter(s =>
|
|
80
|
-
s.name.includes(q) ||
|
|
81
|
-
`${s.ns}:${s.name}`.includes(q) ||
|
|
82
|
-
s.description.toLowerCase().includes(q)
|
|
83
|
-
).slice(0, 10)
|
|
84
|
-
}, [commandQuery, allCommands])
|
|
85
|
-
|
|
86
|
-
const filteredFiles = useMemo(() => {
|
|
87
|
-
if (!atQuery) return files.slice(0, 8)
|
|
88
|
-
return files.filter(f => f.rel.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 8)
|
|
89
|
-
}, [atQuery, files])
|
|
90
|
-
|
|
91
|
-
const overlayCount = overlay === 'command' ? filteredCommands.length : filteredFiles.length
|
|
92
|
-
|
|
93
|
-
function clearInput() {
|
|
94
|
-
setLines([''])
|
|
95
|
-
setCursor({ row: 0, col: 0 })
|
|
96
|
-
setOverlay('none')
|
|
97
|
-
setOverlayIdx(0)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function appendChar(ch: string) {
|
|
101
|
-
setLines(prev => {
|
|
102
|
-
const next = [...prev]
|
|
103
|
-
const r = cursor.row
|
|
104
|
-
next[r] = next[r].slice(0, cursor.col) + ch + next[r].slice(cursor.col)
|
|
105
|
-
return next
|
|
106
|
-
})
|
|
107
|
-
setCursor(c => ({ ...c, col: c.col + ch.length }))
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function deleteChar() {
|
|
111
|
-
const { row, col } = cursor
|
|
112
|
-
setLines(prev => {
|
|
113
|
-
const next = [...prev]
|
|
114
|
-
if (col > 0) {
|
|
115
|
-
next[row] = next[row].slice(0, col - 1) + next[row].slice(col)
|
|
116
|
-
} else if (row > 0) {
|
|
117
|
-
const prevLen = next[row - 1].length
|
|
118
|
-
next.splice(row - 1, 2, next[row - 1] + next[row])
|
|
119
|
-
setCursor({ row: row - 1, col: prevLen })
|
|
120
|
-
return next
|
|
121
|
-
}
|
|
122
|
-
return next
|
|
123
|
-
})
|
|
124
|
-
if (col > 0) setCursor(c => ({ ...c, col: c.col - 1 }))
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function selectCommand(skill: Skill) {
|
|
128
|
-
const name = (skill.ns === 'default' || skill.ns === 'builtin')
|
|
129
|
-
? `/${skill.name}`
|
|
130
|
-
: `/${skill.ns}:${skill.name}`
|
|
131
|
-
clearInput()
|
|
132
|
-
onSubmit(name)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function selectFile(file: FileEntry) {
|
|
136
|
-
setLines(prev => {
|
|
137
|
-
const next = [...prev]
|
|
138
|
-
const r = cursor.row
|
|
139
|
-
const line = next[r]
|
|
140
|
-
const before = line.slice(0, cursor.col)
|
|
141
|
-
const atIdx = before.lastIndexOf('@')
|
|
142
|
-
if (atIdx === -1) return prev
|
|
143
|
-
const newLine = line.slice(0, atIdx) + '@' + file.rel + ' ' + line.slice(cursor.col)
|
|
144
|
-
next[r] = newLine
|
|
145
|
-
setCursor({ row: r, col: atIdx + 1 + file.rel.length + 1 })
|
|
146
|
-
return next
|
|
147
|
-
})
|
|
148
|
-
setOverlay('none')
|
|
149
|
-
setOverlayIdx(0)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
useInput((input: string, key: Key) => {
|
|
153
|
-
// ESC: close overlay, abort stream, or clear input
|
|
154
|
-
if (key.escape) {
|
|
155
|
-
if (overlay !== 'none') { setOverlay('none'); setOverlayIdx(0); return }
|
|
156
|
-
if (status !== 'idle') { onAbort(); return }
|
|
157
|
-
clearInput(); return
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Ctrl+C
|
|
161
|
-
if (key.ctrl && input === 'c') {
|
|
162
|
-
if (status !== 'idle') { onAbort() } else { process.exit(0) }
|
|
163
|
-
return
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (!isActive) return
|
|
167
|
-
|
|
168
|
-
// Overlay navigation
|
|
169
|
-
if (overlay !== 'none') {
|
|
170
|
-
if (key.upArrow) { setOverlayIdx(i => Math.max(0, i - 1)); return }
|
|
171
|
-
if (key.downArrow) { setOverlayIdx(i => Math.min(overlayCount - 1, i + 1)); return }
|
|
172
|
-
if (key.return) {
|
|
173
|
-
if (overlay === 'command') {
|
|
174
|
-
if (commandQuery.includes(' ')) {
|
|
175
|
-
// has args — submit full text, don't pick from palette
|
|
176
|
-
const text = fullInput.trim()
|
|
177
|
-
if (text) { clearInput(); onSubmit(text) }
|
|
178
|
-
} else if (filteredCommands[overlayIdx]) {
|
|
179
|
-
selectCommand(filteredCommands[overlayIdx])
|
|
180
|
-
}
|
|
181
|
-
} else if (overlay === 'at' && filteredFiles[overlayIdx]) {
|
|
182
|
-
selectFile(filteredFiles[overlayIdx])
|
|
183
|
-
}
|
|
184
|
-
return
|
|
185
|
-
}
|
|
186
|
-
// backspace/typing falls through to normal handling below
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (key.return) {
|
|
190
|
-
const text = fullInput.trim()
|
|
191
|
-
if (text) { clearInput(); onSubmit(text) }
|
|
192
|
-
return
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (key.backspace || key.delete) {
|
|
196
|
-
deleteChar()
|
|
197
|
-
// Recompute overlay trigger for updated input
|
|
198
|
-
const r = cursor.row
|
|
199
|
-
const col = cursor.col
|
|
200
|
-
const prospectiveLine = col > 0
|
|
201
|
-
? lines[r].slice(0, col - 1) + lines[r].slice(col)
|
|
202
|
-
: lines[r]
|
|
203
|
-
const prospectiveLines = [...lines]
|
|
204
|
-
prospectiveLines[r] = prospectiveLine
|
|
205
|
-
const prospective = prospectiveLines.join('\n')
|
|
206
|
-
|
|
207
|
-
if (overlay === 'command' && !prospective.startsWith('/')) setOverlay('none')
|
|
208
|
-
if (overlay === 'at') {
|
|
209
|
-
const before = prospectiveLine.slice(0, Math.max(0, col - 1))
|
|
210
|
-
const atIdx = before.lastIndexOf('@')
|
|
211
|
-
if (atIdx === -1) setOverlay('none')
|
|
212
|
-
}
|
|
213
|
-
return
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (key.upArrow && overlay === 'none') { setCursor(c => ({ row: Math.max(0, c.row - 1), col: 0 })); return }
|
|
217
|
-
if (key.downArrow && overlay === 'none') { setCursor(c => ({ row: Math.min(lines.length - 1, c.row + 1), col: 0 })); return }
|
|
218
|
-
if (key.leftArrow) { setCursor(c => ({ ...c, col: Math.max(0, c.col - 1) })); return }
|
|
219
|
-
if (key.rightArrow) { setCursor(c => ({ ...c, col: Math.min(lines[c.row]?.length ?? 0, c.col + 1) })); return }
|
|
220
|
-
|
|
221
|
-
if (input && !key.ctrl && !key.meta) {
|
|
222
|
-
// Compute prospective new input to decide overlay
|
|
223
|
-
const r = cursor.row
|
|
224
|
-
const col = cursor.col
|
|
225
|
-
const prospectiveLine = lines[r].slice(0, col) + input + lines[r].slice(col)
|
|
226
|
-
const prospectiveLines = [...lines]
|
|
227
|
-
prospectiveLines[r] = prospectiveLine
|
|
228
|
-
const prospective = prospectiveLines.join('\n')
|
|
229
|
-
|
|
230
|
-
appendChar(input)
|
|
231
|
-
|
|
232
|
-
// Open/update overlays
|
|
233
|
-
if (prospective.startsWith('/')) {
|
|
234
|
-
const q = prospective.slice(1)
|
|
235
|
-
if (q.includes(' ')) {
|
|
236
|
-
setOverlay('none') // typing args — close palette, let user type freely
|
|
237
|
-
} else {
|
|
238
|
-
setOverlay('command')
|
|
239
|
-
setOverlayIdx(0)
|
|
240
|
-
}
|
|
241
|
-
} else if (input === '@' || (overlay === 'at' && atQuery !== undefined)) {
|
|
242
|
-
setOverlay('at')
|
|
243
|
-
setOverlayIdx(0)
|
|
244
|
-
} else if (overlay === 'command') {
|
|
245
|
-
setOverlay('none')
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
const isProcessing = status !== 'idle'
|
|
251
|
-
const borderColor = isProcessing ? 'yellow' : 'cyan'
|
|
252
|
-
const hint = isProcessing
|
|
253
|
-
? 'esc to abort'
|
|
254
|
-
: overlay === 'command' && !commandQuery.includes(' ')
|
|
255
|
-
? '↑↓ navigate enter select esc close'
|
|
256
|
-
: overlay === 'at'
|
|
257
|
-
? '↑↓ navigate enter select esc close'
|
|
258
|
-
: planningMode
|
|
259
|
-
? '📋 planning mode / suggestions enter send /plan:done to exit'
|
|
260
|
-
: '@ file / command enter send ctrl+c exit'
|
|
261
|
-
|
|
262
|
-
return (
|
|
263
|
-
<Box flexDirection="column">
|
|
264
|
-
{overlay === 'command' && (
|
|
265
|
-
<CommandPalette skills={allCommands} query={commandQuery} idx={overlayIdx} />
|
|
266
|
-
)}
|
|
267
|
-
{overlay === 'at' && (
|
|
268
|
-
<AtPicker files={filteredFiles} query={atQuery} idx={overlayIdx} />
|
|
269
|
-
)}
|
|
270
|
-
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="column">
|
|
271
|
-
<Box>
|
|
272
|
-
<Text color={borderColor} bold>{'❯ '}</Text>
|
|
273
|
-
<Box flexDirection="column" flexGrow={1}>
|
|
274
|
-
{lines.length === 1 && !lines[0] ? (
|
|
275
|
-
<Text color={isActive ? 'white' : 'gray'} dimColor={isProcessing}>
|
|
276
|
-
{isActive ? '█' : 'processing...'}
|
|
277
|
-
</Text>
|
|
278
|
-
) : (
|
|
279
|
-
lines.map((line, i) => (
|
|
280
|
-
<Text key={i} wrap="wrap">
|
|
281
|
-
{i === cursor.row
|
|
282
|
-
? renderLineWithCursor(line, cursor.col, isActive)
|
|
283
|
-
: line}
|
|
284
|
-
</Text>
|
|
285
|
-
))
|
|
286
|
-
)}
|
|
287
|
-
</Box>
|
|
288
|
-
</Box>
|
|
289
|
-
<Text color="gray" dimColor>{hint}</Text>
|
|
290
|
-
</Box>
|
|
291
|
-
</Box>
|
|
292
|
-
)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function renderLineWithCursor(line: string, col: number, showCursor: boolean): string {
|
|
296
|
-
return line.slice(0, col) + (showCursor ? '█' : '') + line.slice(col)
|
|
297
|
-
}
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import { useMemo } from 'react'
|
|
2
|
-
import { Box, Text } from 'ink'
|
|
3
|
-
import type { Message } from '../../types.js'
|
|
4
|
-
|
|
5
|
-
interface Props {
|
|
6
|
-
messages: Message[]
|
|
7
|
-
rows: number
|
|
8
|
-
cols: number
|
|
9
|
-
scrollOffset: number // 0 = pinned at bottom; N = N msgs hidden from bottom
|
|
10
|
-
streaming?: boolean
|
|
11
|
-
thinkingTick?: number
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// ─── height estimation ───────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
function msgHeight(msg: Message, cols: number): number {
|
|
17
|
-
const usable = Math.max(cols - 8, 20)
|
|
18
|
-
if (msg.role === 'system') return 2
|
|
19
|
-
if (msg.role === 'tool') return 3
|
|
20
|
-
let h = 2 // label + blank
|
|
21
|
-
for (const line of msg.content.split('\n')) {
|
|
22
|
-
h += Math.max(1, Math.ceil((line.length || 1) / usable))
|
|
23
|
-
}
|
|
24
|
-
return Math.min(h, 40)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface Slice {
|
|
28
|
-
visible: Message[]
|
|
29
|
-
hiddenAbove: number
|
|
30
|
-
hiddenBelow: number
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function computeSlice(messages: Message[], availRows: number, offset: number, cols: number): Slice {
|
|
34
|
-
const clampedOffset = Math.max(0, Math.min(offset, Math.max(0, messages.length - 1)))
|
|
35
|
-
const endIdx = messages.length - clampedOffset
|
|
36
|
-
|
|
37
|
-
let startIdx = endIdx
|
|
38
|
-
let usedRows = 0
|
|
39
|
-
while (startIdx > 0) {
|
|
40
|
-
const h = msgHeight(messages[startIdx - 1], cols)
|
|
41
|
-
if (usedRows + h > availRows) break
|
|
42
|
-
startIdx--
|
|
43
|
-
usedRows += h
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
visible: messages.slice(startIdx, endIdx),
|
|
48
|
-
hiddenAbove: startIdx,
|
|
49
|
-
hiddenBelow: clampedOffset,
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ─── segments ────────────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
interface Segment { text: string; code: boolean; fence: boolean }
|
|
56
|
-
|
|
57
|
-
function parseSegments(content: string): Segment[] {
|
|
58
|
-
const segs: Segment[] = []
|
|
59
|
-
let inCode = false
|
|
60
|
-
for (const line of content.split('\n')) {
|
|
61
|
-
if (line.startsWith('```')) {
|
|
62
|
-
segs.push({ text: line, code: false, fence: true })
|
|
63
|
-
inCode = !inCode
|
|
64
|
-
} else {
|
|
65
|
-
segs.push({ text: line, code: inCode, fence: false })
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return segs
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function ContentBlock({ content }: { content: string }) {
|
|
72
|
-
const segs = useMemo(() => parseSegments(content), [content])
|
|
73
|
-
return (
|
|
74
|
-
<Box flexDirection="column" paddingLeft={2}>
|
|
75
|
-
{segs.map((seg, i) =>
|
|
76
|
-
seg.fence ? (
|
|
77
|
-
<Text key={i} color="gray" dimColor>{seg.text}</Text>
|
|
78
|
-
) : seg.code ? (
|
|
79
|
-
<Text key={i} color="yellow">{seg.text || ' '}</Text>
|
|
80
|
-
) : (
|
|
81
|
-
<Text key={i} wrap="wrap">{seg.text || ' '}</Text>
|
|
82
|
-
)
|
|
83
|
-
)}
|
|
84
|
-
</Box>
|
|
85
|
-
)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ─── message renderers ───────────────────────────────────────────────────────
|
|
89
|
-
|
|
90
|
-
function UserMsg({ msg }: { msg: Message }) {
|
|
91
|
-
const parts = msg.content.split(/(@[\w./\-]+)/g)
|
|
92
|
-
return (
|
|
93
|
-
<Box flexDirection="column" marginBottom={1}>
|
|
94
|
-
<Text bold color="blue">You</Text>
|
|
95
|
-
<Box paddingLeft={2}>
|
|
96
|
-
<Text wrap="wrap">
|
|
97
|
-
{parts.map((p, i) =>
|
|
98
|
-
p.startsWith('@')
|
|
99
|
-
? <Text key={i} color="cyan">{p}</Text>
|
|
100
|
-
: <Text key={i}>{p}</Text>
|
|
101
|
-
)}
|
|
102
|
-
</Text>
|
|
103
|
-
</Box>
|
|
104
|
-
</Box>
|
|
105
|
-
)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const THINKING_PHRASES = [
|
|
109
|
-
'oh wow, a question. let me pretend to care…',
|
|
110
|
-
'consulting the void…',
|
|
111
|
-
'making something up, just a sec…',
|
|
112
|
-
'definitely not hallucinating right now…',
|
|
113
|
-
'running 47 mental tabs…',
|
|
114
|
-
'staring into the abyss (it blinked)…',
|
|
115
|
-
'calculating your fate, no pressure…',
|
|
116
|
-
'doing the thinking you pay me for…',
|
|
117
|
-
'processing your questionable life choices…',
|
|
118
|
-
'summoning coherent thoughts, rarely works…',
|
|
119
|
-
]
|
|
120
|
-
const SPARKLE = ['✦', '✧', '✶', '✷', '✸', '✹']
|
|
121
|
-
|
|
122
|
-
function AssistantMsg({ msg, thinkingTick }: { msg: Message; thinkingTick?: number }) {
|
|
123
|
-
if (!msg.content && thinkingTick !== undefined) {
|
|
124
|
-
const phrase = THINKING_PHRASES[Math.floor(thinkingTick / 62) % THINKING_PHRASES.length]
|
|
125
|
-
const icon = SPARKLE[thinkingTick % SPARKLE.length]
|
|
126
|
-
return (
|
|
127
|
-
<Box flexDirection="column" marginBottom={1}>
|
|
128
|
-
<Text bold color="green">miii</Text>
|
|
129
|
-
<Box paddingLeft={2}>
|
|
130
|
-
<Text color="yellow">{icon} </Text><Text color="gray" dimColor italic>{phrase}</Text>
|
|
131
|
-
</Box>
|
|
132
|
-
</Box>
|
|
133
|
-
)
|
|
134
|
-
}
|
|
135
|
-
return (
|
|
136
|
-
<Box flexDirection="column" marginBottom={1}>
|
|
137
|
-
<Text bold color="green">miii</Text>
|
|
138
|
-
<ContentBlock content={msg.content} />
|
|
139
|
-
</Box>
|
|
140
|
-
)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function ToolMsg({ msg }: { msg: Message }) {
|
|
144
|
-
const lines = msg.content.split('\n')
|
|
145
|
-
const name = (lines[0] ?? '').replace(/^\[/, '').replace(/\]$/, '')
|
|
146
|
-
const body = lines.slice(1).join('\n').trim()
|
|
147
|
-
return (
|
|
148
|
-
<Box flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
149
|
-
<Text color="green">✓ <Text color="cyan">{name}</Text></Text>
|
|
150
|
-
{body && (
|
|
151
|
-
<Box paddingLeft={2}>
|
|
152
|
-
<Text color="gray" dimColor wrap="wrap">
|
|
153
|
-
{body.length > 300 ? body.slice(0, 300) + '…' : body}
|
|
154
|
-
</Text>
|
|
155
|
-
</Box>
|
|
156
|
-
)}
|
|
157
|
-
</Box>
|
|
158
|
-
)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function SystemMsg({ msg }: { msg: Message }) {
|
|
162
|
-
return (
|
|
163
|
-
<Box marginBottom={1} paddingLeft={1}>
|
|
164
|
-
<Text color="gray" dimColor>─ {msg.content}</Text>
|
|
165
|
-
</Box>
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function MsgItem({ msg, thinkingTick }: { msg: Message; thinkingTick?: number }) {
|
|
170
|
-
switch (msg.role) {
|
|
171
|
-
case 'user': return <UserMsg msg={msg} />
|
|
172
|
-
case 'assistant': return <AssistantMsg msg={msg} thinkingTick={thinkingTick} />
|
|
173
|
-
case 'tool': return <ToolMsg msg={msg} />
|
|
174
|
-
case 'system': return <SystemMsg msg={msg} />
|
|
175
|
-
default: return null
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ─── scroll hint bar ─────────────────────────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
function ScrollHint({ hiddenAbove, hiddenBelow }: { hiddenAbove: number; hiddenBelow: number }) {
|
|
182
|
-
if (hiddenAbove === 0 && hiddenBelow === 0) return null
|
|
183
|
-
const parts: string[] = []
|
|
184
|
-
if (hiddenAbove > 0) parts.push(`↑ ${hiddenAbove} above`)
|
|
185
|
-
if (hiddenBelow > 0) parts.push(`↓ ${hiddenBelow} below`)
|
|
186
|
-
return (
|
|
187
|
-
<Box justifyContent="center">
|
|
188
|
-
<Text color="gray" dimColor>{parts.join(' ')} · PgUp/PgDn</Text>
|
|
189
|
-
</Box>
|
|
190
|
-
)
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// ─── main export ─────────────────────────────────────────────────────────────
|
|
194
|
-
|
|
195
|
-
export function MessageList({ messages, rows, cols, scrollOffset, streaming, thinkingTick }: Props) {
|
|
196
|
-
const availRows = Math.max(rows - 2, 4)
|
|
197
|
-
const { visible, hiddenAbove, hiddenBelow } = useMemo(
|
|
198
|
-
() => computeSlice(messages, availRows, scrollOffset, cols),
|
|
199
|
-
[messages, availRows, scrollOffset, cols]
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
return (
|
|
203
|
-
<Box flexDirection="column" flexGrow={1} overflow="hidden" paddingX={1}>
|
|
204
|
-
<ScrollHint hiddenAbove={hiddenAbove} hiddenBelow={hiddenBelow} />
|
|
205
|
-
|
|
206
|
-
{visible.length === 0 && hiddenAbove === 0 && (
|
|
207
|
-
<Box paddingTop={1}>
|
|
208
|
-
<Text color="gray" dimColor>start typing below — @ for files, / for commands</Text>
|
|
209
|
-
</Box>
|
|
210
|
-
)}
|
|
211
|
-
|
|
212
|
-
{visible.map(msg => <MsgItem key={msg.id} msg={msg} thinkingTick={thinkingTick} />)}
|
|
213
|
-
|
|
214
|
-
{streaming && scrollOffset === 0 && (
|
|
215
|
-
<Box paddingLeft={2}><Text color="gray" dimColor>▋</Text></Box>
|
|
216
|
-
)}
|
|
217
|
-
</Box>
|
|
218
|
-
)
|
|
219
|
-
}
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
2
|
-
import { Box, Text, useInput } from 'ink'
|
|
3
|
-
import type { OllamaModel } from '../../llm/ollama.js'
|
|
4
|
-
import { fmtSize } from '../../llm/ollama.js'
|
|
5
|
-
|
|
6
|
-
type Mode = 'list' | 'pull-input' | 'pulling'
|
|
7
|
-
|
|
8
|
-
interface PullState {
|
|
9
|
-
name: string
|
|
10
|
-
status: string
|
|
11
|
-
pct: number | undefined
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface Props {
|
|
15
|
-
models: OllamaModel[]
|
|
16
|
-
current: string
|
|
17
|
-
loading: boolean
|
|
18
|
-
error?: string
|
|
19
|
-
pull?: PullState
|
|
20
|
-
onSelect: (name: string) => void
|
|
21
|
-
onPull: (name: string) => void
|
|
22
|
-
onClose: () => void
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const BAR_WIDTH = 20
|
|
26
|
-
|
|
27
|
-
function progressBar(pct: number): string {
|
|
28
|
-
const filled = Math.round((pct / 100) * BAR_WIDTH)
|
|
29
|
-
return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function ModelPicker({ models, current, loading, error, pull, onSelect, onPull, onClose }: Props) {
|
|
33
|
-
const [idx, setIdx] = useState(() => {
|
|
34
|
-
const i = models.findIndex(m => m.name === current)
|
|
35
|
-
return i >= 0 ? i : 0
|
|
36
|
-
})
|
|
37
|
-
const [mode, setMode] = useState<Mode>('list')
|
|
38
|
-
const [pullInput, setPullInput] = useState('')
|
|
39
|
-
|
|
40
|
-
const totalItems = models.length + 1 // +1 for "pull new" row
|
|
41
|
-
|
|
42
|
-
useInput((input, key) => {
|
|
43
|
-
if (key.escape) {
|
|
44
|
-
if (mode === 'pull-input') { setMode('list'); setPullInput(''); return }
|
|
45
|
-
onClose()
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (mode === 'list') {
|
|
50
|
-
if (key.upArrow) { setIdx(i => Math.max(0, i - 1)); return }
|
|
51
|
-
if (key.downArrow) { setIdx(i => Math.min(totalItems - 1, i + 1)); return }
|
|
52
|
-
if (key.return) {
|
|
53
|
-
if (idx < models.length) {
|
|
54
|
-
onSelect(models[idx].name)
|
|
55
|
-
} else {
|
|
56
|
-
setMode('pull-input')
|
|
57
|
-
}
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
return
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (mode === 'pull-input') {
|
|
64
|
-
if (key.return) {
|
|
65
|
-
const name = pullInput.trim()
|
|
66
|
-
if (name) { setMode('pulling'); onPull(name) }
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
if (key.backspace || key.delete) { setPullInput(p => p.slice(0, -1)); return }
|
|
70
|
-
if (input && !key.ctrl && !key.meta) { setPullInput(p => p + input); return }
|
|
71
|
-
}
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
return (
|
|
75
|
-
<Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor="cyan" paddingX={1}>
|
|
76
|
-
<Box marginBottom={1}>
|
|
77
|
-
<Text bold color="cyan"> models </Text>
|
|
78
|
-
{loading && <Text color="yellow"> loading...</Text>}
|
|
79
|
-
{error && <Text color="red"> {error}</Text>}
|
|
80
|
-
</Box>
|
|
81
|
-
|
|
82
|
-
{mode === 'list' && (
|
|
83
|
-
<>
|
|
84
|
-
{models.map((m, i) => {
|
|
85
|
-
const active = i === idx
|
|
86
|
-
const isCurrent = m.name === current
|
|
87
|
-
const age = new Date(m.modified_at).toLocaleDateString()
|
|
88
|
-
return (
|
|
89
|
-
<Box key={m.name}>
|
|
90
|
-
<Text color={active ? 'cyan' : 'white'}>
|
|
91
|
-
{active ? '▶ ' : ' '}
|
|
92
|
-
{m.name.padEnd(28)}
|
|
93
|
-
</Text>
|
|
94
|
-
<Text color="gray">{fmtSize(m.size).padEnd(8)}{age}</Text>
|
|
95
|
-
{isCurrent && <Text color="green" bold> ✓ active</Text>}
|
|
96
|
-
</Box>
|
|
97
|
-
)
|
|
98
|
-
})}
|
|
99
|
-
<Box marginTop={1}>
|
|
100
|
-
<Text color={idx === models.length ? 'cyan' : 'gray'}>
|
|
101
|
-
{idx === models.length ? '▶ ' : ' '}
|
|
102
|
-
[pull new model...]
|
|
103
|
-
</Text>
|
|
104
|
-
</Box>
|
|
105
|
-
</>
|
|
106
|
-
)}
|
|
107
|
-
|
|
108
|
-
{mode === 'pull-input' && (
|
|
109
|
-
<Box flexDirection="column">
|
|
110
|
-
<Box>
|
|
111
|
-
<Text color="cyan">model name: </Text>
|
|
112
|
-
<Text>{pullInput}█</Text>
|
|
113
|
-
</Box>
|
|
114
|
-
<Text color="gray" dimColor>enter to pull, esc to cancel</Text>
|
|
115
|
-
</Box>
|
|
116
|
-
)}
|
|
117
|
-
|
|
118
|
-
{mode === 'pulling' && pull && (
|
|
119
|
-
<Box flexDirection="column">
|
|
120
|
-
<Text>pulling <Text color="cyan">{pull.name}</Text></Text>
|
|
121
|
-
<Box>
|
|
122
|
-
<Text color="yellow">{progressBar(pull.pct ?? 0)} </Text>
|
|
123
|
-
<Text>{pull.pct !== undefined ? `${pull.pct}%` : ''}</Text>
|
|
124
|
-
</Box>
|
|
125
|
-
<Text color="gray" dimColor>{pull.status}</Text>
|
|
126
|
-
</Box>
|
|
127
|
-
)}
|
|
128
|
-
|
|
129
|
-
<Box marginTop={1} borderTop borderStyle="single" borderColor="gray">
|
|
130
|
-
<Text color="gray" dimColor>↑↓ navigate enter select esc close</Text>
|
|
131
|
-
</Box>
|
|
132
|
-
</Box>
|
|
133
|
-
)
|
|
134
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { Box, Text } from 'ink'
|
|
3
|
-
import type { Status } from '../../types.js'
|
|
4
|
-
|
|
5
|
-
const DOTS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
6
|
-
|
|
7
|
-
interface Props {
|
|
8
|
-
model: string
|
|
9
|
-
provider: string
|
|
10
|
-
status: Status
|
|
11
|
-
tick: number
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function StatusBar({ model, provider, status, tick }: Props) {
|
|
15
|
-
const isIdle = status === 'idle'
|
|
16
|
-
const spinner = DOTS[tick % DOTS.length]
|
|
17
|
-
|
|
18
|
-
const statusNode =
|
|
19
|
-
status === 'idle' ? <Text color="green">● <Text color="gray">ready</Text></Text>
|
|
20
|
-
: status === 'thinking' ? <Text color="yellow">{spinner} <Text color="gray">thinking</Text></Text>
|
|
21
|
-
: <Text color="yellow">{spinner} <Text color="gray">tool</Text></Text>
|
|
22
|
-
|
|
23
|
-
return (
|
|
24
|
-
<Box>
|
|
25
|
-
<Box flexGrow={1} paddingX={1} paddingY={0} justifyContent="space-between">
|
|
26
|
-
<Text bold color="cyan">MIII</Text>
|
|
27
|
-
<Text color="gray" dimColor>{provider}/{model}</Text>
|
|
28
|
-
{statusNode}
|
|
29
|
-
</Box>
|
|
30
|
-
</Box>
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function Divider({ cols }: { cols: number }) {
|
|
35
|
-
return <Text color="gray" dimColor>{'─'.repeat(Math.max(cols, 10))}</Text>
|
|
36
|
-
}
|