miii-cli 0.2.4 → 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.
@@ -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, useEffect } from 'react';
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 { chat } from '../llm/stream.js';
8
- import { listModels, pullModel } from '../llm/ollama.js';
9
- import { StreamParser, extractBareToolCall } 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 { resolve } from 'path';
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 { generateId } from '../types.js';
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,255 +39,21 @@ function buildAtContext(text) {
63
39
  }
64
40
  return parts.length ? parts.join('\n\n') + '\n\n' : '';
65
41
  }
66
- 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;
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
- function scheduleSave() {
148
- if (saveTimerRef.current)
149
- clearTimeout(saveTimerRef.current);
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
- // Fallback: bare JSON tool call without <tool_call> wrapper
213
- if (!pendingTools.length) {
214
- const bare = extractBareToolCall(fullText);
215
- if (bare)
216
- pendingTools.push({ name: bare.name, args: bare.args });
217
- }
218
- printer.assistantMsg(fullText);
219
- pushHistory({ role: 'assistant', content: fullText });
220
- if (!pendingTools.length) {
221
- // Model printed code as text instead of using tools — nudge it
222
- const hasFencedCode = /```[\w]*\n[\s\S]{50,}?\n```/.test(fullText);
223
- if (hasFencedCode && depth < MAX_TOOL_DEPTH - 1) {
224
- const nudge = {
225
- role: 'user',
226
- content: 'You showed code in your response but did not use any file tools. Use edit_file or patch_file to actually write the changes to disk.',
227
- };
228
- const next = [...msgs, { role: 'assistant', content: fullText }, nudge];
229
- await runLoop(next, depth + 1, goal);
230
- return;
231
- }
232
- setStatus('idle');
233
- return;
234
- }
235
- setStatus('tool');
236
- const next = [...msgs, { role: 'assistant', content: fullText }];
237
- try {
238
- for (const tc of pendingTools) {
239
- const tool = tools.find(t => t.name === tc.name);
240
- setCurrentTool(tc.name);
241
- if (tool) {
242
- try {
243
- const result = await tool.execute(tc.args);
244
- printer.toolMsg(tc.name, result);
245
- next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
246
- }
247
- catch (e) {
248
- const err = `Tool ${tc.name} error: ${e}`;
249
- printer.errorMsg(err);
250
- next.push({ role: 'user', content: err });
251
- }
252
- }
253
- else {
254
- printer.errorMsg(`unknown tool: ${tc.name}`);
255
- next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
256
- }
257
- }
258
- }
259
- finally {
260
- setCurrentTool(undefined);
261
- }
262
- await runLoop(next, depth + 1, goal);
263
- },
264
- onError(err) {
265
- if (err.name !== 'AbortError')
266
- printer.errorMsg(err.message);
267
- setStatus('idle');
268
- },
269
- });
270
- }, [config]);
271
- // ─── model picker ──────────────────────────────────────────────────────────
272
- const openPicker = useCallback(async () => {
273
- setPickerOpen(true);
274
- setPickerLoading(true);
275
- setPickerError(undefined);
276
- try {
277
- setPickerModels(await listModels(config.baseUrl));
278
- }
279
- catch (e) {
280
- setPickerError(String(e));
281
- }
282
- finally {
283
- setPickerLoading(false);
284
- }
285
- }, [config.baseUrl]);
286
- const handleModelSelect = useCallback((name) => {
287
- setCurrentModel(name);
288
- currentModelRef.current = name;
289
- setPickerOpen(false);
290
- printer.systemMsg(`model → ${name}`);
291
- }, []);
292
- const handleModelPull = useCallback(async (name) => {
293
- setPullState({ name, status: 'starting...', pct: undefined });
294
- pullAbortRef.current = new AbortController();
295
- try {
296
- await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal);
297
- setPickerModels(await listModels(config.baseUrl));
298
- setPullState(undefined);
299
- setCurrentModel(name);
300
- currentModelRef.current = name;
301
- setPickerOpen(false);
302
- printer.systemMsg(`pulled ${name} → active`);
303
- }
304
- catch (e) {
305
- setPullState(undefined);
306
- setPickerError(`pull failed: ${e}`);
307
- }
308
- }, [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);
309
52
  // ─── refactor ─────────────────────────────────────────────────────────────
310
53
  const runRefactor = useCallback(async (goal) => {
311
54
  printer.systemMsg(`refactor: ${goal}`);
312
55
  setTaskLabel(`planning: ${goal}`);
313
56
  setStatus('thinking');
314
- // Phase 1 — planning: ask model to list files and describe changes
315
57
  const planCtx = [
316
58
  { role: 'system', content: systemPromptRef.current },
317
59
  {
@@ -336,7 +78,6 @@ export function InputBar({ config, skills, cwd, session }) {
336
78
  return;
337
79
  }
338
80
  printer.assistantMsg(planText);
339
- // Parse FILE:/CHANGE: pairs from plan
340
81
  const filePlan = [];
341
82
  const lines = planText.split('\n');
342
83
  let lastPath = '';
@@ -357,9 +98,7 @@ export function InputBar({ config, skills, cwd, session }) {
357
98
  return;
358
99
  }
359
100
  printer.systemMsg(`plan: ${filePlan.length} file(s) to change`);
360
- // Phase 2 — execute via macro/micro queue
361
101
  const micro = new MicroQueue();
362
- // P1: read all files in parallel
363
102
  for (const fp of filePlan) {
364
103
  const t = { id: `read:${fp.path}`, priority: 1, tool: 'read_file', args: { path: fp.path }, deps: [], status: 'pending' };
365
104
  micro.push(t);
@@ -373,13 +112,12 @@ export function InputBar({ config, skills, cwd, session }) {
373
112
  };
374
113
  macroQueueRef.current.enqueue(macro);
375
114
  setTaskLabel(`reading ${filePlan.length} file(s)…`);
376
- const readResults = await executorRef.current.drain(micro, ({ task, result, error }) => {
115
+ const readResults = await executorRef.current.drain(micro, ({ task, error }) => {
377
116
  if (error)
378
117
  printer.errorMsg(`read failed: ${task.args.path} — ${error}`);
379
118
  else
380
119
  printer.systemMsg(`read: ${task.args.path}`);
381
120
  });
382
- // Phase 3 — per-file LLM call with isolated context → patch
383
121
  setTaskLabel(`applying changes…`);
384
122
  const writeMicro = new MicroQueue();
385
123
  for (const fp of filePlan) {
@@ -391,7 +129,6 @@ export function InputBar({ config, skills, cwd, session }) {
391
129
  }
392
130
  setCurrentTool(`edit ${fp.path}`);
393
131
  setTaskLabel(`editing: ${fp.path}`);
394
- // Isolated context per file keeps model focused
395
132
  const editCtx = fileEditContext(systemPromptRef.current, goal, fp.path, fileContent, fp.change);
396
133
  let editText = '';
397
134
  await chat({
@@ -406,7 +143,6 @@ export function InputBar({ config, skills, cwd, session }) {
406
143
  if (!editText)
407
144
  continue;
408
145
  printer.assistantMsg(editText);
409
- // Queue write tasks from LLM's tool calls (P2)
410
146
  const parser = new StreamParser();
411
147
  for (const item of [...parser.feed(editText), ...parser.flush()]) {
412
148
  if (item.type === 'tool_call') {
@@ -414,7 +150,6 @@ export function InputBar({ config, skills, cwd, session }) {
414
150
  }
415
151
  }
416
152
  }
417
- // Execute all writes
418
153
  if (writeMicro.size > 0) {
419
154
  setTaskLabel(`writing ${writeMicro.size} change(s)…`);
420
155
  await executorRef.current.drain(writeMicro, ({ task, result, error }) => {
@@ -444,28 +179,21 @@ export function InputBar({ config, skills, cwd, session }) {
444
179
  return e.message ?? String(e);
445
180
  }
446
181
  };
447
- // /git or /git status
448
182
  if (!sub || sub === 'status') {
449
- const out = await git('status');
450
- printer.systemMsg(out);
183
+ printer.systemMsg(await git('status'));
451
184
  return;
452
185
  }
453
- // /git log [n]
454
186
  if (sub === 'log' || sub.startsWith('log ')) {
455
187
  const n = parseInt(sub.split(' ')[1] ?? '10', 10) || 10;
456
- const out = await git(`log --oneline --decorate -${Math.min(n, 50)}`);
457
- printer.systemMsg(out);
188
+ printer.systemMsg(await git(`log --oneline --decorate -${Math.min(n, 50)}`));
458
189
  return;
459
190
  }
460
- // /git diff [--staged] [file]
461
191
  if (sub === 'diff' || sub.startsWith('diff ')) {
462
192
  const args = sub.slice(4).trim();
463
193
  const out = await git(`diff ${args}`.trim());
464
- const display = out.length > 6000 ? out.slice(0, 6000) + '\n…[truncated]' : out;
465
- printer.systemMsg(display || '(no diff)');
194
+ printer.systemMsg(out.length > 6000 ? out.slice(0, 6000) + '\n…[truncated]' : out || '(no diff)');
466
195
  return;
467
196
  }
468
- // /git review — inject diff into context, ask model to review
469
197
  if (sub === 'review') {
470
198
  const diff = await git('diff HEAD');
471
199
  const staged = await git('diff --staged');
@@ -481,40 +209,91 @@ export function InputBar({ config, skills, cwd, session }) {
481
209
  await runLoop(buildContext());
482
210
  return;
483
211
  }
484
- // /git branch
485
212
  if (sub === 'branch' || sub.startsWith('branch ')) {
486
- const args = sub.slice(6).trim();
487
- const out = await git(`branch ${args}`.trim());
488
- printer.systemMsg(out || '(done)');
213
+ printer.systemMsg(await git(`branch ${sub.slice(6).trim()}`.trim()) || '(done)');
489
214
  return;
490
215
  }
491
- // /git commit <msg>
492
216
  if (sub.startsWith('commit ')) {
493
217
  const msg = sub.slice(7).trim();
494
218
  if (!msg) {
495
219
  printer.systemMsg('usage: /git commit <message>');
496
220
  return;
497
221
  }
498
- const status = await git('status --short');
499
- if (!status || status === '(clean — no changes)') {
222
+ const gitStatus = await git('status --short');
223
+ if (!gitStatus || gitStatus === '(clean — no changes)') {
500
224
  printer.systemMsg('nothing to commit — working tree clean');
501
225
  return;
502
226
  }
503
- printer.systemMsg(`staging and committing:\n${status}`);
227
+ printer.systemMsg(`staging and committing:\n${gitStatus}`);
504
228
  const stageOut = await git('add -A');
505
229
  if (stageOut)
506
230
  printer.systemMsg(stageOut);
507
- const commitOut = await git(`commit -m ${JSON.stringify(msg)}`);
508
- printer.systemMsg(commitOut);
231
+ printer.systemMsg(await git(`commit -m ${JSON.stringify(msg)}`));
509
232
  return;
510
233
  }
511
- // fallthrough run arbitrary git subcommand
512
- const out = await git(sub);
513
- printer.systemMsg(out || '(done)');
234
+ printer.systemMsg(await git(sub) || '(done)');
514
235
  }, []);
515
236
  // ─── submit ────────────────────────────────────────────────────────────────
516
237
  const handleSubmit = useCallback(async (text) => {
517
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
+ }
518
297
  if (cmd === '/model' || cmd.startsWith('/model ')) {
519
298
  const name = cmd.slice(6).trim();
520
299
  if (!name) {
@@ -522,6 +301,7 @@ export function InputBar({ config, skills, cwd, session }) {
522
301
  return;
523
302
  }
524
303
  setCurrentModel(name);
304
+ currentModelRef.current = name;
525
305
  printer.systemMsg(`model → ${name}`);
526
306
  return;
527
307
  }
@@ -555,8 +335,7 @@ export function InputBar({ config, skills, cwd, session }) {
555
335
  process.exit(0);
556
336
  }
557
337
  if (cmd === '/git' || cmd.startsWith('/git ')) {
558
- const sub = cmd.slice(4).trim();
559
- await handleGit(sub);
338
+ await handleGit(cmd.slice(4).trim());
560
339
  return;
561
340
  }
562
341
  if (cmd.startsWith('/refactor ') || cmd === '/refactor') {
@@ -572,9 +351,7 @@ export function InputBar({ config, skills, cwd, session }) {
572
351
  const topic = cmd.slice(5).trim();
573
352
  setPlanningMode(true);
574
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.`);
575
- const msg = topic
576
- ? `I want to plan: ${topic}`
577
- : '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.';
578
355
  printer.userMsg(msg);
579
356
  pushHistory({ role: 'user', content: msg });
580
357
  await runLoop(buildContext());
@@ -667,13 +444,9 @@ export function InputBar({ config, skills, cwd, session }) {
667
444
  pushHistory({ role: 'user', content: gitPrefix + contextPrefix + text });
668
445
  await runLoop(buildContext());
669
446
  }, [skills, runLoop, openPicker]);
670
- const handleAbort = useCallback(() => {
671
- abortRef.current?.abort();
672
- setStatus('idle');
673
- }, []);
674
447
  const skillList = skills.list();
675
448
  // ─── render ────────────────────────────────────────────────────────────────
676
- 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); setPullState(undefined); } }), _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'
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'
677
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] })] })
678
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 })] }));
679
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
+ }