miii-cli 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +190 -83
  2. package/dist/config.js +0 -1
  3. package/dist/files/ops.js +22 -4
  4. package/dist/index.js +0 -1
  5. package/dist/init.js +0 -1
  6. package/dist/llm/ollama.js +0 -1
  7. package/dist/llm/stream.js +4 -3
  8. package/dist/parser/stream-parser.js +1 -13
  9. package/dist/sessions.js +0 -1
  10. package/dist/skills/loader.js +0 -1
  11. package/dist/tasks/compactor.js +68 -0
  12. package/dist/tasks/executor.js +88 -0
  13. package/dist/tasks/queue.js +72 -0
  14. package/dist/tools/index.js +108 -5
  15. package/dist/tui/App.js +0 -1
  16. package/dist/tui/InputBar.js +379 -32
  17. package/dist/tui/components/AtPicker.js +0 -1
  18. package/dist/tui/components/CommandPalette.js +4 -3
  19. package/dist/tui/components/InputArea.js +25 -13
  20. package/dist/tui/components/MessageList.js +12 -1
  21. package/dist/tui/components/ModelPicker.js +0 -1
  22. package/dist/tui/components/StatusBar.js +0 -1
  23. package/dist/tui/printer.js +0 -1
  24. package/dist/types.js +0 -1
  25. package/dist/workers/context.worker.js +0 -1
  26. package/dist/workers/spawn.js +0 -1
  27. package/package.json +1 -3
  28. package/dist/config.d.ts +0 -2
  29. package/dist/config.js.map +0 -1
  30. package/dist/files/ops.d.ts +0 -14
  31. package/dist/files/ops.js.map +0 -1
  32. package/dist/index.d.ts +0 -2
  33. package/dist/index.js.map +0 -1
  34. package/dist/init.d.ts +0 -1
  35. package/dist/init.js.map +0 -1
  36. package/dist/llm/ollama.d.ts +0 -10
  37. package/dist/llm/ollama.js.map +0 -1
  38. package/dist/llm/stream.d.ts +0 -12
  39. package/dist/llm/stream.js.map +0 -1
  40. package/dist/parser/stream-parser.d.ts +0 -21
  41. package/dist/parser/stream-parser.js.map +0 -1
  42. package/dist/sessions.d.ts +0 -9
  43. package/dist/sessions.js.map +0 -1
  44. package/dist/skills/loader.d.ts +0 -23
  45. package/dist/skills/loader.js.map +0 -1
  46. package/dist/tools/index.d.ts +0 -8
  47. package/dist/tools/index.js.map +0 -1
  48. package/dist/tui/App.d.ts +0 -9
  49. package/dist/tui/App.js.map +0 -1
  50. package/dist/tui/InputBar.d.ts +0 -10
  51. package/dist/tui/InputBar.js.map +0 -1
  52. package/dist/tui/components/AtPicker.d.ts +0 -8
  53. package/dist/tui/components/AtPicker.js.map +0 -1
  54. package/dist/tui/components/CommandPalette.d.ts +0 -8
  55. package/dist/tui/components/CommandPalette.js.map +0 -1
  56. package/dist/tui/components/InputArea.d.ts +0 -12
  57. package/dist/tui/components/InputArea.js.map +0 -1
  58. package/dist/tui/components/MessageList.d.ts +0 -11
  59. package/dist/tui/components/MessageList.js.map +0 -1
  60. package/dist/tui/components/ModelPicker.d.ts +0 -18
  61. package/dist/tui/components/ModelPicker.js.map +0 -1
  62. package/dist/tui/components/StatusBar.d.ts +0 -12
  63. package/dist/tui/components/StatusBar.js.map +0 -1
  64. package/dist/tui/printer.d.ts +0 -7
  65. package/dist/tui/printer.js.map +0 -1
  66. package/dist/types.d.ts +0 -20
  67. package/dist/types.js.map +0 -1
  68. package/dist/workers/context.worker.js.map +0 -1
  69. package/dist/workers/diff.worker.d.ts +0 -1
  70. package/dist/workers/diff.worker.js +0 -12
  71. package/dist/workers/diff.worker.js.map +0 -1
  72. package/dist/workers/spawn.d.ts +0 -1
  73. package/dist/workers/spawn.js.map +0 -1
  74. /package/dist/{workers/context.worker.d.ts → tasks/types.js} +0 -0
@@ -9,8 +9,16 @@ import { listModels, pullModel } from '../llm/ollama.js';
9
9
  import { StreamParser } from '../parser/stream-parser.js';
10
10
  import { tools, getSystemPrompt } from '../tools/index.js';
11
11
  import { readFile } from '../files/ops.js';
12
+ import { resolve } from 'path';
12
13
  import * as printer from './printer.js';
13
14
  import { loadSession, saveSession, listSessions } from '../sessions.js';
15
+ import { shouldCompact, compactContext, fileEditContext } from '../tasks/compactor.js';
16
+ import { MacroQueue, MicroQueue } from '../tasks/queue.js';
17
+ import { TaskExecutor } from '../tasks/executor.js';
18
+ import { generateId } from '../types.js';
19
+ import { exec } from 'child_process';
20
+ import { promisify } from 'util';
21
+ const gitRun = promisify(exec);
14
22
  const MAX_TOOL_DEPTH = 6;
15
23
  const THINKING_PHRASES = [
16
24
  'oh wow, a question. let me pretend to care…',
@@ -23,6 +31,21 @@ const THINKING_PHRASES = [
23
31
  'doing the thinking you pay me for…',
24
32
  'processing your questionable life choices…',
25
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…',
26
49
  ];
27
50
  const SPARKLE = ['✦', '✧', '✶', '✷', '✸', '✹'];
28
51
  function buildAtContext(text) {
@@ -40,6 +63,60 @@ function buildAtContext(text) {
40
63
  }
41
64
  return parts.length ? parts.join('\n\n') + '\n\n' : '';
42
65
  }
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
+ }
43
120
  export function InputBar({ config, skills, cwd, session }) {
44
121
  const { stdout } = useStdout();
45
122
  const cols = stdout.columns ?? 80;
@@ -47,6 +124,8 @@ export function InputBar({ config, skills, cwd, session }) {
47
124
  const [tick, setTick] = useState(0);
48
125
  const [currentModel, setCurrentModel] = useState(config.model);
49
126
  const [sessionName, setSessionName] = useState(session);
127
+ const [currentTool, setCurrentTool] = useState();
128
+ const [taskLabel, setTaskLabel] = useState();
50
129
  const [planningMode, setPlanningMode] = useState(false);
51
130
  // picker opens on mount — force model selection every launch
52
131
  const [pickerOpen, setPickerOpen] = useState(true);
@@ -56,10 +135,29 @@ export function InputBar({ config, skills, cwd, session }) {
56
135
  const [pullState, setPullState] = useState();
57
136
  const abortRef = useRef(null);
58
137
  const pullAbortRef = useRef(null);
138
+ const thinkingStartRef = useRef(0);
139
+ const macroQueueRef = useRef(new MacroQueue());
140
+ const executorRef = useRef(new TaskExecutor(tools));
59
141
  const systemPromptRef = useRef(getSystemPrompt(`\n- CWD: ${cwd}`));
60
142
  const currentModelRef = useRef(currentModel);
61
143
  const sessionNameRef = useRef(sessionName);
62
144
  const historyRef = useRef([]);
145
+ const saveTimerRef = useRef(null);
146
+ 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
+ }
63
161
  useEffect(() => { currentModelRef.current = currentModel; }, [currentModel]);
64
162
  useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
65
163
  // mount: load session history + fetch models for initial picker
@@ -87,18 +185,22 @@ export function InputBar({ config, skills, cwd, session }) {
87
185
  ctx.push(extra);
88
186
  return ctx;
89
187
  }
90
- const runLoop = useCallback(async (contextMsgs, depth = 0) => {
188
+ const runLoop = useCallback(async (contextMsgs, depth = 0, goal) => {
91
189
  if (depth >= MAX_TOOL_DEPTH) {
92
190
  setStatus('idle');
93
191
  return;
94
192
  }
95
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;
96
198
  abortRef.current = new AbortController();
97
199
  await chat({
98
200
  provider: config.provider,
99
201
  model: currentModelRef.current,
100
202
  baseUrl: config.baseUrl,
101
- messages: contextMsgs,
203
+ messages: msgs,
102
204
  signal: abortRef.current.signal,
103
205
  async onDone(fullText) {
104
206
  const pendingTools = [];
@@ -108,34 +210,39 @@ export function InputBar({ config, skills, cwd, session }) {
108
210
  pendingTools.push({ name: item.toolName, args: item.toolArgs });
109
211
  }
110
212
  printer.assistantMsg(fullText);
111
- historyRef.current.push({ role: 'assistant', content: fullText });
112
- saveSession(sessionNameRef.current, historyRef.current);
213
+ pushHistory({ role: 'assistant', content: fullText });
113
214
  if (!pendingTools.length) {
114
215
  setStatus('idle');
115
216
  return;
116
217
  }
117
218
  setStatus('tool');
118
- const next = [...contextMsgs, { role: 'assistant', content: fullText }];
119
- for (const tc of pendingTools) {
120
- const tool = tools.find(t => t.name === tc.name);
121
- if (tool) {
122
- try {
123
- const result = await tool.execute(tc.args);
124
- printer.toolMsg(tc.name, result);
125
- next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
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
+ }
126
235
  }
127
- catch (e) {
128
- const err = `Tool ${tc.name} error: ${e}`;
129
- printer.errorMsg(err);
130
- next.push({ role: 'user', content: err });
236
+ else {
237
+ printer.errorMsg(`unknown tool: ${tc.name}`);
238
+ next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
131
239
  }
132
240
  }
133
- else {
134
- printer.errorMsg(`unknown tool: ${tc.name}`);
135
- next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
136
- }
137
241
  }
138
- await runLoop(next, depth + 1);
242
+ finally {
243
+ setCurrentTool(undefined);
244
+ }
245
+ await runLoop(next, depth + 1, goal);
139
246
  },
140
247
  onError(err) {
141
248
  if (err.name !== 'AbortError')
@@ -182,14 +289,234 @@ export function InputBar({ config, skills, cwd, session }) {
182
289
  setPickerError(`pull failed: ${e}`);
183
290
  }
184
291
  }, [config.baseUrl]);
292
+ // ─── refactor ─────────────────────────────────────────────────────────────
293
+ const runRefactor = useCallback(async (goal) => {
294
+ printer.systemMsg(`refactor: ${goal}`);
295
+ setTaskLabel(`planning: ${goal}`);
296
+ setStatus('thinking');
297
+ // Phase 1 — planning: ask model to list files and describe changes
298
+ const planCtx = [
299
+ { role: 'system', content: systemPromptRef.current },
300
+ {
301
+ role: 'user',
302
+ content: `Refactor goal: ${goal}\n\nList every file that needs to change. For each file output:\nFILE: <path>\nCHANGE: <one sentence describing the edit>\n\nUse list_files and read_file to discover relevant files first. Only list files that genuinely need changes.`,
303
+ },
304
+ ];
305
+ abortRef.current = new AbortController();
306
+ let planText = '';
307
+ await chat({
308
+ provider: config.provider,
309
+ model: currentModelRef.current,
310
+ baseUrl: config.baseUrl,
311
+ messages: planCtx,
312
+ signal: abortRef.current.signal,
313
+ async onDone(text) { planText = text; },
314
+ onError(err) { printer.errorMsg(err.message); },
315
+ });
316
+ if (!planText) {
317
+ setStatus('idle');
318
+ setTaskLabel(undefined);
319
+ return;
320
+ }
321
+ printer.assistantMsg(planText);
322
+ // Parse FILE:/CHANGE: pairs from plan
323
+ const filePlan = [];
324
+ const lines = planText.split('\n');
325
+ let lastPath = '';
326
+ for (const line of lines) {
327
+ const fm = line.match(/^FILE:\s*(.+)/);
328
+ const cm = line.match(/^CHANGE:\s*(.+)/);
329
+ if (fm)
330
+ lastPath = fm[1].trim();
331
+ if (cm && lastPath) {
332
+ filePlan.push({ path: lastPath, change: cm[1].trim() });
333
+ lastPath = '';
334
+ }
335
+ }
336
+ if (!filePlan.length) {
337
+ printer.systemMsg('no files identified in plan — done');
338
+ setStatus('idle');
339
+ setTaskLabel(undefined);
340
+ return;
341
+ }
342
+ printer.systemMsg(`plan: ${filePlan.length} file(s) to change`);
343
+ // Phase 2 — execute via macro/micro queue
344
+ const micro = new MicroQueue();
345
+ // P1: read all files in parallel
346
+ for (const fp of filePlan) {
347
+ const t = { id: `read:${fp.path}`, priority: 1, tool: 'read_file', args: { path: fp.path }, deps: [], status: 'pending' };
348
+ micro.push(t);
349
+ }
350
+ const macro = {
351
+ id: generateId(),
352
+ goal,
353
+ priority: 0,
354
+ microtasks: micro.toArray(),
355
+ status: 'running',
356
+ };
357
+ macroQueueRef.current.enqueue(macro);
358
+ setTaskLabel(`reading ${filePlan.length} file(s)…`);
359
+ const readResults = await executorRef.current.drain(micro, ({ task, result, error }) => {
360
+ if (error)
361
+ printer.errorMsg(`read failed: ${task.args.path} — ${error}`);
362
+ else
363
+ printer.systemMsg(`read: ${task.args.path}`);
364
+ });
365
+ // Phase 3 — per-file LLM call with isolated context → patch
366
+ setTaskLabel(`applying changes…`);
367
+ const writeMicro = new MicroQueue();
368
+ for (const fp of filePlan) {
369
+ const readId = `read:${fp.path}`;
370
+ const fileContent = readResults.get(readId) ?? '';
371
+ if (!fileContent) {
372
+ printer.systemMsg(`skip (unreadable): ${fp.path}`);
373
+ continue;
374
+ }
375
+ setCurrentTool(`edit ${fp.path}`);
376
+ setTaskLabel(`editing: ${fp.path}`);
377
+ // Isolated context per file keeps model focused
378
+ const editCtx = fileEditContext(systemPromptRef.current, goal, fp.path, fileContent, fp.change);
379
+ let editText = '';
380
+ await chat({
381
+ provider: config.provider,
382
+ model: currentModelRef.current,
383
+ baseUrl: config.baseUrl,
384
+ messages: editCtx,
385
+ signal: abortRef.current?.signal,
386
+ async onDone(text) { editText = text; },
387
+ onError(err) { printer.errorMsg(`edit LLM error: ${err.message}`); },
388
+ });
389
+ if (!editText)
390
+ continue;
391
+ printer.assistantMsg(editText);
392
+ // Queue write tasks from LLM's tool calls (P2)
393
+ const parser = new StreamParser();
394
+ for (const item of [...parser.feed(editText), ...parser.flush()]) {
395
+ if (item.type === 'tool_call') {
396
+ writeMicro.push({ id: generateId(), priority: 2, tool: item.toolName, args: item.toolArgs, deps: [], status: 'pending' });
397
+ }
398
+ }
399
+ }
400
+ // Execute all writes
401
+ if (writeMicro.size > 0) {
402
+ setTaskLabel(`writing ${writeMicro.size} change(s)…`);
403
+ await executorRef.current.drain(writeMicro, ({ task, result, error }) => {
404
+ if (error)
405
+ printer.errorMsg(`${task.tool} failed: ${error}`);
406
+ else
407
+ printer.toolMsg(task.tool, result ?? '');
408
+ });
409
+ }
410
+ macro.status = 'done';
411
+ macroQueueRef.current.dequeue();
412
+ setCurrentTool(undefined);
413
+ setTaskLabel(undefined);
414
+ setStatus('idle');
415
+ printer.systemMsg(`refactor done — ${filePlan.length} file(s) processed`);
416
+ pushHistory({ role: 'user', content: `[refactor] ${goal}` });
417
+ pushHistory({ role: 'assistant', content: planText });
418
+ }, [config]);
419
+ // ─── git ───────────────────────────────────────────────────────────────────
420
+ const handleGit = useCallback(async (sub) => {
421
+ const git = async (args) => {
422
+ try {
423
+ const { stdout, stderr } = await gitRun(`git ${args}`, { timeout: 15_000 });
424
+ return (stdout + stderr).trim();
425
+ }
426
+ catch (e) {
427
+ return e.message ?? String(e);
428
+ }
429
+ };
430
+ // /git or /git status
431
+ if (!sub || sub === 'status') {
432
+ const out = await git('status');
433
+ printer.systemMsg(out);
434
+ return;
435
+ }
436
+ // /git log [n]
437
+ if (sub === 'log' || sub.startsWith('log ')) {
438
+ const n = parseInt(sub.split(' ')[1] ?? '10', 10) || 10;
439
+ const out = await git(`log --oneline --decorate -${Math.min(n, 50)}`);
440
+ printer.systemMsg(out);
441
+ return;
442
+ }
443
+ // /git diff [--staged] [file]
444
+ if (sub === 'diff' || sub.startsWith('diff ')) {
445
+ const args = sub.slice(4).trim();
446
+ const out = await git(`diff ${args}`.trim());
447
+ const display = out.length > 6000 ? out.slice(0, 6000) + '\n…[truncated]' : out;
448
+ printer.systemMsg(display || '(no diff)');
449
+ return;
450
+ }
451
+ // /git review — inject diff into context, ask model to review
452
+ if (sub === 'review') {
453
+ const diff = await git('diff HEAD');
454
+ const staged = await git('diff --staged');
455
+ const combined = [diff, staged].filter(Boolean).join('\n').trim();
456
+ if (!combined || combined === '(no diff)') {
457
+ printer.systemMsg('no changes to review');
458
+ return;
459
+ }
460
+ const truncated = combined.length > 8000 ? combined.slice(0, 8000) + '\n…[truncated]' : combined;
461
+ const userMsg = `Review these git changes for bugs, issues, and improvements:\n\n${truncated}`;
462
+ printer.userMsg('/git review');
463
+ pushHistory({ role: 'user', content: userMsg });
464
+ await runLoop(buildContext());
465
+ return;
466
+ }
467
+ // /git branch
468
+ if (sub === 'branch' || sub.startsWith('branch ')) {
469
+ const args = sub.slice(6).trim();
470
+ const out = await git(`branch ${args}`.trim());
471
+ printer.systemMsg(out || '(done)');
472
+ return;
473
+ }
474
+ // /git commit <msg>
475
+ if (sub.startsWith('commit ')) {
476
+ const msg = sub.slice(7).trim();
477
+ if (!msg) {
478
+ printer.systemMsg('usage: /git commit <message>');
479
+ return;
480
+ }
481
+ const status = await git('status --short');
482
+ if (!status || status === '(clean — no changes)') {
483
+ printer.systemMsg('nothing to commit — working tree clean');
484
+ return;
485
+ }
486
+ printer.systemMsg(`staging and committing:\n${status}`);
487
+ const stageOut = await git('add -A');
488
+ if (stageOut)
489
+ printer.systemMsg(stageOut);
490
+ const commitOut = await git(`commit -m ${JSON.stringify(msg)}`);
491
+ printer.systemMsg(commitOut);
492
+ return;
493
+ }
494
+ // fallthrough — run arbitrary git subcommand
495
+ const out = await git(sub);
496
+ printer.systemMsg(out || '(done)');
497
+ }, []);
185
498
  // ─── submit ────────────────────────────────────────────────────────────────
186
499
  const handleSubmit = useCallback(async (text) => {
187
500
  const cmd = text.trim();
501
+ if (cmd === '/model' || cmd.startsWith('/model ')) {
502
+ const name = cmd.slice(6).trim();
503
+ if (!name) {
504
+ printer.systemMsg(`current model: ${currentModelRef.current}`);
505
+ return;
506
+ }
507
+ setCurrentModel(name);
508
+ printer.systemMsg(`model → ${name}`);
509
+ return;
510
+ }
188
511
  if (cmd === '/models') {
189
512
  await openPicker();
190
513
  return;
191
514
  }
192
515
  if (cmd === '/new') {
516
+ if (saveTimerRef.current) {
517
+ clearTimeout(saveTimerRef.current);
518
+ saveTimerRef.current = null;
519
+ }
193
520
  saveSession(sessionNameRef.current, historyRef.current);
194
521
  const newName = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
195
522
  historyRef.current = [];
@@ -210,6 +537,20 @@ export function InputBar({ config, skills, cwd, session }) {
210
537
  if (cmd === '/exit') {
211
538
  process.exit(0);
212
539
  }
540
+ if (cmd === '/git' || cmd.startsWith('/git ')) {
541
+ const sub = cmd.slice(4).trim();
542
+ await handleGit(sub);
543
+ return;
544
+ }
545
+ if (cmd.startsWith('/refactor ') || cmd === '/refactor') {
546
+ const goal = cmd.slice(9).trim();
547
+ if (!goal) {
548
+ printer.systemMsg('usage: /refactor <goal>');
549
+ return;
550
+ }
551
+ await runRefactor(goal);
552
+ return;
553
+ }
213
554
  if (cmd === '/plan' || cmd.startsWith('/plan ')) {
214
555
  const topic = cmd.slice(5).trim();
215
556
  setPlanningMode(true);
@@ -218,8 +559,7 @@ export function InputBar({ config, skills, cwd, session }) {
218
559
  ? `I want to plan: ${topic}`
219
560
  : 'I want to start planning. Help me think through my goals step by step.';
220
561
  printer.userMsg(msg);
221
- historyRef.current.push({ role: 'user', content: msg });
222
- saveSession(sessionNameRef.current, historyRef.current);
562
+ pushHistory({ role: 'user', content: msg });
223
563
  await runLoop(buildContext());
224
564
  return;
225
565
  }
@@ -239,8 +579,7 @@ export function InputBar({ config, skills, cwd, session }) {
239
579
  const msg = subPrompts[subCmd];
240
580
  if (msg) {
241
581
  printer.userMsg(msg);
242
- historyRef.current.push({ role: 'user', content: msg });
243
- saveSession(sessionNameRef.current, historyRef.current);
582
+ pushHistory({ role: 'user', content: msg });
244
583
  await runLoop(buildContext());
245
584
  return;
246
585
  }
@@ -260,6 +599,10 @@ export function InputBar({ config, skills, cwd, session }) {
260
599
  printer.systemMsg(`current: ${sessionNameRef.current}`);
261
600
  return;
262
601
  }
602
+ if (saveTimerRef.current) {
603
+ clearTimeout(saveTimerRef.current);
604
+ saveTimerRef.current = null;
605
+ }
263
606
  saveSession(sessionNameRef.current, historyRef.current);
264
607
  historyRef.current = loadSession(arg);
265
608
  setSessionName(arg);
@@ -288,7 +631,7 @@ export function InputBar({ config, skills, cwd, session }) {
288
631
  }
289
632
  if (skill.prompt) {
290
633
  printer.userMsg(skill.prompt);
291
- historyRef.current.push({ role: 'user', content: skill.prompt });
634
+ pushHistory({ role: 'user', content: skill.prompt });
292
635
  await runLoop(buildContext());
293
636
  return;
294
637
  }
@@ -297,9 +640,14 @@ export function InputBar({ config, skills, cwd, session }) {
297
640
  return;
298
641
  }
299
642
  const contextPrefix = buildAtContext(text);
643
+ const shouldInjectGit = config.gitContext !== false && looksCodeRelated(text);
644
+ const { prefix: gitPrefix, label: gitLabel } = shouldInjectGit
645
+ ? await buildGitContext(cwd, lastGitStatusRef)
646
+ : { prefix: '', label: '' };
647
+ if (gitLabel)
648
+ printer.systemMsg(gitLabel);
300
649
  printer.userMsg(text);
301
- historyRef.current.push({ role: 'user', content: contextPrefix + text });
302
- saveSession(sessionNameRef.current, historyRef.current);
650
+ pushHistory({ role: 'user', content: gitPrefix + contextPrefix + text });
303
651
  await runLoop(buildContext());
304
652
  }, [skills, runLoop, openPicker]);
305
653
  const handleAbort = useCallback(() => {
@@ -308,8 +656,7 @@ export function InputBar({ config, skills, cwd, session }) {
308
656
  }, []);
309
657
  const skillList = skills.list();
310
658
  // ─── render ────────────────────────────────────────────────────────────────
311
- 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" }), _jsx(Box, { paddingLeft: 2, children: status === 'thinking'
312
- ? _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] })] })
313
- : _jsx(Text, { color: "yellow", dimColor: true, children: "running tool\u2026" }) })] }), _jsx(Divider, { cols: cols })] })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, onSubmit: handleSubmit, onAbort: handleAbort })] }));
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); 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'
660
+ ? _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
+ : _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 })] }));
314
662
  }
315
- //# sourceMappingURL=InputBar.js.map
@@ -16,4 +16,3 @@ export function AtPicker({ files, query, idx }) {
16
16
  return (_jsxs(Box, { paddingX: 1, children: [_jsxs(Text, { color: active ? 'cyan' : 'white', bold: active, children: [active ? '▶' : ' ', icon] }), _jsxs(Text, { color: active ? 'cyan' : f.type === 'dir' ? 'blue' : 'white', children: [' ', f.rel] }), f.size !== undefined && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', f.size > 1024 ? `${(f.size / 1024).toFixed(0)}k` : `${f.size}b`] }))] }, f.path));
17
17
  }) }));
18
18
  }
19
- //# sourceMappingURL=AtPicker.js.map
@@ -15,11 +15,12 @@ export function CommandPalette({ skills, query, idx }) {
15
15
  }
16
16
  return (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", marginX: 1, children: filtered.map((s, i) => {
17
17
  const active = i === idx;
18
- const isBuiltin = s.ns === 'builtin';
18
+ const isBuiltin = s.ns === 'builtin' || s.ns === 'git';
19
19
  const name = (s.ns === 'default' || s.ns === 'builtin')
20
20
  ? `/${s.name}`
21
- : `/${s.ns}:${s.name}`;
21
+ : s.ns === 'git'
22
+ ? `/git ${s.name}`
23
+ : `/${s.ns}:${s.name}`;
22
24
  return (_jsxs(Box, { paddingX: 1, children: [_jsxs(Text, { color: active ? 'cyan' : isBuiltin ? 'white' : 'magenta', bold: active, children: [active ? '▶ ' : ' ', name.padEnd(20)] }), _jsx(Text, { color: "gray", dimColor: true, children: s.description })] }, `${s.ns}:${s.name}`));
23
25
  }) }));
24
26
  }
25
- //# sourceMappingURL=CommandPalette.js.map
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useMemo } from 'react';
2
+ import { useState, useMemo, useRef } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { listFiles } from '../../files/ops.js';
5
5
  import { CommandPalette } from './CommandPalette.js';
@@ -11,8 +11,17 @@ const BUILTIN_COMMANDS = [
11
11
  { ns: 'builtin', name: 'sessions', description: 'list all saved sessions' },
12
12
  { ns: 'builtin', name: 'session', description: 'switch session /session <name>' },
13
13
  { ns: 'builtin', name: 'exit', description: 'exit miii' },
14
+ { ns: 'builtin', name: 'model', description: 'switch model mid-session /model <name>' },
14
15
  { ns: 'builtin', name: 'list', description: 'list all loaded skills' },
15
16
  { ns: 'builtin', name: 'plan', description: 'start planning mode /plan [topic]' },
17
+ { ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor /refactor <goal>' },
18
+ { ns: 'git', name: 'status', description: 'show git working tree status' },
19
+ { ns: 'git', name: 'diff', description: 'show unstaged diff' },
20
+ { ns: 'git', name: 'diff --staged', description: 'show staged diff' },
21
+ { ns: 'git', name: 'log', description: 'show recent commits' },
22
+ { ns: 'git', name: 'review', description: 'review current changes with AI' },
23
+ { ns: 'git', name: 'branch', description: 'list branches' },
24
+ { ns: 'git', name: 'commit', description: 'stage all and commit /git commit <msg>' },
16
25
  ];
17
26
  const PLANNING_COMMANDS = [
18
27
  { ns: 'plan', name: 'next', description: 'suggest next concrete steps' },
@@ -25,14 +34,8 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
25
34
  const [cursor, setCursor] = useState({ row: 0, col: 0 });
26
35
  const [overlay, setOverlay] = useState('none');
27
36
  const [overlayIdx, setOverlayIdx] = useState(0);
28
- const [files] = useState(() => {
29
- try {
30
- return listFiles(cwd, true);
31
- }
32
- catch {
33
- return [];
34
- }
35
- });
37
+ const [files, setFiles] = useState([]);
38
+ const filesLoadedRef = useRef(false);
36
39
  // built-ins first, then loaded skills (deduplicated by name)
37
40
  const allCommands = useMemo(() => {
38
41
  const builtinNames = new Set(BUILTIN_COMMANDS.map(b => b.name));
@@ -64,9 +67,17 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
64
67
  }, [commandQuery, allCommands]);
65
68
  const filteredFiles = useMemo(() => {
66
69
  if (!atQuery)
67
- return files.slice(0, 8);
70
+ return [];
71
+ if (!filesLoadedRef.current) {
72
+ filesLoadedRef.current = true;
73
+ setTimeout(() => { try {
74
+ setFiles(listFiles(cwd, true));
75
+ }
76
+ catch { } }, 0);
77
+ return [];
78
+ }
68
79
  return files.filter(f => f.rel.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 8);
69
- }, [atQuery, files]);
80
+ }, [atQuery, files, cwd]);
70
81
  const overlayCount = overlay === 'command' ? filteredCommands.length : filteredFiles.length;
71
82
  function clearInput() {
72
83
  setLines(['']);
@@ -104,7 +115,9 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
104
115
  function selectCommand(skill) {
105
116
  const name = (skill.ns === 'default' || skill.ns === 'builtin')
106
117
  ? `/${skill.name}`
107
- : `/${skill.ns}:${skill.name}`;
118
+ : skill.ns === 'git'
119
+ ? `/git ${skill.name}`
120
+ : `/${skill.ns}:${skill.name}`;
108
121
  clearInput();
109
122
  onSubmit(name);
110
123
  }
@@ -275,4 +288,3 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
275
288
  function renderLineWithCursor(line, col, showCursor) {
276
289
  return line.slice(0, col) + (showCursor ? '█' : '') + line.slice(col);
277
290
  }
278
- //# sourceMappingURL=InputArea.js.map