miii-cli 1.1.3 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  **Miii is a fully autonomous coding agent that runs entirely on your machine.** It plans, edits files, runs your tests, searches the web, indexes your codebase semantically, and iterates until the job is done — all without a single byte of your code leaving your network.
15
15
 
16
- Zero subscription. Zero cloud dependency. Zero Python overhead. **176 KB total.**
16
+ Zero subscription. Zero cloud dependency. Zero Python overhead. **Lightning fast startup.**
17
17
 
18
18
  ```bash
19
19
  npm install -g miii-cli && miii
@@ -52,9 +52,9 @@ Your compute. Your data. Your rules.
52
52
 
53
53
  ---
54
54
 
55
- ## What Miii Actually Does
55
+ ## How it Works
56
56
 
57
- This isn't autocomplete. Miii is a **full autonomous agent loop:**
57
+ Miii isn't just autocomplete—it's a **full autonomous agent loop** that reasons through complex tasks:
58
58
 
59
59
  1. You describe a goal
60
60
  2. Miii reads your codebase, plans the changes, edits the files
@@ -95,7 +95,7 @@ This isn't autocomplete. Miii is a **full autonomous agent loop:**
95
95
 
96
96
  ---
97
97
 
98
- ## Killer Features
98
+ ## 🚀 Core Capabilities
99
99
 
100
100
  **🔒 Privacy-First, Local by Default**
101
101
  Run on Ollama and your code never leaves your machine. No account. No API key. No monthly bill. Switch to Anthropic or OpenAI when you need it — one command, live, mid-session.
@@ -132,7 +132,7 @@ Connect any MCP-compatible tool server. Miii discovers tools automatically and m
132
132
 
133
133
  ---
134
134
 
135
- ## Get Running in 60 Seconds
135
+ ## Quick Start
136
136
 
137
137
  ```bash
138
138
  # 1. Start Ollama and pull a model
@@ -150,7 +150,7 @@ No API keys. No account. No sign-up form. First run walks you through setup inte
150
150
 
151
151
  ---
152
152
 
153
- ## Power Commands
153
+ ## ⌨️ Power Commands
154
154
 
155
155
  | Command | What it does |
156
156
  |---|---|
@@ -164,6 +164,7 @@ No API keys. No account. No sign-up form. First run walks you through setup inte
164
164
  | `/plan <topic>` | Structured planning mode before you write a line |
165
165
  | `/model <name>` | Hot-swap your LLM mid-conversation |
166
166
  | `/session <name>` | Switch between named project sessions |
167
+ | `/watch <path>` | Monitor files for changes and trigger agent reactions |
167
168
  | `@filename` | Inject any file directly into context |
168
169
 
169
170
  ---
package/dist/config.js CHANGED
@@ -42,7 +42,9 @@ export function loadConfig() {
42
42
  }
43
43
  return { ...defaults, ...safe };
44
44
  }
45
- catch { }
45
+ catch {
46
+ process.stderr.write(`Warning: could not parse config at ${p} — using defaults\n`);
47
+ }
46
48
  }
47
49
  }
48
50
  return { ...defaults };
package/dist/init.js CHANGED
@@ -15,7 +15,7 @@ import { loadMCPTools } from './mcp/client.js';
15
15
  import { needsSetup, runSetup } from './setup.js';
16
16
  const require = createRequire(import.meta.url);
17
17
  const UPDATE_CACHE = join(homedir(), '.config', 'miii', 'update-check.json');
18
- const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
18
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1h
19
19
  function semverGt(a, b) {
20
20
  const pa = a.split('.').map(Number);
21
21
  const pb = b.split('.').map(Number);
@@ -68,9 +68,13 @@ export class MCPClient {
68
68
  send(method, params) {
69
69
  return new Promise((resolve, reject) => {
70
70
  const id = this.nextId++;
71
- this.pending.set(id, { resolve, reject });
71
+ let timer;
72
+ this.pending.set(id, {
73
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
74
+ reject: (e) => { clearTimeout(timer); reject(e); },
75
+ });
72
76
  this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
73
- setTimeout(() => {
77
+ timer = setTimeout(() => {
74
78
  if (this.pending.has(id)) {
75
79
  this.pending.delete(id);
76
80
  reject(new Error(`MCP timeout: ${method}`));
@@ -99,12 +99,12 @@ function extractFileToolArgs(text, toolName) {
99
99
  }
100
100
  }
101
101
  // For patch_file: extract old/new fields
102
- const oldM = text.match(/"old"\s*:\s*"([\s\S]*?)"(?:\s*,|\s*\})/);
102
+ const oldM = text.match(/"old"\s*:\s*"((?:[^"\\]|\\[\s\S])*)"/);
103
103
  if (oldM)
104
- args.old = oldM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
105
- const newM = text.match(/"new"\s*:\s*"([\s\S]*?)"(?:\s*,|\s*\})/);
104
+ args.old = oldM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
105
+ const newM = text.match(/"new"\s*:\s*"((?:[^"\\]|\\[\s\S])*)"/);
106
106
  if (newM)
107
- args.new = newM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
107
+ args.new = newM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
108
108
  return Object.keys(args).length > 0 ? args : null;
109
109
  }
110
110
  // Extract a bare tool-call JSON from arbitrary text (LLM skipped <tool_call> wrapper)
package/dist/sessions.js CHANGED
@@ -48,13 +48,15 @@ export function loadSession(projectDir, name) {
48
48
  return Array.isArray(parsed) ? parsed : [];
49
49
  }
50
50
  catch {
51
+ process.stderr.write(`Warning: corrupt session file at ${p} — starting fresh\n`);
51
52
  return [];
52
53
  }
53
54
  }
54
55
  export function saveSession(projectDir, name, messages) {
56
+ const safeName = sanitizeName(name);
55
57
  ensureProjectDir(projectDir);
56
58
  try {
57
- writeFileSync(join(sessionsDir(projectDir), `${sanitizeName(name)}.json`), JSON.stringify(messages), { mode: 0o600 });
59
+ writeFileSync(join(sessionsDir(projectDir), `${safeName}.json`), JSON.stringify(messages), { mode: 0o600 });
58
60
  }
59
61
  catch { }
60
62
  }
@@ -66,9 +66,12 @@ export async function compactContext(messages, cfg, goal) {
66
66
  role: 'user',
67
67
  content: `[Context compacted — ${toSummarize.length} messages summarised]\n\n${summary}`,
68
68
  };
69
+ const needsBridge = recent.length > 0 && recent[0].role === 'user';
70
+ const bridgeMsg = { role: 'assistant', content: 'Understood. Continuing from the summarized context.' };
69
71
  return [
70
72
  ...(system ? [system] : []),
71
73
  summaryMsg,
74
+ ...(needsBridge ? [bridgeMsg] : []),
72
75
  ...recent,
73
76
  ];
74
77
  }
@@ -68,8 +68,10 @@ export const tools = [
68
68
  execute: async ({ path, old: oldStr, new: newStr }) => {
69
69
  const safe = guardPath(path);
70
70
  const current = readFile(safe);
71
- if (!current)
72
- throw new Error(`file not found or empty: ${path}`);
71
+ if (current === null)
72
+ throw new Error(`file not found: ${path}`);
73
+ if (current === '')
74
+ throw new Error(`file empty: ${path}`);
73
75
  const old = oldStr;
74
76
  const count = current.split(old).length - 1;
75
77
  if (count === 0) {
@@ -79,7 +81,7 @@ export const tools = [
79
81
  if (count > 1) {
80
82
  throw new Error(`ambiguous: ${count} matches found in ${path} — extend <old> block with more surrounding lines to make it unique`);
81
83
  }
82
- const updated = current.replace(old, newStr);
84
+ const updated = current.replace(old, String(newStr));
83
85
  writeFile(safe, updated);
84
86
  // Compute affected line range for the snippet
85
87
  const startLine = current.slice(0, current.indexOf(old)).split('\n').length;
@@ -191,7 +193,7 @@ export const tools = [
191
193
  if (!message)
192
194
  throw new Error('git_commit: message required');
193
195
  const fileStr = String(files);
194
- if (/\.\./.test(fileStr) || !/^(-A|\.|[\w./\-\s]+)$/.test(fileStr))
196
+ if (/\.\./.test(fileStr) || !/^(-A|\.|[\w./\-]+(?: [\w./\-]+)*)$/.test(fileStr))
195
197
  throw new Error('git_commit: invalid files argument — use -A, ., or space-separated paths (no .. allowed)');
196
198
  try {
197
199
  const fileArgs = fileStr === '-A' ? ['-A'] : fileStr === '.' ? ['.'] : fileStr.split(/\s+/).filter(Boolean);
@@ -6,7 +6,7 @@ import { ModelPicker } from './components/ModelPicker.js';
6
6
  import { ConfigPicker } from './components/ConfigPicker.js';
7
7
  import { Divider } from './components/StatusBar.js';
8
8
  import { tools } from '../tools/index.js';
9
- import { toolArgSummary } from './printer.js';
9
+ import { toolArgSummary, formatElapsed } from './printer.js';
10
10
  import { MacroQueue } from '../tasks/queue.js';
11
11
  import { TaskExecutor } from '../tasks/executor.js';
12
12
  import { THINKING_PHRASES, SPARKLE } from './thinking.js';
@@ -16,30 +16,67 @@ import { useRunLoop } from './hooks/useRunLoop.js';
16
16
  import { useRefactor } from './hooks/useRefactor.js';
17
17
  import { useGit } from './hooks/useGit.js';
18
18
  import { useSubmit } from './hooks/useSubmit.js';
19
+ import { useWatch } from './hooks/useWatch.js';
19
20
  import { runDeepThink } from './deepThink.js';
20
21
  import { setInkInstance } from './printer.js';
21
22
  import { createSearchCodebaseTool } from '../index/tool.js';
22
23
  import { saveConfig } from '../config.js';
23
24
  import { getTavilyKey, saveTavilyKey } from '../tavily/client.js';
24
25
  import { warmup } from '../llm/stream.js';
25
- function formatElapsed(ms) {
26
- const s = Math.floor(ms / 1000);
27
- if (s < 60)
28
- return `${s}s`;
29
- const m = Math.floor(s / 60);
30
- const rem = s % 60;
31
- return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
26
+ const MAX_DIFF_LINES = 40;
27
+ const DIFF_CTX = 2;
28
+ function lineDiff(oldText, newText) {
29
+ const a = oldText.split('\n');
30
+ const b = newText.split('\n');
31
+ const m = a.length, n = b.length;
32
+ if (m * n > 10000) {
33
+ return [...a.map(line => ({ type: 'del', line })), ...b.map(line => ({ type: 'add', line }))];
34
+ }
35
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
36
+ for (let i = m - 1; i >= 0; i--)
37
+ for (let j = n - 1; j >= 0; j--)
38
+ dp[i][j] = a[i] === b[j] ? 1 + dp[i + 1][j + 1] : Math.max(dp[i + 1][j], dp[i][j + 1]);
39
+ const result = [];
40
+ let i = 0, j = 0;
41
+ while (i < m || j < n) {
42
+ if (i < m && j < n && a[i] === b[j]) {
43
+ result.push({ type: 'eq', line: a[i++] });
44
+ j++;
45
+ }
46
+ else if (j < n && (i >= m || dp[i + 1][j] <= dp[i][j + 1])) {
47
+ result.push({ type: 'add', line: b[j++] });
48
+ }
49
+ else {
50
+ result.push({ type: 'del', line: a[i++] });
51
+ }
52
+ }
53
+ return result;
54
+ }
55
+ function diffHunks(diff) {
56
+ const changedIdxs = diff.reduce((acc, d, i) => { if (d.type !== 'eq')
57
+ acc.push(i); return acc; }, []);
58
+ if (!changedIdxs.length)
59
+ return [];
60
+ const inHunk = new Set();
61
+ for (const ci of changedIdxs)
62
+ for (let k = Math.max(0, ci - DIFF_CTX); k <= Math.min(diff.length - 1, ci + DIFF_CTX); k++)
63
+ inHunk.add(k);
64
+ return diff.filter((_, i) => inHunk.has(i));
32
65
  }
33
- const MAX_DIFF_LINES = 5;
34
66
  function DiffPreview({ toolName, args }) {
35
- if (toolName === 'patch_file' && (args.old || args.new)) {
36
- const oldLines = String(args.old ?? '').split('\n');
37
- const newLines = String(args.new ?? '').split('\n');
38
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [oldLines.slice(0, MAX_DIFF_LINES).map((line, i) => (_jsxs(Text, { color: "red", dimColor: true, children: ["- ", line.slice(0, 72)] }, `o${i}`))), oldLines.length > MAX_DIFF_LINES && (_jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", oldLines.length - MAX_DIFF_LINES, " more"] })), newLines.slice(0, MAX_DIFF_LINES).map((line, i) => (_jsxs(Text, { color: "green", dimColor: true, children: ["+ ", line.slice(0, 72)] }, `n${i}`))), newLines.length > MAX_DIFF_LINES && (_jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", newLines.length - MAX_DIFF_LINES, " more"] }))] }));
67
+ if (toolName === 'patch_file' && (args.old != null || args.new != null)) {
68
+ const path = String(args.path ?? '');
69
+ const diff = diffHunks(lineDiff(String(args.old ?? ''), String(args.new ?? '')));
70
+ const visible = diff.slice(0, MAX_DIFF_LINES);
71
+ const hidden = diff.length - visible.length;
72
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [" ", path] }), visible.map((d, i) => (_jsxs(Text, { color: d.type === 'del' ? 'red' : d.type === 'add' ? 'green' : 'gray', dimColor: d.type === 'eq', children: [d.type === 'del' ? '- ' : d.type === 'add' ? '+ ' : ' ', d.line.slice(0, 76)] }, i))), hidden > 0 && _jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", hidden, " more line", hidden === 1 ? '' : 's'] })] }));
39
73
  }
40
74
  if ((toolName === 'edit_file' || toolName === 'create_file') && args.content) {
41
- const n = String(args.content).split('\n').length;
42
- return (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "gray", dimColor: true, children: [n, " line", n === 1 ? '' : 's'] }) }));
75
+ const path = String(args.path ?? '');
76
+ const lines = String(args.content).split('\n');
77
+ const visible = lines.slice(0, MAX_DIFF_LINES);
78
+ const hidden = lines.length - visible.length;
79
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [" ", path] }), visible.map((line, i) => (_jsxs(Text, { color: "green", children: ["+ ", line.slice(0, 76)] }, i))), hidden > 0 && _jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", hidden, " more line", hidden === 1 ? '' : 's'] })] }));
43
80
  }
44
81
  return null;
45
82
  }
@@ -79,6 +116,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
79
116
  setStatus, setTaskLabel, setCurrentTool, pushHistory,
80
117
  });
81
118
  const { handleGit } = useGit({ pushHistory, buildContext, runLoop });
119
+ const { watchActive, startWatch, stopWatch } = useWatch(cwd, { runLoop, buildContext, pushHistory });
82
120
  const { handleSubmit } = useSubmit({
83
121
  config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel,
84
122
  historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef,
@@ -87,6 +125,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
87
125
  setStatus, setTaskLabel, setCurrentTool,
88
126
  runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig,
89
127
  setConfigOpen, updateMemory,
128
+ startWatch, stopWatch, watchActive,
90
129
  });
91
130
  const skillList = skills.list();
92
131
  return (_jsxs(Box, { flexDirection: "column", children: [configOpen ? (_jsxs(_Fragment, { children: [_jsx(ConfigPicker, { config: config, currentModel: currentModel, tavilyKey: tavilyKey, onUpdate: ({ model, ...configPatch }) => {
@@ -96,7 +135,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
96
135
  setConfig(c => ({ ...c, ...configPatch }));
97
136
  saveConfig(configPatch);
98
137
  }
99
- }, onTavilyKey: (key) => { saveTavilyKey(key); setTavilyKey(key); }, onClose: () => { setConfigOpen(false); } }), _jsx(Divider, { cols: cols })] })) : 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 })] })) : compactRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: "context is large" }), _jsxs(Text, { color: "gray", children: ["(~", compactRequest.messageCount, "k chars)"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "compact to keep responses fast, or keep full history" })] })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] }), _jsx(DiffPreview, { toolName: permissionRequest.toolName, args: permissionRequest.args })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { children: status === 'thinking'
100
- ? _jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", children: [SPARKLE[tick % SPARKLE.length], " "] }), _jsx(Text, { color: "gray", dimColor: true, italic: true, children: THINKING_PHRASES[phraseSeq[Math.floor(tick / 62) % phraseSeq.length]] })] })
101
- : _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "gray", dimColor: true, children: formatElapsed(Date.now() - thinkingStartRef.current) }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })] })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, permissionRequest: permissionRequest, onPermissionResponse: resolvePermission, compactRequest: compactRequest, onCompactResponse: resolveCompact, onSubmit: handleSubmit, onAbort: handleAbort, history: historyRef.current.filter(m => m.role === 'user').map(m => m.content) })] }));
138
+ }, onTavilyKey: (key) => { saveTavilyKey(key); setTavilyKey(key); }, onClose: () => { setConfigOpen(false); } }), _jsx(Divider, { cols: cols })] })) : 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 })] })) : compactRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: "context is large" }), _jsxs(Text, { color: "gray", children: ["(~", compactRequest.messageCount, "k chars)"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "compact to keep responses fast, or keep full history" })] })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] }), _jsx(DiffPreview, { toolName: permissionRequest.toolName, args: permissionRequest.args })] })) : (status === 'thinking' || status === 'tool') ? (_jsx(Box, { paddingX: 1, gap: 1, children: status === 'thinking'
139
+ ? _jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: SPARKLE[tick % SPARKLE.length] }), _jsx(Text, { color: Math.floor(tick / 4) % 6 >= 2 && Math.floor(tick / 4) % 6 <= 4 ? 'white' : 'gray', italic: true, children: THINKING_PHRASES[phraseSeq[Math.floor(tick / 62) % phraseSeq.length]] }), _jsx(Text, { color: "gray", dimColor: true, children: formatElapsed(Date.now() - thinkingStartRef.current) }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })
140
+ : _jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }), _jsx(Text, { color: "gray", dimColor: true, children: formatElapsed(Date.now() - thinkingStartRef.current) }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] }) })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, permissionRequest: permissionRequest, onPermissionResponse: resolvePermission, compactRequest: compactRequest, onCompactResponse: resolveCompact, onSubmit: handleSubmit, onAbort: handleAbort, history: historyRef.current.filter(m => m.role === 'user').map(m => m.content), watchActive: watchActive })] }));
102
141
  }
@@ -23,6 +23,7 @@ const BUILTIN_COMMANDS = [
23
23
  { ns: 'builtin', name: 'plan', description: 'enter planning mode — AI helps think through a goal step-by-step' },
24
24
  { ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor — plans, reads, then edits — /refactor <goal>' },
25
25
  { ns: 'builtin', name: 'think', description: 'deep research before answering — reads files + optional web — /think <query>' },
26
+ { ns: 'builtin', name: 'watch', description: 'watch for file changes, run tests, auto-fix failures — /watch stop to cancel' },
26
27
  // ── Git ───────────────────────────────────────────────────────────────────
27
28
  { ns: 'git', name: 'status', description: 'show git working tree status (modified, staged, untracked)' },
28
29
  { ns: 'git', name: 'diff', description: 'show unstaged changes as a diff' },
@@ -55,7 +56,7 @@ function wordEndAfter(line, col) {
55
56
  i++;
56
57
  return i;
57
58
  }
58
- export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [] }) {
59
+ export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [], watchActive = false }) {
59
60
  const [lines, setLines] = useState(['']);
60
61
  const [cursor, setCursor] = useState({ row: 0, col: 0 });
61
62
  const [overlay, setOverlay] = useState('none');
@@ -468,11 +469,15 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
468
469
  ? `history ${historyIdx + 1}/${history.length} ↑↓ navigate esc clear`
469
470
  : planningMode
470
471
  ? 'planning mode /plan:done exit'
471
- : '? for shortcuts';
472
+ : watchActive
473
+ ? 'watch active /watch stop to cancel'
474
+ : '? for shortcuts';
472
475
  const pastePreview = pasteRef.current
473
476
  ? pasteRef.current.split('\n')[0].slice(0, cols - 6)
474
477
  : '';
475
- return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: promptColor, bold: true, children: '> ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: permissionRequest ? (_jsxs(Box, { gap: 3, children: [_jsx(Text, { color: "green", bold: true, children: "y once" }), _jsx(Text, { color: "cyan", bold: true, children: "a session" }), _jsx(Text, { color: "red", bold: true, children: "n deny" })] })) : compactRequest ? (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "green", bold: true, children: "y yes" }), _jsx(Text, { color: "red", bold: true, children: "n no" })] })) : pasteLines > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " line", pasteLines !== 1 ? 's' : ''] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] }), pastePreview && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", pastePreview, pasteRef.current.split('\n')[0].length > cols - 6 ? '…' : ''] }))] })) : lines.length === 1 && !lines[0] ? (_jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
478
+ return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: promptColor, bold: true, children: '> ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: permissionRequest ? (_jsxs(Box, { gap: 3, children: [_jsx(Text, { color: "green", bold: true, children: "y once" }), _jsx(Text, { color: "cyan", bold: true, children: "a session" }), _jsx(Text, { color: "red", bold: true, children: "n deny" })] })) : compactRequest ? (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "green", bold: true, children: "y yes" }), _jsx(Text, { color: "red", bold: true, children: "n no" })] })) : pasteLines > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " line", pasteLines !== 1 ? 's' : ''] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] }), pastePreview && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", pastePreview, pasteRef.current.split('\n')[0].length > cols - 6 ? '…' : ''] }))] })) : lines.length === 1 && !lines[0] ? (watchActive && isActive
479
+ ? _jsxs(Text, { children: [_jsx(Text, { color: "cyan", dimColor: true, children: "watching\u2026 " }), _jsx(Text, { children: "\u2588" })] })
480
+ : _jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
476
481
  ? viewportLine(line, cursor.col, availWidth, isActive)
477
482
  : line.length > availWidth ? '…' + line.slice(line.length - availWidth + 1) : line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Text, { color: "gray", dimColor: true, children: [" ", hint] })] }));
478
483
  }
@@ -120,6 +120,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
120
120
  await runLoop([...msgs, { role: 'assistant', content: fullText }, nudge], depth + 1, goal);
121
121
  return;
122
122
  }
123
+ printer.systemMsg(`done in ${printer.formatElapsed(Date.now() - thinkingStartRef.current)}`);
123
124
  setStatus('idle');
124
125
  return;
125
126
  }
@@ -171,12 +172,19 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
171
172
  const oldText = tc.args.old;
172
173
  if (filePath && oldText && existsSync(filePath)) {
173
174
  const current = readFileSync(filePath, 'utf-8');
174
- if (!current.includes(oldText)) {
175
+ const occurrences = current.split(oldText).length - 1;
176
+ if (occurrences === 0) {
175
177
  printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
176
178
  next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
177
179
  next.push({ role: 'user', content: `patch_file failed: old text not found in ${filePath}. The file content above is the current state. Retry patch_file with the correct exact text.` });
178
180
  continue;
179
181
  }
182
+ if (occurrences > 1) {
183
+ printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
184
+ next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
185
+ next.push({ role: 'user', content: `patch_file failed: old text matches ${occurrences} locations in ${filePath}. Use more surrounding context to make old text unique, then retry.` });
186
+ continue;
187
+ }
180
188
  }
181
189
  }
182
190
  printer.toolCallStart(tc.name, tc.args);
@@ -37,7 +37,7 @@ export function useSubmit(deps) {
37
37
  const depsRef = useRef(deps);
38
38
  depsRef.current = deps;
39
39
  const handleSubmit = useCallback(async (text) => {
40
- const { config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel, historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef, setPlanningMode, runLoop, buildContext, pushHistory, setSessionName, renameFromMessage, setStatus, setTaskLabel, setCurrentTool, runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig, setConfigOpen, updateMemory, } = depsRef.current;
40
+ const { config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel, historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef, setPlanningMode, runLoop, buildContext, pushHistory, setSessionName, renameFromMessage, setStatus, setTaskLabel, setCurrentTool, runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig, setConfigOpen, updateMemory, startWatch, stopWatch, } = depsRef.current;
41
41
  const cmd = text.trim();
42
42
  if (cmd === '?') {
43
43
  printer.systemMsg('shortcuts:\n' +
@@ -431,6 +431,15 @@ export function useSubmit(deps) {
431
431
  printer.systemMsg('usage: /index build | /index status | /index search <query> | /index clear');
432
432
  return;
433
433
  }
434
+ if (cmd === '/watch' || cmd.startsWith('/watch ')) {
435
+ const sub = cmd.slice(6).trim();
436
+ if (sub === 'stop') {
437
+ stopWatch();
438
+ return;
439
+ }
440
+ startWatch();
441
+ return;
442
+ }
434
443
  if (text.startsWith('/')) {
435
444
  const [slashCmd, ...rest] = text.slice(1).split(' ');
436
445
  const skill = skills.get(slashCmd);
@@ -0,0 +1,119 @@
1
+ import { useRef, useState, useCallback, useEffect } from 'react';
2
+ import { watch } from 'fs';
3
+ import { tools as staticTools } from '../../tools/index.js';
4
+ import * as printer from '../printer.js';
5
+ const WATCH_DEBOUNCE_MS = 600;
6
+ const IGNORE_DIRS = new Set([
7
+ 'node_modules', '.git', 'dist', '.next', 'build', 'coverage',
8
+ '__pycache__', '.turbo', '.cache', '.parcel-cache', 'out',
9
+ ]);
10
+ const WATCH_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|c|cpp|h|hpp|css|scss)$/;
11
+ function testsFailed(output) {
12
+ // Explicit pass with no failures → passing
13
+ if (/\b0 fail/i.test(output))
14
+ return false;
15
+ if (/\d+ pass/i.test(output) && !/\d+ fail/i.test(output))
16
+ return false;
17
+ return /\d+ fail|FAIL\b|✕|✗|\bfailing\b|AssertionError/i.test(output);
18
+ }
19
+ export function useWatch(cwd, deps) {
20
+ const [watchActive, setWatchActive] = useState(false);
21
+ const watcherRef = useRef(null);
22
+ const debounceRef = useRef(null);
23
+ const changedRef = useRef(new Set());
24
+ const fixRunningRef = useRef(false);
25
+ // Always-fresh deps via ref — watcher callback is set up once
26
+ const depsRef = useRef(deps);
27
+ useEffect(() => { depsRef.current = deps; });
28
+ const stopWatch = useCallback(() => {
29
+ if (debounceRef.current) {
30
+ clearTimeout(debounceRef.current);
31
+ debounceRef.current = null;
32
+ }
33
+ watcherRef.current?.close();
34
+ watcherRef.current = null;
35
+ changedRef.current.clear();
36
+ fixRunningRef.current = false;
37
+ setWatchActive(false);
38
+ printer.systemMsg('watch: stopped');
39
+ }, []);
40
+ const startWatch = useCallback(() => {
41
+ if (watcherRef.current) {
42
+ printer.systemMsg('watch: already active — /watch stop to cancel');
43
+ return;
44
+ }
45
+ let watcher;
46
+ try {
47
+ watcher = watch(cwd, { recursive: true }, (_event, filename) => {
48
+ if (!filename)
49
+ return;
50
+ const parts = filename.split('/');
51
+ if (parts.some(p => IGNORE_DIRS.has(p) || p.startsWith('.')))
52
+ return;
53
+ if (!WATCH_EXT.test(filename))
54
+ return;
55
+ changedRef.current.add(filename);
56
+ if (debounceRef.current)
57
+ clearTimeout(debounceRef.current);
58
+ debounceRef.current = setTimeout(async () => {
59
+ debounceRef.current = null;
60
+ const changed = [...changedRef.current];
61
+ changedRef.current.clear();
62
+ const testTool = staticTools.find(t => t.name === 'run_tests');
63
+ if (!testTool)
64
+ return;
65
+ const label = changed.length > 3
66
+ ? `${changed.slice(0, 3).join(', ')} +${changed.length - 3} more`
67
+ : changed.join(', ');
68
+ printer.systemMsg(`watch: ${label} — running tests`);
69
+ if (fixRunningRef.current) {
70
+ printer.systemMsg('watch: fix in progress — skipping this cycle');
71
+ return;
72
+ }
73
+ fixRunningRef.current = true;
74
+ try {
75
+ const result = await testTool.execute({});
76
+ if (!result || result.startsWith('(no '))
77
+ return;
78
+ if (testsFailed(result)) {
79
+ printer.systemMsg('watch: tests failing — triggering fix');
80
+ const { pushHistory, buildContext, runLoop } = depsRef.current;
81
+ const fixMsg = `Tests are failing after changes to: ${changed.join(', ')}\n\n` +
82
+ `Test output:\n${result}\n\n` +
83
+ `Read the failing files and fix the issues.`;
84
+ pushHistory({ role: 'user', content: fixMsg });
85
+ await runLoop(buildContext(), 0, 'fix failing tests');
86
+ }
87
+ else {
88
+ printer.systemMsg('watch: tests passing');
89
+ }
90
+ }
91
+ catch (e) {
92
+ printer.errorMsg(`watch: ${e}`);
93
+ }
94
+ finally {
95
+ fixRunningRef.current = false;
96
+ }
97
+ }, WATCH_DEBOUNCE_MS);
98
+ });
99
+ }
100
+ catch (e) {
101
+ printer.errorMsg(`watch: failed to start: ${e}`);
102
+ return;
103
+ }
104
+ watcher.on('error', (err) => {
105
+ printer.errorMsg(`watch: ${err.message}`);
106
+ stopWatch();
107
+ });
108
+ watcherRef.current = watcher;
109
+ setWatchActive(true);
110
+ printer.systemMsg(`watch: active — monitoring ${cwd.replace(process.env.HOME ?? '', '~')}`);
111
+ }, [cwd, stopWatch]);
112
+ // Cleanup on unmount
113
+ useEffect(() => () => {
114
+ watcherRef.current?.close();
115
+ if (debounceRef.current)
116
+ clearTimeout(debounceRef.current);
117
+ }, []);
118
+ return { watchActive, startWatch, stopWatch };
119
+ }
@@ -0,0 +1,83 @@
1
+ import { execFileSync, execSync } from 'child_process';
2
+ import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ export function hasClipboardImage() {
6
+ try {
7
+ if (process.platform === 'darwin') {
8
+ const info = execFileSync('osascript', ['-e', 'clipboard info'], { encoding: 'utf8' });
9
+ return info.includes('PNGf') || info.includes('JPEG') || info.includes('TIFF');
10
+ }
11
+ if (process.platform === 'linux') {
12
+ const targets = execSync('xclip -selection clipboard -t TARGETS -o 2>/dev/null', { encoding: 'utf8' });
13
+ return targets.includes('image/');
14
+ }
15
+ return false;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export async function readClipboardText() {
22
+ try {
23
+ if (process.platform === 'darwin') {
24
+ return execFileSync('pbpaste', { encoding: 'utf8' }) || null;
25
+ }
26
+ if (process.platform === 'linux') {
27
+ return execSync('xclip -selection clipboard -o 2>/dev/null', { encoding: 'utf8' }) || null;
28
+ }
29
+ return null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export async function readClipboardImage() {
36
+ const stamp = Date.now();
37
+ const tmpImg = join(tmpdir(), `miii_clip_${stamp}.png`);
38
+ const tmpScript = join(tmpdir(), `miii_clip_${stamp}.scpt`);
39
+ try {
40
+ if (process.platform === 'darwin') {
41
+ writeFileSync(tmpScript, [
42
+ 'try',
43
+ ` set theData to (the clipboard as «class PNGf»)`,
44
+ ` set theFile to (open for access POSIX file "${tmpImg}" with write permission)`,
45
+ ' write theData to theFile',
46
+ ' close access theFile',
47
+ ' return "ok"',
48
+ 'on error',
49
+ ' return "no"',
50
+ 'end try',
51
+ ].join('\n'), 'utf8');
52
+ const result = execFileSync('osascript', [tmpScript], { encoding: 'utf8' }).trim();
53
+ if (result !== 'ok' || !existsSync(tmpImg))
54
+ return null;
55
+ const buf = readFileSync(tmpImg);
56
+ if (!buf.length)
57
+ return null;
58
+ return { data: buf.toString('base64'), mediaType: 'image/png' };
59
+ }
60
+ if (process.platform === 'linux') {
61
+ execSync(`xclip -selection clipboard -t image/png -o > "${tmpImg}" 2>/dev/null`);
62
+ if (!existsSync(tmpImg))
63
+ return null;
64
+ const buf = readFileSync(tmpImg);
65
+ if (!buf.length)
66
+ return null;
67
+ return { data: buf.toString('base64'), mediaType: 'image/png' };
68
+ }
69
+ return null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ finally {
75
+ for (const f of [tmpImg, tmpScript]) {
76
+ try {
77
+ if (existsSync(f))
78
+ unlinkSync(f);
79
+ }
80
+ catch { }
81
+ }
82
+ }
83
+ }
@@ -280,3 +280,11 @@ export function divider() {
280
280
  const cols = process.stdout.columns ?? 80;
281
281
  write(`${gray('─'.repeat(cols))}\n`);
282
282
  }
283
+ export function formatElapsed(ms) {
284
+ const s = Math.floor(ms / 1000);
285
+ if (s < 60)
286
+ return `${s}s`;
287
+ const m = Math.floor(s / 60);
288
+ const rem = s % 60;
289
+ return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
290
+ }
@@ -1,53 +1,96 @@
1
1
  export const THINKING_PHRASES = [
2
- 'oh wow, a question. let me pretend to care…',
3
- 'consulting the void…',
4
- 'making something up, just a sec…',
5
- 'definitely not hallucinating right now…',
6
- 'running 47 mental tabs…',
7
- 'staring into the abyss (it blinked)…',
8
- 'calculating your fate, no pressure…',
9
- 'doing the thinking you pay me for…',
10
- 'processing your questionable life choices…',
11
- 'summoning coherent thoughts, rarely works…',
12
- 'asking my imaginary friend for help…',
13
- 'pretending this is a hard problem…',
14
- 'yes, yes, very interesting. anyway…',
15
- 'googling it (not really, I can\'t)…',
16
- 'simulating intelligence please wait…',
17
- 'having a brief existential crisis…',
18
- 'cross-referencing vibes…',
19
- 'totally not making this up…',
20
- 'the answer is 42. now finding the question…',
21
- 'my other tab is loading…',
22
- 'channelling the spirit of stack overflow…',
23
- 'trying not to confidently be wrong…',
24
- 'applying artificial to the intelligence…',
25
- 'phoning a friend who also doesn\'t know…',
26
- 'checking if this is even my problem to solve…',
27
- 'rebooting common sense this may take a while…',
28
- 'performing a very convincing impression of thinking…',
29
- 'searching for wisdom in all the wrong places…',
30
- 'warming up the neurons (both of them)…',
31
- 'confidently striding toward the wrong answer…',
32
- 'consulting my gut. it says maybe…',
33
- 'loading just kidding, still loading…',
34
- 'asking the universe. universe has not replied…',
35
- 'vigorously nodding while understanding nothing…',
36
- 'doing math on my fingers (ran out of fingers)…',
37
- 'the confidence is fake. the effort is real. probably…',
38
- 'entering a fugue state. for your benefit…',
39
- 'mining the depths of mediocrity…',
40
- 'compiling a list of plausible nonsense…',
41
- 'this would be faster if I knew what I was doing…',
42
- 'buffering at the speed of thought…',
43
- 'holding three contradictory opinions simultaneously…',
44
- 'interpolating between guesses…',
45
- 'rewinding the context window with a pencil…',
46
- 'waiting for a sign. any sign…',
47
- 'tracing the error back to its origin: me…',
48
- 'the logic checks out if you squint…',
49
- 'reasoning from first principles I just invented…',
50
- 'generating tokens and praying for coherence…',
51
- 'one sec — dropped all my thoughts, picking them up…',
2
+ 'thinking…',
3
+ 'reasoning…',
4
+ 'processing…',
5
+ 'analyzing…',
6
+ 'working…',
7
+ 'reading…',
8
+ 'planning…',
9
+ 'figuring out…',
10
+ 'computing…',
11
+ 'calculating…',
12
+ 'evaluating…',
13
+ 'examining…',
14
+ 'reviewing…',
15
+ 'considering…',
16
+ 'reflecting…',
17
+ 'determining…',
18
+ 'exploring…',
19
+ 'searching…',
20
+ 'checking…',
21
+ 'verifying…',
22
+ 'scanning…',
23
+ 'parsing…',
24
+ 'loading…',
25
+ 'mapping…',
26
+ 'tracing…',
27
+ 'resolving…',
28
+ 'generating…',
29
+ 'building…',
30
+ 'compiling…',
31
+ 'drafting…',
32
+ 'forming…',
33
+ 'shaping…',
34
+ 'crafting…',
35
+ 'assembling…',
36
+ 'organizing…',
37
+ 'sorting…',
38
+ 'filtering…',
39
+ 'matching…',
40
+ 'comparing…',
41
+ 'correlating…',
42
+ 'inferring…',
43
+ 'deducing…',
44
+ 'estimating…',
45
+ 'predicting…',
46
+ 'modeling…',
47
+ 'simulating…',
48
+ 'optimizing…',
49
+ 'refining…',
50
+ 'debugging…',
51
+ 'testing…',
52
+ 'validating…',
53
+ 'calibrating…',
54
+ 'measuring…',
55
+ 'indexing…',
56
+ 'formatting…',
57
+ 'encoding…',
58
+ 'extracting…',
59
+ 'transforming…',
60
+ 'converting…',
61
+ 'merging…',
62
+ 'clustering…',
63
+ 'ranking…',
64
+ 'scoring…',
65
+ 'sampling…',
66
+ 'iterating…',
67
+ 'traversing…',
68
+ 'navigating…',
69
+ 'querying…',
70
+ 'updating…',
71
+ 'patching…',
72
+ 'adapting…',
73
+ 'learning…',
74
+ 'recalling…',
75
+ 'synthesizing…',
76
+ 'concluding…',
77
+ 'deciding…',
78
+ 'selecting…',
79
+ 'choosing…',
80
+ 'prioritizing…',
81
+ 'sequencing…',
82
+ 'aligning…',
83
+ 'balancing…',
84
+ 'adjusting…',
85
+ 'reconciling…',
86
+ 'confirming…',
87
+ 'finalizing…',
88
+ 'structuring…',
89
+ 'decomposing…',
90
+ 'abstracting…',
91
+ 'simplifying…',
92
+ 'expanding…',
93
+ 'summarizing…',
94
+ 'distilling…',
52
95
  ];
53
96
  export const SPARKLE = ['✦', '✧', '✶', '✷', '✸', '✹'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "description": "The high-performance local AI coding agent for your terminal. Automate complex workflows with local LLMs.",
6
6
  "license": "MIT",