miii-cli 0.1.0

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.
Files changed (98) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/Makefile +13 -0
  3. package/README.md +182 -0
  4. package/dist/config.d.ts +2 -0
  5. package/dist/config.js +24 -0
  6. package/dist/config.js.map +1 -0
  7. package/dist/files/ops.d.ts +11 -0
  8. package/dist/files/ops.js +66 -0
  9. package/dist/files/ops.js.map +1 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +12 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/init.d.ts +1 -0
  14. package/dist/init.js +32 -0
  15. package/dist/init.js.map +1 -0
  16. package/dist/llm/ollama.d.ts +9 -0
  17. package/dist/llm/ollama.js +51 -0
  18. package/dist/llm/ollama.js.map +1 -0
  19. package/dist/llm/stream.d.ts +12 -0
  20. package/dist/llm/stream.js +129 -0
  21. package/dist/llm/stream.js.map +1 -0
  22. package/dist/parser/stream-parser.d.ts +17 -0
  23. package/dist/parser/stream-parser.js +54 -0
  24. package/dist/parser/stream-parser.js.map +1 -0
  25. package/dist/sessions.d.ts +9 -0
  26. package/dist/sessions.js +48 -0
  27. package/dist/sessions.js.map +1 -0
  28. package/dist/skills/loader.d.ts +23 -0
  29. package/dist/skills/loader.js +91 -0
  30. package/dist/skills/loader.js.map +1 -0
  31. package/dist/tools/index.d.ts +8 -0
  32. package/dist/tools/index.js +79 -0
  33. package/dist/tools/index.js.map +1 -0
  34. package/dist/tui/App.d.ts +9 -0
  35. package/dist/tui/App.js +259 -0
  36. package/dist/tui/App.js.map +1 -0
  37. package/dist/tui/InputBar.d.ts +10 -0
  38. package/dist/tui/InputBar.js +289 -0
  39. package/dist/tui/InputBar.js.map +1 -0
  40. package/dist/tui/components/AtPicker.d.ts +8 -0
  41. package/dist/tui/components/AtPicker.js +19 -0
  42. package/dist/tui/components/AtPicker.js.map +1 -0
  43. package/dist/tui/components/CommandPalette.d.ts +8 -0
  44. package/dist/tui/components/CommandPalette.js +25 -0
  45. package/dist/tui/components/CommandPalette.js.map +1 -0
  46. package/dist/tui/components/InputArea.d.ts +11 -0
  47. package/dist/tui/components/InputArea.js +268 -0
  48. package/dist/tui/components/InputArea.js.map +1 -0
  49. package/dist/tui/components/MessageList.d.ts +10 -0
  50. package/dist/tui/components/MessageList.js +98 -0
  51. package/dist/tui/components/MessageList.js.map +1 -0
  52. package/dist/tui/components/ModelPicker.d.ts +18 -0
  53. package/dist/tui/components/ModelPicker.js +74 -0
  54. package/dist/tui/components/ModelPicker.js.map +1 -0
  55. package/dist/tui/components/StatusBar.d.ts +12 -0
  56. package/dist/tui/components/StatusBar.js +15 -0
  57. package/dist/tui/components/StatusBar.js.map +1 -0
  58. package/dist/tui/printer.d.ts +7 -0
  59. package/dist/tui/printer.js +106 -0
  60. package/dist/tui/printer.js.map +1 -0
  61. package/dist/types.d.ts +19 -0
  62. package/dist/types.js +4 -0
  63. package/dist/types.js.map +1 -0
  64. package/dist/workers/context.worker.d.ts +1 -0
  65. package/dist/workers/context.worker.js +69 -0
  66. package/dist/workers/context.worker.js.map +1 -0
  67. package/dist/workers/diff.worker.d.ts +1 -0
  68. package/dist/workers/diff.worker.js +12 -0
  69. package/dist/workers/diff.worker.js.map +1 -0
  70. package/dist/workers/spawn.d.ts +1 -0
  71. package/dist/workers/spawn.js +18 -0
  72. package/dist/workers/spawn.js.map +1 -0
  73. package/install.sh +6 -0
  74. package/package.json +29 -0
  75. package/src/config.ts +25 -0
  76. package/src/files/ops.ts +71 -0
  77. package/src/index.ts +11 -0
  78. package/src/init.ts +39 -0
  79. package/src/llm/ollama.ts +58 -0
  80. package/src/llm/stream.ts +118 -0
  81. package/src/parser/stream-parser.ts +54 -0
  82. package/src/sessions.ts +46 -0
  83. package/src/skills/loader.ts +109 -0
  84. package/src/tools/index.ts +83 -0
  85. package/src/tui/App.tsx +308 -0
  86. package/src/tui/InputBar.tsx +347 -0
  87. package/src/tui/components/AtPicker.tsx +49 -0
  88. package/src/tui/components/CommandPalette.tsx +50 -0
  89. package/src/tui/components/InputArea.tsx +285 -0
  90. package/src/tui/components/MessageList.tsx +192 -0
  91. package/src/tui/components/ModelPicker.tsx +134 -0
  92. package/src/tui/components/StatusBar.tsx +36 -0
  93. package/src/tui/printer.ts +121 -0
  94. package/src/types.ts +25 -0
  95. package/src/workers/context.worker.ts +62 -0
  96. package/src/workers/diff.worker.ts +20 -0
  97. package/src/workers/spawn.ts +19 -0
  98. package/tsconfig.json +18 -0
@@ -0,0 +1,285 @@
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
+ ]
20
+
21
+ type Overlay = 'none' | 'command' | 'at'
22
+
23
+ interface Props {
24
+ status: Status
25
+ skills: Skill[]
26
+ cwd: string
27
+ onSubmit: (text: string) => void
28
+ onAbort: () => void
29
+ }
30
+
31
+ export function InputArea({ status, skills, cwd, onSubmit, onAbort }: Props) {
32
+ const [lines, setLines] = useState<string[]>([''])
33
+ const [cursor, setCursor] = useState({ row: 0, col: 0 })
34
+ const [overlay, setOverlay] = useState<Overlay>('none')
35
+ const [overlayIdx, setOverlayIdx] = useState(0)
36
+
37
+ const [files] = useState<FileEntry[]>(() => {
38
+ try { return listFiles(cwd, true) } catch { return [] }
39
+ })
40
+
41
+ // built-ins first, then loaded skills (deduplicated by name)
42
+ const allCommands = useMemo(() => {
43
+ const builtinNames = new Set(BUILTIN_COMMANDS.map(b => b.name))
44
+ const userSkills = skills.filter(s => !builtinNames.has(s.name))
45
+ return [...BUILTIN_COMMANDS, ...userSkills]
46
+ }, [skills])
47
+
48
+ const isActive = status === 'idle'
49
+ const fullInput = lines.join('\n')
50
+
51
+ const commandQuery = useMemo(() =>
52
+ fullInput.startsWith('/') ? fullInput.slice(1) : '',
53
+ [fullInput]
54
+ )
55
+
56
+ const atQuery = useMemo(() => {
57
+ const line = lines[cursor.row] ?? ''
58
+ const before = line.slice(0, cursor.col)
59
+ const atIdx = before.lastIndexOf('@')
60
+ if (atIdx === -1) return ''
61
+ const after = before.slice(atIdx + 1)
62
+ if (after.includes(' ')) return '' // space breaks @ ref
63
+ return after
64
+ }, [lines, cursor])
65
+
66
+ const filteredCommands = useMemo(() => {
67
+ const q = commandQuery.toLowerCase()
68
+ if (!q) return allCommands.slice(0, 10)
69
+ return allCommands.filter(s =>
70
+ s.name.includes(q) ||
71
+ `${s.ns}:${s.name}`.includes(q) ||
72
+ s.description.toLowerCase().includes(q)
73
+ ).slice(0, 10)
74
+ }, [commandQuery, allCommands])
75
+
76
+ const filteredFiles = useMemo(() => {
77
+ if (!atQuery) return files.slice(0, 8)
78
+ return files.filter(f => f.rel.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 8)
79
+ }, [atQuery, files])
80
+
81
+ const overlayCount = overlay === 'command' ? filteredCommands.length : filteredFiles.length
82
+
83
+ function clearInput() {
84
+ setLines([''])
85
+ setCursor({ row: 0, col: 0 })
86
+ setOverlay('none')
87
+ setOverlayIdx(0)
88
+ }
89
+
90
+ function appendChar(ch: string) {
91
+ setLines(prev => {
92
+ const next = [...prev]
93
+ const r = cursor.row
94
+ next[r] = next[r].slice(0, cursor.col) + ch + next[r].slice(cursor.col)
95
+ return next
96
+ })
97
+ setCursor(c => ({ ...c, col: c.col + ch.length }))
98
+ }
99
+
100
+ function deleteChar() {
101
+ const { row, col } = cursor
102
+ setLines(prev => {
103
+ const next = [...prev]
104
+ if (col > 0) {
105
+ next[row] = next[row].slice(0, col - 1) + next[row].slice(col)
106
+ } else if (row > 0) {
107
+ const prevLen = next[row - 1].length
108
+ next.splice(row - 1, 2, next[row - 1] + next[row])
109
+ setCursor({ row: row - 1, col: prevLen })
110
+ return next
111
+ }
112
+ return next
113
+ })
114
+ if (col > 0) setCursor(c => ({ ...c, col: c.col - 1 }))
115
+ }
116
+
117
+ function selectCommand(skill: Skill) {
118
+ const name = (skill.ns === 'default' || skill.ns === 'builtin')
119
+ ? `/${skill.name}`
120
+ : `/${skill.ns}:${skill.name}`
121
+ clearInput()
122
+ onSubmit(name)
123
+ }
124
+
125
+ function selectFile(file: FileEntry) {
126
+ setLines(prev => {
127
+ const next = [...prev]
128
+ const r = cursor.row
129
+ const line = next[r]
130
+ const before = line.slice(0, cursor.col)
131
+ const atIdx = before.lastIndexOf('@')
132
+ if (atIdx === -1) return prev
133
+ const newLine = line.slice(0, atIdx) + '@' + file.rel + ' ' + line.slice(cursor.col)
134
+ next[r] = newLine
135
+ setCursor({ row: r, col: atIdx + 1 + file.rel.length + 1 })
136
+ return next
137
+ })
138
+ setOverlay('none')
139
+ setOverlayIdx(0)
140
+ }
141
+
142
+ useInput((input: string, key: Key) => {
143
+ // ESC: close overlay, abort stream, or clear input
144
+ if (key.escape) {
145
+ if (overlay !== 'none') { setOverlay('none'); setOverlayIdx(0); return }
146
+ if (status !== 'idle') { onAbort(); return }
147
+ clearInput(); return
148
+ }
149
+
150
+ // Ctrl+C
151
+ if (key.ctrl && input === 'c') {
152
+ if (status !== 'idle') { onAbort() } else { process.exit(0) }
153
+ return
154
+ }
155
+
156
+ if (!isActive) return
157
+
158
+ // Overlay navigation
159
+ if (overlay !== 'none') {
160
+ if (key.upArrow) { setOverlayIdx(i => Math.max(0, i - 1)); return }
161
+ if (key.downArrow) { setOverlayIdx(i => Math.min(overlayCount - 1, i + 1)); return }
162
+ if (key.return) {
163
+ if (overlay === 'command') {
164
+ if (commandQuery.includes(' ')) {
165
+ // has args — submit full text, don't pick from palette
166
+ const text = fullInput.trim()
167
+ if (text) { clearInput(); onSubmit(text) }
168
+ } else if (filteredCommands[overlayIdx]) {
169
+ selectCommand(filteredCommands[overlayIdx])
170
+ }
171
+ } else if (overlay === 'at' && filteredFiles[overlayIdx]) {
172
+ selectFile(filteredFiles[overlayIdx])
173
+ }
174
+ return
175
+ }
176
+ // backspace/typing falls through to normal handling below
177
+ }
178
+
179
+ if (key.return) {
180
+ const text = fullInput.trim()
181
+ if (text) { clearInput(); onSubmit(text) }
182
+ return
183
+ }
184
+
185
+ if (key.backspace || key.delete) {
186
+ deleteChar()
187
+ // Recompute overlay trigger for updated input
188
+ const r = cursor.row
189
+ const col = cursor.col
190
+ const prospectiveLine = col > 0
191
+ ? lines[r].slice(0, col - 1) + lines[r].slice(col)
192
+ : lines[r]
193
+ const prospectiveLines = [...lines]
194
+ prospectiveLines[r] = prospectiveLine
195
+ const prospective = prospectiveLines.join('\n')
196
+
197
+ if (overlay === 'command' && !prospective.startsWith('/')) setOverlay('none')
198
+ if (overlay === 'at') {
199
+ const before = prospectiveLine.slice(0, Math.max(0, col - 1))
200
+ const atIdx = before.lastIndexOf('@')
201
+ if (atIdx === -1) setOverlay('none')
202
+ }
203
+ return
204
+ }
205
+
206
+ if (key.upArrow && overlay === 'none') { setCursor(c => ({ row: Math.max(0, c.row - 1), col: 0 })); return }
207
+ if (key.downArrow && overlay === 'none') { setCursor(c => ({ row: Math.min(lines.length - 1, c.row + 1), col: 0 })); return }
208
+ if (key.leftArrow) { setCursor(c => ({ ...c, col: Math.max(0, c.col - 1) })); return }
209
+ if (key.rightArrow) { setCursor(c => ({ ...c, col: Math.min(lines[c.row]?.length ?? 0, c.col + 1) })); return }
210
+
211
+ if (input && !key.ctrl && !key.meta) {
212
+ // Compute prospective new input to decide overlay
213
+ const r = cursor.row
214
+ const col = cursor.col
215
+ const prospectiveLine = lines[r].slice(0, col) + input + lines[r].slice(col)
216
+ const prospectiveLines = [...lines]
217
+ prospectiveLines[r] = prospectiveLine
218
+ const prospective = prospectiveLines.join('\n')
219
+
220
+ appendChar(input)
221
+
222
+ // Open/update overlays
223
+ if (prospective.startsWith('/')) {
224
+ const q = prospective.slice(1)
225
+ if (q.includes(' ')) {
226
+ setOverlay('none') // typing args — close palette, let user type freely
227
+ } else {
228
+ setOverlay('command')
229
+ setOverlayIdx(0)
230
+ }
231
+ } else if (input === '@' || (overlay === 'at' && atQuery !== undefined)) {
232
+ setOverlay('at')
233
+ setOverlayIdx(0)
234
+ } else if (overlay === 'command') {
235
+ setOverlay('none')
236
+ }
237
+ }
238
+ })
239
+
240
+ const isProcessing = status !== 'idle'
241
+ const borderColor = isProcessing ? 'yellow' : 'cyan'
242
+ const hint = isProcessing
243
+ ? 'esc to abort'
244
+ : overlay === 'command' && !commandQuery.includes(' ')
245
+ ? '↑↓ navigate enter select esc close'
246
+ : overlay === 'at'
247
+ ? '↑↓ navigate enter select esc close'
248
+ : '@ file / command enter send ctrl+c exit'
249
+
250
+ return (
251
+ <Box flexDirection="column">
252
+ {overlay === 'command' && (
253
+ <CommandPalette skills={allCommands} query={commandQuery} idx={overlayIdx} />
254
+ )}
255
+ {overlay === 'at' && (
256
+ <AtPicker files={filteredFiles} query={atQuery} idx={overlayIdx} />
257
+ )}
258
+ <Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="column">
259
+ <Box>
260
+ <Text color={borderColor} bold>{'❯ '}</Text>
261
+ <Box flexDirection="column" flexGrow={1}>
262
+ {lines.length === 1 && !lines[0] ? (
263
+ <Text color={isActive ? 'white' : 'gray'} dimColor={isProcessing}>
264
+ {isActive ? '█' : 'processing...'}
265
+ </Text>
266
+ ) : (
267
+ lines.map((line, i) => (
268
+ <Text key={i} wrap="wrap">
269
+ {i === cursor.row
270
+ ? renderLineWithCursor(line, cursor.col, isActive)
271
+ : line}
272
+ </Text>
273
+ ))
274
+ )}
275
+ </Box>
276
+ </Box>
277
+ <Text color="gray" dimColor>{hint}</Text>
278
+ </Box>
279
+ </Box>
280
+ )
281
+ }
282
+
283
+ function renderLineWithCursor(line: string, col: number, showCursor: boolean): string {
284
+ return line.slice(0, col) + (showCursor ? '█' : '') + line.slice(col)
285
+ }
@@ -0,0 +1,192 @@
1
+ import React, { 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
+ }
12
+
13
+ // ─── height estimation ───────────────────────────────────────────────────────
14
+
15
+ function msgHeight(msg: Message, cols: number): number {
16
+ const usable = Math.max(cols - 8, 20)
17
+ if (msg.role === 'system') return 2
18
+ if (msg.role === 'tool') return 3
19
+ let h = 2 // label + blank
20
+ for (const line of msg.content.split('\n')) {
21
+ h += Math.max(1, Math.ceil((line.length || 1) / usable))
22
+ }
23
+ return Math.min(h, 40)
24
+ }
25
+
26
+ interface Slice {
27
+ visible: Message[]
28
+ hiddenAbove: number
29
+ hiddenBelow: number
30
+ }
31
+
32
+ function computeSlice(messages: Message[], availRows: number, offset: number, cols: number): Slice {
33
+ const clampedOffset = Math.max(0, Math.min(offset, Math.max(0, messages.length - 1)))
34
+ const endIdx = messages.length - clampedOffset
35
+
36
+ let startIdx = endIdx
37
+ let usedRows = 0
38
+ while (startIdx > 0) {
39
+ const h = msgHeight(messages[startIdx - 1], cols)
40
+ if (usedRows + h > availRows) break
41
+ startIdx--
42
+ usedRows += h
43
+ }
44
+
45
+ return {
46
+ visible: messages.slice(startIdx, endIdx),
47
+ hiddenAbove: startIdx,
48
+ hiddenBelow: clampedOffset,
49
+ }
50
+ }
51
+
52
+ // ─── segments ────────────────────────────────────────────────────────────────
53
+
54
+ interface Segment { text: string; code: boolean; fence: boolean }
55
+
56
+ function parseSegments(content: string): Segment[] {
57
+ const segs: Segment[] = []
58
+ let inCode = false
59
+ for (const line of content.split('\n')) {
60
+ if (line.startsWith('```')) {
61
+ segs.push({ text: line, code: false, fence: true })
62
+ inCode = !inCode
63
+ } else {
64
+ segs.push({ text: line, code: inCode, fence: false })
65
+ }
66
+ }
67
+ return segs
68
+ }
69
+
70
+ function ContentBlock({ content }: { content: string }) {
71
+ const segs = useMemo(() => parseSegments(content), [content])
72
+ return (
73
+ <Box flexDirection="column" paddingLeft={2}>
74
+ {segs.map((seg, i) =>
75
+ seg.fence ? (
76
+ <Text key={i} color="gray" dimColor>{seg.text}</Text>
77
+ ) : seg.code ? (
78
+ <Text key={i} color="yellow">{seg.text || ' '}</Text>
79
+ ) : (
80
+ <Text key={i} wrap="wrap">{seg.text || ' '}</Text>
81
+ )
82
+ )}
83
+ </Box>
84
+ )
85
+ }
86
+
87
+ // ─── message renderers ───────────────────────────────────────────────────────
88
+
89
+ function UserMsg({ msg }: { msg: Message }) {
90
+ const parts = msg.content.split(/(@[\w./\-]+)/g)
91
+ return (
92
+ <Box flexDirection="column" marginBottom={1}>
93
+ <Text bold color="blue">You</Text>
94
+ <Box paddingLeft={2}>
95
+ <Text wrap="wrap">
96
+ {parts.map((p, i) =>
97
+ p.startsWith('@')
98
+ ? <Text key={i} color="cyan">{p}</Text>
99
+ : <Text key={i}>{p}</Text>
100
+ )}
101
+ </Text>
102
+ </Box>
103
+ </Box>
104
+ )
105
+ }
106
+
107
+ function AssistantMsg({ msg }: { msg: Message }) {
108
+ return (
109
+ <Box flexDirection="column" marginBottom={1}>
110
+ <Text bold color="green">miii</Text>
111
+ <ContentBlock content={msg.content} />
112
+ </Box>
113
+ )
114
+ }
115
+
116
+ function ToolMsg({ msg }: { msg: Message }) {
117
+ const lines = msg.content.split('\n')
118
+ const name = (lines[0] ?? '').replace(/^\[/, '').replace(/\]$/, '')
119
+ const body = lines.slice(1).join('\n').trim()
120
+ return (
121
+ <Box flexDirection="column" marginBottom={1} paddingLeft={2}>
122
+ <Text color="green">✓ <Text color="cyan">{name}</Text></Text>
123
+ {body && (
124
+ <Box paddingLeft={2}>
125
+ <Text color="gray" dimColor wrap="wrap">
126
+ {body.length > 300 ? body.slice(0, 300) + '…' : body}
127
+ </Text>
128
+ </Box>
129
+ )}
130
+ </Box>
131
+ )
132
+ }
133
+
134
+ function SystemMsg({ msg }: { msg: Message }) {
135
+ return (
136
+ <Box marginBottom={1} paddingLeft={1}>
137
+ <Text color="gray" dimColor>─ {msg.content}</Text>
138
+ </Box>
139
+ )
140
+ }
141
+
142
+ function MsgItem({ msg }: { msg: Message }) {
143
+ switch (msg.role) {
144
+ case 'user': return <UserMsg msg={msg} />
145
+ case 'assistant': return <AssistantMsg msg={msg} />
146
+ case 'tool': return <ToolMsg msg={msg} />
147
+ case 'system': return <SystemMsg msg={msg} />
148
+ default: return null
149
+ }
150
+ }
151
+
152
+ // ─── scroll hint bar ─────────────────────────────────────────────────────────
153
+
154
+ function ScrollHint({ hiddenAbove, hiddenBelow }: { hiddenAbove: number; hiddenBelow: number }) {
155
+ if (hiddenAbove === 0 && hiddenBelow === 0) return null
156
+ const parts: string[] = []
157
+ if (hiddenAbove > 0) parts.push(`↑ ${hiddenAbove} above`)
158
+ if (hiddenBelow > 0) parts.push(`↓ ${hiddenBelow} below`)
159
+ return (
160
+ <Box justifyContent="center">
161
+ <Text color="gray" dimColor>{parts.join(' ')} · PgUp/PgDn</Text>
162
+ </Box>
163
+ )
164
+ }
165
+
166
+ // ─── main export ─────────────────────────────────────────────────────────────
167
+
168
+ export function MessageList({ messages, rows, cols, scrollOffset, streaming }: Props) {
169
+ const availRows = Math.max(rows - 2, 4)
170
+ const { visible, hiddenAbove, hiddenBelow } = useMemo(
171
+ () => computeSlice(messages, availRows, scrollOffset, cols),
172
+ [messages, availRows, scrollOffset, cols]
173
+ )
174
+
175
+ return (
176
+ <Box flexDirection="column" flexGrow={1} overflow="hidden" paddingX={1}>
177
+ <ScrollHint hiddenAbove={hiddenAbove} hiddenBelow={hiddenBelow} />
178
+
179
+ {visible.length === 0 && hiddenAbove === 0 && (
180
+ <Box paddingTop={1}>
181
+ <Text color="gray" dimColor>start typing below — @ for files, / for commands</Text>
182
+ </Box>
183
+ )}
184
+
185
+ {visible.map(msg => <MsgItem key={msg.id} msg={msg} />)}
186
+
187
+ {streaming && scrollOffset === 0 && (
188
+ <Box paddingLeft={2}><Text color="gray" dimColor>▋</Text></Box>
189
+ )}
190
+ </Box>
191
+ )
192
+ }
@@ -0,0 +1,134 @@
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" 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
+ }
@@ -0,0 +1,36 @@
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 === 'streaming' ? <Text color="yellow">{spinner} <Text color="gray">streaming</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
+ }