miii-cli 0.2.0 → 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.
@@ -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
- }