miii-cli 0.2.2 → 0.2.4

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 +111 -6
  15. package/dist/tui/App.js +0 -1
  16. package/dist/tui/InputBar.js +397 -33
  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
@@ -6,11 +6,19 @@ import { ModelPicker } from './components/ModelPicker.js';
6
6
  import { Divider } from './components/StatusBar.js';
7
7
  import { chat } from '../llm/stream.js';
8
8
  import { listModels, pullModel } from '../llm/ollama.js';
9
- import { StreamParser } from '../parser/stream-parser.js';
9
+ import { StreamParser, extractBareToolCall } 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 = [];
@@ -107,35 +209,57 @@ export function InputBar({ config, skills, cwd, session }) {
107
209
  if (item.type === 'tool_call')
108
210
  pendingTools.push({ name: item.toolName, args: item.toolArgs });
109
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
+ }
110
218
  printer.assistantMsg(fullText);
111
- historyRef.current.push({ role: 'assistant', content: fullText });
112
- saveSession(sessionNameRef.current, historyRef.current);
219
+ pushHistory({ role: 'assistant', content: fullText });
113
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
+ }
114
232
  setStatus('idle');
115
233
  return;
116
234
  }
117
235
  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}` });
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
+ }
126
252
  }
127
- catch (e) {
128
- const err = `Tool ${tc.name} error: ${e}`;
129
- printer.errorMsg(err);
130
- next.push({ role: 'user', content: err });
253
+ else {
254
+ printer.errorMsg(`unknown tool: ${tc.name}`);
255
+ next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
131
256
  }
132
257
  }
133
- else {
134
- printer.errorMsg(`unknown tool: ${tc.name}`);
135
- next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
136
- }
137
258
  }
138
- await runLoop(next, depth + 1);
259
+ finally {
260
+ setCurrentTool(undefined);
261
+ }
262
+ await runLoop(next, depth + 1, goal);
139
263
  },
140
264
  onError(err) {
141
265
  if (err.name !== 'AbortError')
@@ -182,14 +306,234 @@ export function InputBar({ config, skills, cwd, session }) {
182
306
  setPickerError(`pull failed: ${e}`);
183
307
  }
184
308
  }, [config.baseUrl]);
309
+ // ─── refactor ─────────────────────────────────────────────────────────────
310
+ const runRefactor = useCallback(async (goal) => {
311
+ printer.systemMsg(`refactor: ${goal}`);
312
+ setTaskLabel(`planning: ${goal}`);
313
+ setStatus('thinking');
314
+ // Phase 1 — planning: ask model to list files and describe changes
315
+ const planCtx = [
316
+ { role: 'system', content: systemPromptRef.current },
317
+ {
318
+ role: 'user',
319
+ 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.`,
320
+ },
321
+ ];
322
+ abortRef.current = new AbortController();
323
+ let planText = '';
324
+ await chat({
325
+ provider: config.provider,
326
+ model: currentModelRef.current,
327
+ baseUrl: config.baseUrl,
328
+ messages: planCtx,
329
+ signal: abortRef.current.signal,
330
+ async onDone(text) { planText = text; },
331
+ onError(err) { printer.errorMsg(err.message); },
332
+ });
333
+ if (!planText) {
334
+ setStatus('idle');
335
+ setTaskLabel(undefined);
336
+ return;
337
+ }
338
+ printer.assistantMsg(planText);
339
+ // Parse FILE:/CHANGE: pairs from plan
340
+ const filePlan = [];
341
+ const lines = planText.split('\n');
342
+ let lastPath = '';
343
+ for (const line of lines) {
344
+ const fm = line.match(/^FILE:\s*(.+)/);
345
+ const cm = line.match(/^CHANGE:\s*(.+)/);
346
+ if (fm)
347
+ lastPath = fm[1].trim();
348
+ if (cm && lastPath) {
349
+ filePlan.push({ path: lastPath, change: cm[1].trim() });
350
+ lastPath = '';
351
+ }
352
+ }
353
+ if (!filePlan.length) {
354
+ printer.systemMsg('no files identified in plan — done');
355
+ setStatus('idle');
356
+ setTaskLabel(undefined);
357
+ return;
358
+ }
359
+ printer.systemMsg(`plan: ${filePlan.length} file(s) to change`);
360
+ // Phase 2 — execute via macro/micro queue
361
+ const micro = new MicroQueue();
362
+ // P1: read all files in parallel
363
+ for (const fp of filePlan) {
364
+ const t = { id: `read:${fp.path}`, priority: 1, tool: 'read_file', args: { path: fp.path }, deps: [], status: 'pending' };
365
+ micro.push(t);
366
+ }
367
+ const macro = {
368
+ id: generateId(),
369
+ goal,
370
+ priority: 0,
371
+ microtasks: micro.toArray(),
372
+ status: 'running',
373
+ };
374
+ macroQueueRef.current.enqueue(macro);
375
+ setTaskLabel(`reading ${filePlan.length} file(s)…`);
376
+ const readResults = await executorRef.current.drain(micro, ({ task, result, error }) => {
377
+ if (error)
378
+ printer.errorMsg(`read failed: ${task.args.path} — ${error}`);
379
+ else
380
+ printer.systemMsg(`read: ${task.args.path}`);
381
+ });
382
+ // Phase 3 — per-file LLM call with isolated context → patch
383
+ setTaskLabel(`applying changes…`);
384
+ const writeMicro = new MicroQueue();
385
+ for (const fp of filePlan) {
386
+ const readId = `read:${fp.path}`;
387
+ const fileContent = readResults.get(readId) ?? '';
388
+ if (!fileContent) {
389
+ printer.systemMsg(`skip (unreadable): ${fp.path}`);
390
+ continue;
391
+ }
392
+ setCurrentTool(`edit ${fp.path}`);
393
+ setTaskLabel(`editing: ${fp.path}`);
394
+ // Isolated context per file keeps model focused
395
+ const editCtx = fileEditContext(systemPromptRef.current, goal, fp.path, fileContent, fp.change);
396
+ let editText = '';
397
+ await chat({
398
+ provider: config.provider,
399
+ model: currentModelRef.current,
400
+ baseUrl: config.baseUrl,
401
+ messages: editCtx,
402
+ signal: abortRef.current?.signal,
403
+ async onDone(text) { editText = text; },
404
+ onError(err) { printer.errorMsg(`edit LLM error: ${err.message}`); },
405
+ });
406
+ if (!editText)
407
+ continue;
408
+ printer.assistantMsg(editText);
409
+ // Queue write tasks from LLM's tool calls (P2)
410
+ const parser = new StreamParser();
411
+ for (const item of [...parser.feed(editText), ...parser.flush()]) {
412
+ if (item.type === 'tool_call') {
413
+ writeMicro.push({ id: generateId(), priority: 2, tool: item.toolName, args: item.toolArgs, deps: [], status: 'pending' });
414
+ }
415
+ }
416
+ }
417
+ // Execute all writes
418
+ if (writeMicro.size > 0) {
419
+ setTaskLabel(`writing ${writeMicro.size} change(s)…`);
420
+ await executorRef.current.drain(writeMicro, ({ task, result, error }) => {
421
+ if (error)
422
+ printer.errorMsg(`${task.tool} failed: ${error}`);
423
+ else
424
+ printer.toolMsg(task.tool, result ?? '');
425
+ });
426
+ }
427
+ macro.status = 'done';
428
+ macroQueueRef.current.dequeue();
429
+ setCurrentTool(undefined);
430
+ setTaskLabel(undefined);
431
+ setStatus('idle');
432
+ printer.systemMsg(`refactor done — ${filePlan.length} file(s) processed`);
433
+ pushHistory({ role: 'user', content: `[refactor] ${goal}` });
434
+ pushHistory({ role: 'assistant', content: planText });
435
+ }, [config]);
436
+ // ─── git ───────────────────────────────────────────────────────────────────
437
+ const handleGit = useCallback(async (sub) => {
438
+ const git = async (args) => {
439
+ try {
440
+ const { stdout, stderr } = await gitRun(`git ${args}`, { timeout: 15_000 });
441
+ return (stdout + stderr).trim();
442
+ }
443
+ catch (e) {
444
+ return e.message ?? String(e);
445
+ }
446
+ };
447
+ // /git or /git status
448
+ if (!sub || sub === 'status') {
449
+ const out = await git('status');
450
+ printer.systemMsg(out);
451
+ return;
452
+ }
453
+ // /git log [n]
454
+ if (sub === 'log' || sub.startsWith('log ')) {
455
+ 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);
458
+ return;
459
+ }
460
+ // /git diff [--staged] [file]
461
+ if (sub === 'diff' || sub.startsWith('diff ')) {
462
+ const args = sub.slice(4).trim();
463
+ 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)');
466
+ return;
467
+ }
468
+ // /git review — inject diff into context, ask model to review
469
+ if (sub === 'review') {
470
+ const diff = await git('diff HEAD');
471
+ const staged = await git('diff --staged');
472
+ const combined = [diff, staged].filter(Boolean).join('\n').trim();
473
+ if (!combined || combined === '(no diff)') {
474
+ printer.systemMsg('no changes to review');
475
+ return;
476
+ }
477
+ const truncated = combined.length > 8000 ? combined.slice(0, 8000) + '\n…[truncated]' : combined;
478
+ const userMsg = `Review these git changes for bugs, issues, and improvements:\n\n${truncated}`;
479
+ printer.userMsg('/git review');
480
+ pushHistory({ role: 'user', content: userMsg });
481
+ await runLoop(buildContext());
482
+ return;
483
+ }
484
+ // /git branch
485
+ 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)');
489
+ return;
490
+ }
491
+ // /git commit <msg>
492
+ if (sub.startsWith('commit ')) {
493
+ const msg = sub.slice(7).trim();
494
+ if (!msg) {
495
+ printer.systemMsg('usage: /git commit <message>');
496
+ return;
497
+ }
498
+ const status = await git('status --short');
499
+ if (!status || status === '(clean — no changes)') {
500
+ printer.systemMsg('nothing to commit — working tree clean');
501
+ return;
502
+ }
503
+ printer.systemMsg(`staging and committing:\n${status}`);
504
+ const stageOut = await git('add -A');
505
+ if (stageOut)
506
+ printer.systemMsg(stageOut);
507
+ const commitOut = await git(`commit -m ${JSON.stringify(msg)}`);
508
+ printer.systemMsg(commitOut);
509
+ return;
510
+ }
511
+ // fallthrough — run arbitrary git subcommand
512
+ const out = await git(sub);
513
+ printer.systemMsg(out || '(done)');
514
+ }, []);
185
515
  // ─── submit ────────────────────────────────────────────────────────────────
186
516
  const handleSubmit = useCallback(async (text) => {
187
517
  const cmd = text.trim();
518
+ if (cmd === '/model' || cmd.startsWith('/model ')) {
519
+ const name = cmd.slice(6).trim();
520
+ if (!name) {
521
+ printer.systemMsg(`current model: ${currentModelRef.current}`);
522
+ return;
523
+ }
524
+ setCurrentModel(name);
525
+ printer.systemMsg(`model → ${name}`);
526
+ return;
527
+ }
188
528
  if (cmd === '/models') {
189
529
  await openPicker();
190
530
  return;
191
531
  }
192
532
  if (cmd === '/new') {
533
+ if (saveTimerRef.current) {
534
+ clearTimeout(saveTimerRef.current);
535
+ saveTimerRef.current = null;
536
+ }
193
537
  saveSession(sessionNameRef.current, historyRef.current);
194
538
  const newName = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
195
539
  historyRef.current = [];
@@ -210,6 +554,20 @@ export function InputBar({ config, skills, cwd, session }) {
210
554
  if (cmd === '/exit') {
211
555
  process.exit(0);
212
556
  }
557
+ if (cmd === '/git' || cmd.startsWith('/git ')) {
558
+ const sub = cmd.slice(4).trim();
559
+ await handleGit(sub);
560
+ return;
561
+ }
562
+ if (cmd.startsWith('/refactor ') || cmd === '/refactor') {
563
+ const goal = cmd.slice(9).trim();
564
+ if (!goal) {
565
+ printer.systemMsg('usage: /refactor <goal>');
566
+ return;
567
+ }
568
+ await runRefactor(goal);
569
+ return;
570
+ }
213
571
  if (cmd === '/plan' || cmd.startsWith('/plan ')) {
214
572
  const topic = cmd.slice(5).trim();
215
573
  setPlanningMode(true);
@@ -218,8 +576,7 @@ export function InputBar({ config, skills, cwd, session }) {
218
576
  ? `I want to plan: ${topic}`
219
577
  : 'I want to start planning. Help me think through my goals step by step.';
220
578
  printer.userMsg(msg);
221
- historyRef.current.push({ role: 'user', content: msg });
222
- saveSession(sessionNameRef.current, historyRef.current);
579
+ pushHistory({ role: 'user', content: msg });
223
580
  await runLoop(buildContext());
224
581
  return;
225
582
  }
@@ -239,8 +596,7 @@ export function InputBar({ config, skills, cwd, session }) {
239
596
  const msg = subPrompts[subCmd];
240
597
  if (msg) {
241
598
  printer.userMsg(msg);
242
- historyRef.current.push({ role: 'user', content: msg });
243
- saveSession(sessionNameRef.current, historyRef.current);
599
+ pushHistory({ role: 'user', content: msg });
244
600
  await runLoop(buildContext());
245
601
  return;
246
602
  }
@@ -260,6 +616,10 @@ export function InputBar({ config, skills, cwd, session }) {
260
616
  printer.systemMsg(`current: ${sessionNameRef.current}`);
261
617
  return;
262
618
  }
619
+ if (saveTimerRef.current) {
620
+ clearTimeout(saveTimerRef.current);
621
+ saveTimerRef.current = null;
622
+ }
263
623
  saveSession(sessionNameRef.current, historyRef.current);
264
624
  historyRef.current = loadSession(arg);
265
625
  setSessionName(arg);
@@ -288,7 +648,7 @@ export function InputBar({ config, skills, cwd, session }) {
288
648
  }
289
649
  if (skill.prompt) {
290
650
  printer.userMsg(skill.prompt);
291
- historyRef.current.push({ role: 'user', content: skill.prompt });
651
+ pushHistory({ role: 'user', content: skill.prompt });
292
652
  await runLoop(buildContext());
293
653
  return;
294
654
  }
@@ -297,9 +657,14 @@ export function InputBar({ config, skills, cwd, session }) {
297
657
  return;
298
658
  }
299
659
  const contextPrefix = buildAtContext(text);
660
+ const shouldInjectGit = config.gitContext !== false && looksCodeRelated(text);
661
+ const { prefix: gitPrefix, label: gitLabel } = shouldInjectGit
662
+ ? await buildGitContext(cwd, lastGitStatusRef)
663
+ : { prefix: '', label: '' };
664
+ if (gitLabel)
665
+ printer.systemMsg(gitLabel);
300
666
  printer.userMsg(text);
301
- historyRef.current.push({ role: 'user', content: contextPrefix + text });
302
- saveSession(sessionNameRef.current, historyRef.current);
667
+ pushHistory({ role: 'user', content: gitPrefix + contextPrefix + text });
303
668
  await runLoop(buildContext());
304
669
  }, [skills, runLoop, openPicker]);
305
670
  const handleAbort = useCallback(() => {
@@ -308,8 +673,7 @@ export function InputBar({ config, skills, cwd, session }) {
308
673
  }, []);
309
674
  const skillList = skills.list();
310
675
  // ─── 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 })] }));
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'
677
+ ? _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
+ : _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
679
  }
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