miii-cli 0.2.3 → 0.2.5
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 +193 -152
- package/dist/__tests__/integration.test.js +50 -0
- package/dist/config.js +1 -1
- package/dist/init.js +67 -5
- package/dist/skills/loader.js +65 -1
- package/dist/tavily/client.js +64 -0
- package/dist/tools/index.js +37 -2
- package/dist/tui/InputBar.js +89 -299
- package/dist/tui/components/InputArea.js +3 -0
- package/dist/tui/git-context.js +59 -0
- package/dist/tui/hooks/useModelPicker.js +63 -0
- package/dist/tui/hooks/useRunLoop.js +137 -0
- package/dist/tui/hooks/useSession.js +50 -0
- package/dist/tui/printer.js +10 -3
- package/dist/tui/thinking.js +28 -0
- package/package.json +5 -3
- package/dist/tui/App.js +0 -285
- package/dist/tui/components/MessageList.js +0 -127
- package/dist/workers/context.worker.js +0 -71
- package/dist/workers/spawn.js +0 -17
package/dist/tui/InputBar.js
CHANGED
|
@@ -1,53 +1,29 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useCallback, useRef
|
|
2
|
+
import { useState, useCallback, useRef } from 'react';
|
|
3
3
|
import { Box, Text, useStdout } from 'ink';
|
|
4
4
|
import { InputArea } from './components/InputArea.js';
|
|
5
5
|
import { ModelPicker } from './components/ModelPicker.js';
|
|
6
6
|
import { Divider } from './components/StatusBar.js';
|
|
7
|
-
import {
|
|
8
|
-
import { listModels, pullModel } from '../llm/ollama.js';
|
|
9
|
-
import { StreamParser } from '../parser/stream-parser.js';
|
|
10
|
-
import { tools, getSystemPrompt } from '../tools/index.js';
|
|
7
|
+
import { tools } from '../tools/index.js';
|
|
11
8
|
import { readFile } from '../files/ops.js';
|
|
12
|
-
import {
|
|
9
|
+
import { generateId } from '../types.js';
|
|
13
10
|
import * as printer from './printer.js';
|
|
14
11
|
import { loadSession, saveSession, listSessions } from '../sessions.js';
|
|
15
|
-
import { shouldCompact, compactContext, fileEditContext } from '../tasks/compactor.js';
|
|
16
12
|
import { MacroQueue, MicroQueue } from '../tasks/queue.js';
|
|
17
13
|
import { TaskExecutor } from '../tasks/executor.js';
|
|
18
|
-
import {
|
|
14
|
+
import { fileEditContext } from '../tasks/compactor.js';
|
|
15
|
+
import { StreamParser } from '../parser/stream-parser.js';
|
|
16
|
+
import { chat } from '../llm/stream.js';
|
|
19
17
|
import { exec } from 'child_process';
|
|
20
18
|
import { promisify } from 'util';
|
|
19
|
+
import { getTavilyKey, saveTavilyKey } from '../tavily/client.js';
|
|
20
|
+
import { getSystemPrompt } from '../tools/index.js';
|
|
21
|
+
import { THINKING_PHRASES, SPARKLE } from './thinking.js';
|
|
22
|
+
import { buildGitContext, looksCodeRelated } from './git-context.js';
|
|
23
|
+
import { useSession } from './hooks/useSession.js';
|
|
24
|
+
import { useModelPicker } from './hooks/useModelPicker.js';
|
|
25
|
+
import { useRunLoop } from './hooks/useRunLoop.js';
|
|
21
26
|
const gitRun = promisify(exec);
|
|
22
|
-
const MAX_TOOL_DEPTH = 6;
|
|
23
|
-
const THINKING_PHRASES = [
|
|
24
|
-
'oh wow, a question. let me pretend to care…',
|
|
25
|
-
'consulting the void…',
|
|
26
|
-
'making something up, just a sec…',
|
|
27
|
-
'definitely not hallucinating right now…',
|
|
28
|
-
'running 47 mental tabs…',
|
|
29
|
-
'staring into the abyss (it blinked)…',
|
|
30
|
-
'calculating your fate, no pressure…',
|
|
31
|
-
'doing the thinking you pay me for…',
|
|
32
|
-
'processing your questionable life choices…',
|
|
33
|
-
'summoning coherent thoughts, rarely works…',
|
|
34
|
-
'asking my imaginary friend for help…',
|
|
35
|
-
'pretending this is a hard problem…',
|
|
36
|
-
'yes, yes, very interesting. anyway…',
|
|
37
|
-
'googling it (not really, I can\'t)…',
|
|
38
|
-
'simulating intelligence… please wait…',
|
|
39
|
-
'having a brief existential crisis…',
|
|
40
|
-
'cross-referencing vibes…',
|
|
41
|
-
'totally not making this up…',
|
|
42
|
-
'the answer is 42. now finding the question…',
|
|
43
|
-
'my other tab is loading…',
|
|
44
|
-
'channelling the spirit of stack overflow…',
|
|
45
|
-
'trying not to confidently be wrong…',
|
|
46
|
-
'applying artificial to the intelligence…',
|
|
47
|
-
'phoning a friend who also doesn\'t know…',
|
|
48
|
-
'checking if this is even my problem to solve…',
|
|
49
|
-
];
|
|
50
|
-
const SPARKLE = ['✦', '✧', '✶', '✷', '✸', '✹'];
|
|
51
27
|
function buildAtContext(text) {
|
|
52
28
|
const refs = [...text.matchAll(/@([\w./\-]+)/g)];
|
|
53
29
|
if (!refs.length)
|
|
@@ -63,238 +39,21 @@ function buildAtContext(text) {
|
|
|
63
39
|
}
|
|
64
40
|
return parts.length ? parts.join('\n\n') + '\n\n' : '';
|
|
65
41
|
}
|
|
66
|
-
|
|
67
|
-
function looksCodeRelated(text) {
|
|
68
|
-
return text.length >= 10 && CODE_PATTERN.test(text);
|
|
69
|
-
}
|
|
70
|
-
async function buildGitContext(cwd, lastStatusRef) {
|
|
71
|
-
try {
|
|
72
|
-
const { stdout } = await gitRun('git status --short', { cwd, timeout: 5000 });
|
|
73
|
-
const status = stdout.trim();
|
|
74
|
-
if (!status || status === lastStatusRef.current)
|
|
75
|
-
return { prefix: '', label: '' };
|
|
76
|
-
lastStatusRef.current = status;
|
|
77
|
-
const MAX_TOTAL = 40_000;
|
|
78
|
-
const MAX_FILE = 15_000;
|
|
79
|
-
let total = 0;
|
|
80
|
-
const parts = [];
|
|
81
|
-
const skipped = [];
|
|
82
|
-
for (const line of status.split('\n')) {
|
|
83
|
-
const code = line.slice(0, 2);
|
|
84
|
-
if (code.includes('D'))
|
|
85
|
-
continue;
|
|
86
|
-
const raw = line.slice(3).trim().replace(/^"|"$/g, '');
|
|
87
|
-
const rel = raw.includes(' -> ') ? raw.split(' -> ')[1] : raw;
|
|
88
|
-
if (!rel)
|
|
89
|
-
continue;
|
|
90
|
-
try {
|
|
91
|
-
const content = readFile(resolve(cwd, rel));
|
|
92
|
-
if (!content || content.length > MAX_FILE) {
|
|
93
|
-
skipped.push(rel);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
total += content.length;
|
|
97
|
-
if (total > MAX_TOTAL) {
|
|
98
|
-
skipped.push(rel);
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
parts.push(`<file path="${rel}">\n${content}\n</file>`);
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
skipped.push(rel);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (!parts.length && !skipped.length)
|
|
108
|
-
return { prefix: '', label: '' };
|
|
109
|
-
let prefix = '[Auto-context: git-changed files]\n' + parts.join('\n') + '\n';
|
|
110
|
-
if (skipped.length)
|
|
111
|
-
prefix += `Files changed but too large to auto-load: ${skipped.join(', ')}\n`;
|
|
112
|
-
prefix += '\n';
|
|
113
|
-
const label = `auto-loaded ${parts.length} changed file(s)${skipped.length ? `, skipped ${skipped.length} (too large)` : ''}`;
|
|
114
|
-
return { prefix, label };
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
return { prefix: '', label: '' };
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
export function InputBar({ config, skills, cwd, session }) {
|
|
42
|
+
export function InputBar({ config, skills, cwd, session, version }) {
|
|
121
43
|
const { stdout } = useStdout();
|
|
122
44
|
const cols = stdout.columns ?? 80;
|
|
123
|
-
const [status, setStatus] = useState('idle');
|
|
124
|
-
const [tick, setTick] = useState(0);
|
|
125
|
-
const [currentModel, setCurrentModel] = useState(config.model);
|
|
126
|
-
const [sessionName, setSessionName] = useState(session);
|
|
127
|
-
const [currentTool, setCurrentTool] = useState();
|
|
128
|
-
const [taskLabel, setTaskLabel] = useState();
|
|
129
45
|
const [planningMode, setPlanningMode] = useState(false);
|
|
130
|
-
// picker opens on mount — force model selection every launch
|
|
131
|
-
const [pickerOpen, setPickerOpen] = useState(true);
|
|
132
|
-
const [pickerModels, setPickerModels] = useState([]);
|
|
133
|
-
const [pickerLoading, setPickerLoading] = useState(false);
|
|
134
|
-
const [pickerError, setPickerError] = useState();
|
|
135
|
-
const [pullState, setPullState] = useState();
|
|
136
|
-
const abortRef = useRef(null);
|
|
137
|
-
const pullAbortRef = useRef(null);
|
|
138
|
-
const thinkingStartRef = useRef(0);
|
|
139
46
|
const macroQueueRef = useRef(new MacroQueue());
|
|
140
47
|
const executorRef = useRef(new TaskExecutor(tools));
|
|
141
|
-
const systemPromptRef = useRef(getSystemPrompt(`\n- CWD: ${cwd}`));
|
|
142
|
-
const currentModelRef = useRef(currentModel);
|
|
143
|
-
const sessionNameRef = useRef(sessionName);
|
|
144
|
-
const historyRef = useRef([]);
|
|
145
|
-
const saveTimerRef = useRef(null);
|
|
146
48
|
const lastGitStatusRef = useRef('');
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
saveTimerRef.current = setTimeout(() => {
|
|
151
|
-
saveSession(sessionNameRef.current, historyRef.current);
|
|
152
|
-
saveTimerRef.current = null;
|
|
153
|
-
}, 2000);
|
|
154
|
-
}
|
|
155
|
-
function pushHistory(msg) {
|
|
156
|
-
historyRef.current.push(msg);
|
|
157
|
-
if (historyRef.current.length > 100)
|
|
158
|
-
historyRef.current.splice(0, historyRef.current.length - 100);
|
|
159
|
-
scheduleSave();
|
|
160
|
-
}
|
|
161
|
-
useEffect(() => { currentModelRef.current = currentModel; }, [currentModel]);
|
|
162
|
-
useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
|
|
163
|
-
// mount: load session history + fetch models for initial picker
|
|
164
|
-
useEffect(() => {
|
|
165
|
-
const history = loadSession(session);
|
|
166
|
-
historyRef.current = history;
|
|
167
|
-
if (history.length) {
|
|
168
|
-
printer.systemMsg(`resumed "${session}" — ${history.length} messages`);
|
|
169
|
-
}
|
|
170
|
-
setPickerLoading(true);
|
|
171
|
-
listModels(config.baseUrl)
|
|
172
|
-
.then(m => { setPickerModels(m); setPickerLoading(false); })
|
|
173
|
-
.catch(e => { setPickerError(String(e)); setPickerLoading(false); });
|
|
174
|
-
}, []);
|
|
175
|
-
useEffect(() => {
|
|
176
|
-
if (status === 'idle')
|
|
177
|
-
return;
|
|
178
|
-
const t = setInterval(() => setTick(n => n + 1), 80);
|
|
179
|
-
return () => clearInterval(t);
|
|
180
|
-
}, [status]);
|
|
181
|
-
function buildContext(extra) {
|
|
182
|
-
const ctx = [{ role: 'system', content: systemPromptRef.current }];
|
|
183
|
-
ctx.push(...historyRef.current);
|
|
184
|
-
if (extra)
|
|
185
|
-
ctx.push(extra);
|
|
186
|
-
return ctx;
|
|
187
|
-
}
|
|
188
|
-
const runLoop = useCallback(async (contextMsgs, depth = 0, goal) => {
|
|
189
|
-
if (depth >= MAX_TOOL_DEPTH) {
|
|
190
|
-
setStatus('idle');
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
setStatus('thinking');
|
|
194
|
-
if (depth === 0)
|
|
195
|
-
thinkingStartRef.current = Date.now();
|
|
196
|
-
// Auto-compact context when local model starts losing the thread
|
|
197
|
-
const msgs = shouldCompact(contextMsgs) ? compactContext(contextMsgs, goal) : contextMsgs;
|
|
198
|
-
abortRef.current = new AbortController();
|
|
199
|
-
await chat({
|
|
200
|
-
provider: config.provider,
|
|
201
|
-
model: currentModelRef.current,
|
|
202
|
-
baseUrl: config.baseUrl,
|
|
203
|
-
messages: msgs,
|
|
204
|
-
signal: abortRef.current.signal,
|
|
205
|
-
async onDone(fullText) {
|
|
206
|
-
const pendingTools = [];
|
|
207
|
-
const parser = new StreamParser();
|
|
208
|
-
for (const item of [...parser.feed(fullText), ...parser.flush()]) {
|
|
209
|
-
if (item.type === 'tool_call')
|
|
210
|
-
pendingTools.push({ name: item.toolName, args: item.toolArgs });
|
|
211
|
-
}
|
|
212
|
-
printer.assistantMsg(fullText);
|
|
213
|
-
pushHistory({ role: 'assistant', content: fullText });
|
|
214
|
-
if (!pendingTools.length) {
|
|
215
|
-
setStatus('idle');
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
setStatus('tool');
|
|
219
|
-
const next = [...msgs, { role: 'assistant', content: fullText }];
|
|
220
|
-
try {
|
|
221
|
-
for (const tc of pendingTools) {
|
|
222
|
-
const tool = tools.find(t => t.name === tc.name);
|
|
223
|
-
setCurrentTool(tc.name);
|
|
224
|
-
if (tool) {
|
|
225
|
-
try {
|
|
226
|
-
const result = await tool.execute(tc.args);
|
|
227
|
-
printer.toolMsg(tc.name, result);
|
|
228
|
-
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
|
|
229
|
-
}
|
|
230
|
-
catch (e) {
|
|
231
|
-
const err = `Tool ${tc.name} error: ${e}`;
|
|
232
|
-
printer.errorMsg(err);
|
|
233
|
-
next.push({ role: 'user', content: err });
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
printer.errorMsg(`unknown tool: ${tc.name}`);
|
|
238
|
-
next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
finally {
|
|
243
|
-
setCurrentTool(undefined);
|
|
244
|
-
}
|
|
245
|
-
await runLoop(next, depth + 1, goal);
|
|
246
|
-
},
|
|
247
|
-
onError(err) {
|
|
248
|
-
if (err.name !== 'AbortError')
|
|
249
|
-
printer.errorMsg(err.message);
|
|
250
|
-
setStatus('idle');
|
|
251
|
-
},
|
|
252
|
-
});
|
|
253
|
-
}, [config]);
|
|
254
|
-
// ─── model picker ──────────────────────────────────────────────────────────
|
|
255
|
-
const openPicker = useCallback(async () => {
|
|
256
|
-
setPickerOpen(true);
|
|
257
|
-
setPickerLoading(true);
|
|
258
|
-
setPickerError(undefined);
|
|
259
|
-
try {
|
|
260
|
-
setPickerModels(await listModels(config.baseUrl));
|
|
261
|
-
}
|
|
262
|
-
catch (e) {
|
|
263
|
-
setPickerError(String(e));
|
|
264
|
-
}
|
|
265
|
-
finally {
|
|
266
|
-
setPickerLoading(false);
|
|
267
|
-
}
|
|
268
|
-
}, [config.baseUrl]);
|
|
269
|
-
const handleModelSelect = useCallback((name) => {
|
|
270
|
-
setCurrentModel(name);
|
|
271
|
-
currentModelRef.current = name;
|
|
272
|
-
setPickerOpen(false);
|
|
273
|
-
printer.systemMsg(`model → ${name}`);
|
|
274
|
-
}, []);
|
|
275
|
-
const handleModelPull = useCallback(async (name) => {
|
|
276
|
-
setPullState({ name, status: 'starting...', pct: undefined });
|
|
277
|
-
pullAbortRef.current = new AbortController();
|
|
278
|
-
try {
|
|
279
|
-
await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal);
|
|
280
|
-
setPickerModels(await listModels(config.baseUrl));
|
|
281
|
-
setPullState(undefined);
|
|
282
|
-
setCurrentModel(name);
|
|
283
|
-
currentModelRef.current = name;
|
|
284
|
-
setPickerOpen(false);
|
|
285
|
-
printer.systemMsg(`pulled ${name} → active`);
|
|
286
|
-
}
|
|
287
|
-
catch (e) {
|
|
288
|
-
setPullState(undefined);
|
|
289
|
-
setPickerError(`pull failed: ${e}`);
|
|
290
|
-
}
|
|
291
|
-
}, [config.baseUrl]);
|
|
49
|
+
const { setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, } = useSession(session, cwd, config);
|
|
50
|
+
const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, openPicker, handleModelSelect, handleModelPull, } = useModelPicker(config);
|
|
51
|
+
const { status, setStatus, tick, currentTool, setCurrentTool, taskLabel, setTaskLabel, thinkingStartRef, abortRef, runLoop, handleAbort, } = useRunLoop(config, currentModelRef, pushHistory);
|
|
292
52
|
// ─── refactor ─────────────────────────────────────────────────────────────
|
|
293
53
|
const runRefactor = useCallback(async (goal) => {
|
|
294
54
|
printer.systemMsg(`refactor: ${goal}`);
|
|
295
55
|
setTaskLabel(`planning: ${goal}`);
|
|
296
56
|
setStatus('thinking');
|
|
297
|
-
// Phase 1 — planning: ask model to list files and describe changes
|
|
298
57
|
const planCtx = [
|
|
299
58
|
{ role: 'system', content: systemPromptRef.current },
|
|
300
59
|
{
|
|
@@ -319,7 +78,6 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
319
78
|
return;
|
|
320
79
|
}
|
|
321
80
|
printer.assistantMsg(planText);
|
|
322
|
-
// Parse FILE:/CHANGE: pairs from plan
|
|
323
81
|
const filePlan = [];
|
|
324
82
|
const lines = planText.split('\n');
|
|
325
83
|
let lastPath = '';
|
|
@@ -340,9 +98,7 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
340
98
|
return;
|
|
341
99
|
}
|
|
342
100
|
printer.systemMsg(`plan: ${filePlan.length} file(s) to change`);
|
|
343
|
-
// Phase 2 — execute via macro/micro queue
|
|
344
101
|
const micro = new MicroQueue();
|
|
345
|
-
// P1: read all files in parallel
|
|
346
102
|
for (const fp of filePlan) {
|
|
347
103
|
const t = { id: `read:${fp.path}`, priority: 1, tool: 'read_file', args: { path: fp.path }, deps: [], status: 'pending' };
|
|
348
104
|
micro.push(t);
|
|
@@ -356,13 +112,12 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
356
112
|
};
|
|
357
113
|
macroQueueRef.current.enqueue(macro);
|
|
358
114
|
setTaskLabel(`reading ${filePlan.length} file(s)…`);
|
|
359
|
-
const readResults = await executorRef.current.drain(micro, ({ task,
|
|
115
|
+
const readResults = await executorRef.current.drain(micro, ({ task, error }) => {
|
|
360
116
|
if (error)
|
|
361
117
|
printer.errorMsg(`read failed: ${task.args.path} — ${error}`);
|
|
362
118
|
else
|
|
363
119
|
printer.systemMsg(`read: ${task.args.path}`);
|
|
364
120
|
});
|
|
365
|
-
// Phase 3 — per-file LLM call with isolated context → patch
|
|
366
121
|
setTaskLabel(`applying changes…`);
|
|
367
122
|
const writeMicro = new MicroQueue();
|
|
368
123
|
for (const fp of filePlan) {
|
|
@@ -374,7 +129,6 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
374
129
|
}
|
|
375
130
|
setCurrentTool(`edit ${fp.path}`);
|
|
376
131
|
setTaskLabel(`editing: ${fp.path}`);
|
|
377
|
-
// Isolated context per file keeps model focused
|
|
378
132
|
const editCtx = fileEditContext(systemPromptRef.current, goal, fp.path, fileContent, fp.change);
|
|
379
133
|
let editText = '';
|
|
380
134
|
await chat({
|
|
@@ -389,7 +143,6 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
389
143
|
if (!editText)
|
|
390
144
|
continue;
|
|
391
145
|
printer.assistantMsg(editText);
|
|
392
|
-
// Queue write tasks from LLM's tool calls (P2)
|
|
393
146
|
const parser = new StreamParser();
|
|
394
147
|
for (const item of [...parser.feed(editText), ...parser.flush()]) {
|
|
395
148
|
if (item.type === 'tool_call') {
|
|
@@ -397,7 +150,6 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
397
150
|
}
|
|
398
151
|
}
|
|
399
152
|
}
|
|
400
|
-
// Execute all writes
|
|
401
153
|
if (writeMicro.size > 0) {
|
|
402
154
|
setTaskLabel(`writing ${writeMicro.size} change(s)…`);
|
|
403
155
|
await executorRef.current.drain(writeMicro, ({ task, result, error }) => {
|
|
@@ -427,28 +179,21 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
427
179
|
return e.message ?? String(e);
|
|
428
180
|
}
|
|
429
181
|
};
|
|
430
|
-
// /git or /git status
|
|
431
182
|
if (!sub || sub === 'status') {
|
|
432
|
-
|
|
433
|
-
printer.systemMsg(out);
|
|
183
|
+
printer.systemMsg(await git('status'));
|
|
434
184
|
return;
|
|
435
185
|
}
|
|
436
|
-
// /git log [n]
|
|
437
186
|
if (sub === 'log' || sub.startsWith('log ')) {
|
|
438
187
|
const n = parseInt(sub.split(' ')[1] ?? '10', 10) || 10;
|
|
439
|
-
|
|
440
|
-
printer.systemMsg(out);
|
|
188
|
+
printer.systemMsg(await git(`log --oneline --decorate -${Math.min(n, 50)}`));
|
|
441
189
|
return;
|
|
442
190
|
}
|
|
443
|
-
// /git diff [--staged] [file]
|
|
444
191
|
if (sub === 'diff' || sub.startsWith('diff ')) {
|
|
445
192
|
const args = sub.slice(4).trim();
|
|
446
193
|
const out = await git(`diff ${args}`.trim());
|
|
447
|
-
|
|
448
|
-
printer.systemMsg(display || '(no diff)');
|
|
194
|
+
printer.systemMsg(out.length > 6000 ? out.slice(0, 6000) + '\n…[truncated]' : out || '(no diff)');
|
|
449
195
|
return;
|
|
450
196
|
}
|
|
451
|
-
// /git review — inject diff into context, ask model to review
|
|
452
197
|
if (sub === 'review') {
|
|
453
198
|
const diff = await git('diff HEAD');
|
|
454
199
|
const staged = await git('diff --staged');
|
|
@@ -464,40 +209,91 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
464
209
|
await runLoop(buildContext());
|
|
465
210
|
return;
|
|
466
211
|
}
|
|
467
|
-
// /git branch
|
|
468
212
|
if (sub === 'branch' || sub.startsWith('branch ')) {
|
|
469
|
-
|
|
470
|
-
const out = await git(`branch ${args}`.trim());
|
|
471
|
-
printer.systemMsg(out || '(done)');
|
|
213
|
+
printer.systemMsg(await git(`branch ${sub.slice(6).trim()}`.trim()) || '(done)');
|
|
472
214
|
return;
|
|
473
215
|
}
|
|
474
|
-
// /git commit <msg>
|
|
475
216
|
if (sub.startsWith('commit ')) {
|
|
476
217
|
const msg = sub.slice(7).trim();
|
|
477
218
|
if (!msg) {
|
|
478
219
|
printer.systemMsg('usage: /git commit <message>');
|
|
479
220
|
return;
|
|
480
221
|
}
|
|
481
|
-
const
|
|
482
|
-
if (!
|
|
222
|
+
const gitStatus = await git('status --short');
|
|
223
|
+
if (!gitStatus || gitStatus === '(clean — no changes)') {
|
|
483
224
|
printer.systemMsg('nothing to commit — working tree clean');
|
|
484
225
|
return;
|
|
485
226
|
}
|
|
486
|
-
printer.systemMsg(`staging and committing:\n${
|
|
227
|
+
printer.systemMsg(`staging and committing:\n${gitStatus}`);
|
|
487
228
|
const stageOut = await git('add -A');
|
|
488
229
|
if (stageOut)
|
|
489
230
|
printer.systemMsg(stageOut);
|
|
490
|
-
|
|
491
|
-
printer.systemMsg(commitOut);
|
|
231
|
+
printer.systemMsg(await git(`commit -m ${JSON.stringify(msg)}`));
|
|
492
232
|
return;
|
|
493
233
|
}
|
|
494
|
-
|
|
495
|
-
const out = await git(sub);
|
|
496
|
-
printer.systemMsg(out || '(done)');
|
|
234
|
+
printer.systemMsg(await git(sub) || '(done)');
|
|
497
235
|
}, []);
|
|
498
236
|
// ─── submit ────────────────────────────────────────────────────────────────
|
|
499
237
|
const handleSubmit = useCallback(async (text) => {
|
|
500
238
|
const cmd = text.trim();
|
|
239
|
+
if (cmd === '/version') {
|
|
240
|
+
printer.systemMsg(`miii v${version ?? 'unknown'}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (cmd === '/tavily-key' || cmd.startsWith('/tavily-key ')) {
|
|
244
|
+
const key = cmd.slice(11).trim();
|
|
245
|
+
if (!key) {
|
|
246
|
+
const existing = getTavilyKey();
|
|
247
|
+
printer.systemMsg(existing ? 'Tavily key set (use /tavily-key <key> to update)' : 'No Tavily key set. Usage: /tavily-key tvly-...');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!key.startsWith('tvly-')) {
|
|
251
|
+
printer.systemMsg('Key should start with tvly-. Get yours at https://tavily.com');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
saveTavilyKey(key);
|
|
255
|
+
printer.systemMsg('Tavily API key saved to ~/.config/miii/tavily.key (mode 600)');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (cmd === '/skills' || cmd.startsWith('/skills ')) {
|
|
259
|
+
const sub = cmd.slice(7).trim();
|
|
260
|
+
if (!sub || sub === 'list') {
|
|
261
|
+
const pkgs = skills.listNpmSkills();
|
|
262
|
+
printer.systemMsg(pkgs.length ? `installed npm skills:\n${pkgs.map(p => ` ${p}`).join('\n')}` : 'no npm skills installed — try /skills install <name>');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (sub.startsWith('install ')) {
|
|
266
|
+
const pkg = sub.slice(8).trim();
|
|
267
|
+
if (!pkg) {
|
|
268
|
+
printer.systemMsg('usage: /skills install <name> (e.g. /skills install git-summary)');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
printer.systemMsg(`installing miii-skill-${pkg}…`);
|
|
272
|
+
try {
|
|
273
|
+
printer.systemMsg(await skills.installSkill(pkg));
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
printer.errorMsg(String(e));
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (sub.startsWith('uninstall ')) {
|
|
281
|
+
const pkg = sub.slice(10).trim();
|
|
282
|
+
if (!pkg) {
|
|
283
|
+
printer.systemMsg('usage: /skills uninstall <name>');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
printer.systemMsg(await skills.uninstallSkill(pkg));
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
printer.errorMsg(String(e));
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
printer.systemMsg('usage: /skills install <name> | /skills uninstall <name> | /skills list');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
501
297
|
if (cmd === '/model' || cmd.startsWith('/model ')) {
|
|
502
298
|
const name = cmd.slice(6).trim();
|
|
503
299
|
if (!name) {
|
|
@@ -505,6 +301,7 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
505
301
|
return;
|
|
506
302
|
}
|
|
507
303
|
setCurrentModel(name);
|
|
304
|
+
currentModelRef.current = name;
|
|
508
305
|
printer.systemMsg(`model → ${name}`);
|
|
509
306
|
return;
|
|
510
307
|
}
|
|
@@ -538,8 +335,7 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
538
335
|
process.exit(0);
|
|
539
336
|
}
|
|
540
337
|
if (cmd === '/git' || cmd.startsWith('/git ')) {
|
|
541
|
-
|
|
542
|
-
await handleGit(sub);
|
|
338
|
+
await handleGit(cmd.slice(4).trim());
|
|
543
339
|
return;
|
|
544
340
|
}
|
|
545
341
|
if (cmd.startsWith('/refactor ') || cmd === '/refactor') {
|
|
@@ -555,9 +351,7 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
555
351
|
const topic = cmd.slice(5).trim();
|
|
556
352
|
setPlanningMode(true);
|
|
557
353
|
systemPromptRef.current = getSystemPrompt(`\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.`);
|
|
558
|
-
const msg = topic
|
|
559
|
-
? `I want to plan: ${topic}`
|
|
560
|
-
: 'I want to start planning. Help me think through my goals step by step.';
|
|
354
|
+
const msg = topic ? `I want to plan: ${topic}` : 'I want to start planning. Help me think through my goals step by step.';
|
|
561
355
|
printer.userMsg(msg);
|
|
562
356
|
pushHistory({ role: 'user', content: msg });
|
|
563
357
|
await runLoop(buildContext());
|
|
@@ -650,13 +444,9 @@ export function InputBar({ config, skills, cwd, session }) {
|
|
|
650
444
|
pushHistory({ role: 'user', content: gitPrefix + contextPrefix + text });
|
|
651
445
|
await runLoop(buildContext());
|
|
652
446
|
}, [skills, runLoop, openPicker]);
|
|
653
|
-
const handleAbort = useCallback(() => {
|
|
654
|
-
abortRef.current?.abort();
|
|
655
|
-
setStatus('idle');
|
|
656
|
-
}, []);
|
|
657
447
|
const skillList = skills.list();
|
|
658
448
|
// ─── render ────────────────────────────────────────────────────────────────
|
|
659
|
-
return (_jsxs(Box, { flexDirection: "column", children: [pickerOpen ? (_jsxs(_Fragment, { children: [_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false);
|
|
449
|
+
return (_jsxs(Box, { flexDirection: "column", children: [pickerOpen ? (_jsxs(_Fragment, { children: [_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false); } }), _jsx(Divider, { cols: cols })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "miii" }), _jsxs(Box, { paddingLeft: 2, flexDirection: "column", children: [_jsx(Box, { children: status === 'thinking'
|
|
660
450
|
? _jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", children: [SPARKLE[tick % SPARKLE.length], " "] }), _jsx(Text, { color: "gray", dimColor: true, italic: true, children: THINKING_PHRASES[Math.floor(tick / 62) % THINKING_PHRASES.length] })] })
|
|
661
451
|
: _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [Math.floor((Date.now() - thinkingStartRef.current) / 1000), "s"] }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })] })] }), _jsx(Divider, { cols: cols })] })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, onSubmit: handleSubmit, onAbort: handleAbort })] }));
|
|
662
452
|
}
|
|
@@ -12,6 +12,9 @@ const BUILTIN_COMMANDS = [
|
|
|
12
12
|
{ ns: 'builtin', name: 'session', description: 'switch session /session <name>' },
|
|
13
13
|
{ ns: 'builtin', name: 'exit', description: 'exit miii' },
|
|
14
14
|
{ ns: 'builtin', name: 'model', description: 'switch model mid-session /model <name>' },
|
|
15
|
+
{ ns: 'builtin', name: 'version', description: 'show current miii version' },
|
|
16
|
+
{ ns: 'builtin', name: 'tavily-key', description: 'set Tavily API key for web search /tavily-key tvly-...' },
|
|
17
|
+
{ ns: 'builtin', name: 'skills', description: 'install/uninstall/list npm skills /skills install <name>' },
|
|
15
18
|
{ ns: 'builtin', name: 'list', description: 'list all loaded skills' },
|
|
16
19
|
{ ns: 'builtin', name: 'plan', description: 'start planning mode /plan [topic]' },
|
|
17
20
|
{ ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor /refactor <goal>' },
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile } from '../files/ops.js';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
const gitRun = promisify(exec);
|
|
6
|
+
const CODE_PATTERN = /\.(ts|js|tsx|jsx|py|go|rs|java|rb|sh|css|html|json|yaml|yml)\b|function|class|import|export|const|let|var|def |async|await|error|bug|fix|refactor|implement|`[^`]+`/i;
|
|
7
|
+
export function looksCodeRelated(text) {
|
|
8
|
+
return text.length >= 10 && CODE_PATTERN.test(text);
|
|
9
|
+
}
|
|
10
|
+
export async function buildGitContext(cwd, lastStatusRef) {
|
|
11
|
+
try {
|
|
12
|
+
const { stdout } = await gitRun('git status --short', { cwd, timeout: 5000 });
|
|
13
|
+
const status = stdout.trim();
|
|
14
|
+
if (!status || status === lastStatusRef.current)
|
|
15
|
+
return { prefix: '', label: '' };
|
|
16
|
+
lastStatusRef.current = status;
|
|
17
|
+
const MAX_TOTAL = 40_000;
|
|
18
|
+
const MAX_FILE = 15_000;
|
|
19
|
+
let total = 0;
|
|
20
|
+
const parts = [];
|
|
21
|
+
const skipped = [];
|
|
22
|
+
for (const line of status.split('\n')) {
|
|
23
|
+
const code = line.slice(0, 2);
|
|
24
|
+
if (code.includes('D'))
|
|
25
|
+
continue;
|
|
26
|
+
const raw = line.slice(3).trim().replace(/^"|"$/g, '');
|
|
27
|
+
const rel = raw.includes(' -> ') ? raw.split(' -> ')[1] : raw;
|
|
28
|
+
if (!rel)
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
const content = readFile(resolve(cwd, rel));
|
|
32
|
+
if (!content || content.length > MAX_FILE) {
|
|
33
|
+
skipped.push(rel);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
total += content.length;
|
|
37
|
+
if (total > MAX_TOTAL) {
|
|
38
|
+
skipped.push(rel);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
parts.push(`<file path="${rel}">\n${content}\n</file>`);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
skipped.push(rel);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!parts.length && !skipped.length)
|
|
48
|
+
return { prefix: '', label: '' };
|
|
49
|
+
let prefix = '[Auto-context: git-changed files]\n' + parts.join('\n') + '\n';
|
|
50
|
+
if (skipped.length)
|
|
51
|
+
prefix += `Files changed but too large to auto-load: ${skipped.join(', ')}\n`;
|
|
52
|
+
prefix += '\n';
|
|
53
|
+
const label = `auto-loaded ${parts.length} changed file(s)${skipped.length ? `, skipped ${skipped.length} (too large)` : ''}`;
|
|
54
|
+
return { prefix, label };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { prefix: '', label: '' };
|
|
58
|
+
}
|
|
59
|
+
}
|