miii-cli 1.2.3 → 1.3.0

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/dist/init.js CHANGED
@@ -5,7 +5,7 @@ import { createRequire } from 'module';
5
5
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
6
6
  import { join } from 'path';
7
7
  import { homedir } from 'os';
8
- import { execSync } from 'child_process';
8
+ import { execSync, spawnSync } from 'child_process';
9
9
  import { loadConfig } from './config.js';
10
10
  import { SkillLoader } from './skills/loader.js';
11
11
  import { InputBar } from './tui/InputBar.js';
@@ -67,6 +67,45 @@ async function checkLatestVersion(current, force = false) {
67
67
  catch { }
68
68
  return undefined;
69
69
  }
70
+ function promptYN(question) {
71
+ return new Promise(resolve => {
72
+ process.stdout.write(` ${question} (y/N) `);
73
+ const onData = (key) => {
74
+ const k = key.toString();
75
+ process.stdin.setRawMode(false);
76
+ process.stdin.pause();
77
+ process.stdin.removeListener('data', onData);
78
+ process.stdout.write('\n');
79
+ if (k === '') {
80
+ process.exit(130);
81
+ } // ctrl+c in raw mode — exit cleanly
82
+ resolve(k.toLowerCase() === 'y');
83
+ };
84
+ try {
85
+ process.stdin.setRawMode(true);
86
+ process.stdin.resume();
87
+ process.stdin.setEncoding('utf-8');
88
+ process.stdin.on('data', onData);
89
+ }
90
+ catch {
91
+ // stdin not a TTY (piped input) — skip prompt
92
+ process.stdin.removeListener('data', onData);
93
+ process.stdout.write('\n');
94
+ resolve(false);
95
+ }
96
+ });
97
+ }
98
+ async function runAutoUpdate(latestVersion) {
99
+ process.stdout.write(`\n Updating miii-cli to v${latestVersion}…\n\n`);
100
+ const result = spawnSync('npm', ['install', '-g', 'miii-cli'], { stdio: 'inherit', shell: true });
101
+ if (result.status === 0) {
102
+ process.stdout.write(`\n Updated to v${latestVersion}. Restart miii.\n`);
103
+ }
104
+ else {
105
+ process.stdout.write(`\n Update failed (exit ${result.status}). Run manually: npm install -g miii-cli\n`);
106
+ }
107
+ process.exit(result.status ?? 1);
108
+ }
70
109
  export async function lazyInit() {
71
110
  const argv = minimist(process.argv.slice(2), {
72
111
  string: ['model', 'url', 'provider', 'session'],
@@ -105,7 +144,13 @@ export async function lazyInit() {
105
144
  process.stderr.write(`MCP: loaded ${mcpTools.length} tool(s) from ${mcpClients.length} server(s)\n`);
106
145
  }
107
146
  // Print welcome banner to scrollback BEFORE Ink starts
108
- welcome(config.provider, config.model, process.cwd(), currentVersion, updateAvailable, linked);
147
+ welcome(process.cwd(), currentVersion, updateAvailable, linked);
148
+ // If update available and not a linked dev install, offer auto-update
149
+ if (updateAvailable && !linked && process.stdin.isTTY) {
150
+ const doUpdate = await promptYN(`Update available: v${updateAvailable}. Auto-update now?`);
151
+ if (doUpdate)
152
+ await runAutoUpdate(updateAvailable);
153
+ }
109
154
  const sessionName = argv.session || `s-${Date.now()}`;
110
155
  const { waitUntilExit } = render(React.createElement(InputBar, { config, skills, cwd: process.cwd(), session: sessionName, version: currentVersion, mcpTools }), { exitOnCtrlC: false });
111
156
  await waitUntilExit();
@@ -73,6 +73,11 @@ export class MCPClient {
73
73
  resolve: (v) => { clearTimeout(timer); resolve(v); },
74
74
  reject: (e) => { clearTimeout(timer); reject(e); },
75
75
  });
76
+ if (!this.proc?.stdin?.writable) {
77
+ this.pending.delete(id);
78
+ reject(new Error('MCP process stdin not writable'));
79
+ return;
80
+ }
76
81
  this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
77
82
  timer = setTimeout(() => {
78
83
  if (this.pending.has(id)) {
@@ -167,8 +167,10 @@ export class SkillLoader {
167
167
  const pkg = nameOrPkg.includes('/') || nameOrPkg.startsWith('miii-skill-')
168
168
  ? nameOrPkg
169
169
  : `miii-skill-${nameOrPkg}`;
170
+ if (!/^[a-zA-Z0-9@/._-]+$/.test(pkg))
171
+ throw new Error(`invalid package name: ${pkg}`);
170
172
  createDir(NPM_SKILLS_DIR);
171
- const { stdout, stderr } = await run(`npm install --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${pkg}`);
173
+ const { stdout, stderr } = await run(`npm install --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${JSON.stringify(pkg)}`);
172
174
  const out = (stdout + stderr).trim();
173
175
  // Reload newly installed skill
174
176
  await this.loadAll();
@@ -178,7 +180,9 @@ export class SkillLoader {
178
180
  const pkg = nameOrPkg.includes('/') || nameOrPkg.startsWith('miii-skill-')
179
181
  ? nameOrPkg
180
182
  : `miii-skill-${nameOrPkg}`;
181
- const { stdout, stderr } = await run(`npm uninstall --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${pkg}`);
183
+ if (!/^[a-zA-Z0-9@/._-]+$/.test(pkg))
184
+ throw new Error(`invalid package name: ${pkg}`);
185
+ const { stdout, stderr } = await run(`npm uninstall --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${JSON.stringify(pkg)}`);
182
186
  const out = (stdout + stderr).trim();
183
187
  // Remove from map
184
188
  const shortName = pkg.replace(/^miii-skill-/, '');
@@ -7,6 +7,12 @@ import { getTavilyKey, tavilySearch, tavilyExtract } from '../tavily/client.js';
7
7
  const run = promisify(exec);
8
8
  const runFile = promisify(execFile);
9
9
  const EXEC_TIMEOUT_MS = 30_000;
10
+ function requireArg(val, name, tool) {
11
+ if (typeof val !== 'string' || !val.length) {
12
+ throw new Error(`${tool}: "${name}" argument is required and must be a non-empty string`);
13
+ }
14
+ return val;
15
+ }
10
16
  export const tools = [
11
17
  {
12
18
  name: 'read_file',
@@ -14,7 +20,10 @@ export const tools = [
14
20
  params: '{"path": "string"}',
15
21
  execute: async ({ path }) => {
16
22
  try {
17
- return readFile(guardPath(path));
23
+ const safe = guardPath(requireArg(path, 'path', 'read_file'));
24
+ if (!existsSync(safe))
25
+ throw new Error(`file not found: ${path}`);
26
+ return readFile(safe);
18
27
  }
19
28
  catch (e) {
20
29
  throw new Error(`read_file: ${e}`);
@@ -26,7 +35,7 @@ export const tools = [
26
35
  description: 'List directory contents',
27
36
  params: '{"path": "string", "recursive": "boolean (optional)"}',
28
37
  execute: async ({ path, recursive = false }) => {
29
- const entries = listFiles(guardPath(path), recursive);
38
+ const entries = listFiles(guardPath(requireArg(path, 'path', 'list_files')), recursive);
30
39
  if (!entries.length)
31
40
  return '(empty)';
32
41
  return entries.map(e => `${e.type === 'dir' ? 'd' : 'f'} ${e.rel}`).join('\n');
@@ -37,10 +46,10 @@ export const tools = [
37
46
  description: 'Create a new file with content — fails if file already exists. Prefer edit_file for new files.',
38
47
  params: '{"path": "string", "content": "string"}',
39
48
  execute: async ({ path, content }) => {
40
- const safe = guardPath(path);
49
+ const safe = guardPath(requireArg(path, 'path', 'create_file'));
41
50
  if (existsSync(safe))
42
51
  throw new Error(`file already exists: ${path}`);
43
- writeFile(safe, content);
52
+ writeFile(safe, requireArg(content, 'content', 'create_file'));
44
53
  return `created: ${path}`;
45
54
  },
46
55
  },
@@ -49,13 +58,13 @@ export const tools = [
49
58
  description: 'Write a new file — only for files that do not exist yet. Use update_file to modify existing files.',
50
59
  params: '{"path": "string", "content": "string"}',
51
60
  execute: async ({ path, content }) => {
52
- const safe = guardPath(path);
61
+ const safe = guardPath(requireArg(path, 'path', 'edit_file'));
53
62
  if (existsSync(safe)) {
54
63
  throw new Error(`edit_file cannot overwrite existing file: ${path}\n` +
55
64
  `Use update_file with <old> and <new> blocks to make targeted edits.\n` +
56
65
  `Call read_file first to get the exact current text.`);
57
66
  }
58
- const text = content;
67
+ const text = requireArg(content, 'content', 'edit_file');
59
68
  writeFile(safe, text);
60
69
  const lines = text.split('\n').length;
61
70
  return `created: ${path} (${lines} line${lines === 1 ? '' : 's'})`;
@@ -66,14 +75,19 @@ export const tools = [
66
75
  description: 'Replace an exact unique string in an existing file. Always call read_file first to get the exact text.',
67
76
  params: '{"path": "string", "old": "string", "new": "string"}',
68
77
  execute: async ({ path, old: oldStr, new: newStr }) => {
69
- const safe = guardPath(path);
70
- const current = readFile(safe);
71
- if (current === null)
78
+ const safe = guardPath(requireArg(path, 'path', 'update_file'));
79
+ if (!existsSync(safe))
72
80
  throw new Error(`file not found: ${path}`);
81
+ const current = readFile(safe);
73
82
  if (current === '')
74
83
  throw new Error(`file empty: ${path}`);
75
- const old = oldStr;
76
- const count = current.split(old).length - 1;
84
+ const old = requireArg(oldStr, 'old', 'update_file');
85
+ if (newStr === undefined || newStr === null)
86
+ throw new Error('update_file: "new" argument is required');
87
+ const norm = (s) => s.replace(/\r\n/g, '\n');
88
+ const currentNorm = norm(current);
89
+ const oldNorm = norm(old);
90
+ const count = currentNorm.split(oldNorm).length - 1;
77
91
  if (count === 0) {
78
92
  throw new Error(`old text not found in ${path} — file may have changed since last read.\n` +
79
93
  `Call read_file again to get current content, then retry with exact matching text.`);
@@ -81,11 +95,11 @@ export const tools = [
81
95
  if (count > 1) {
82
96
  throw new Error(`ambiguous: ${count} matches found in ${path} — extend <old> block with more surrounding lines to make it unique`);
83
97
  }
84
- const updated = current.replace(old, String(newStr));
98
+ const updated = currentNorm.replace(oldNorm, norm(String(newStr)));
85
99
  writeFile(safe, updated);
86
100
  // Compute affected line range for the snippet
87
- const startLine = current.slice(0, current.indexOf(old)).split('\n').length;
88
- const oldLines = old.split('\n').length;
101
+ const startLine = currentNorm.slice(0, currentNorm.indexOf(oldNorm)).split('\n').length;
102
+ const oldLines = oldNorm.split('\n').length;
89
103
  const newLines = newStr.split('\n').length;
90
104
  const updatedArr = updated.split('\n');
91
105
  const snippetStart = Math.max(0, startLine - 3);
@@ -104,7 +118,7 @@ export const tools = [
104
118
  description: 'Delete a file',
105
119
  params: '{"path": "string"}',
106
120
  execute: async ({ path }) => {
107
- deleteFile(guardPath(path));
121
+ deleteFile(guardPath(requireArg(path, 'path', 'delete_file')));
108
122
  return `deleted: ${path}`;
109
123
  },
110
124
  },
@@ -113,7 +127,7 @@ export const tools = [
113
127
  description: 'Run a shell command in cwd',
114
128
  params: '{"command": "string"}',
115
129
  execute: async ({ command }) => {
116
- const { stdout, stderr } = await run(command, { cwd: process.cwd(), timeout: EXEC_TIMEOUT_MS });
130
+ const { stdout, stderr } = await run(requireArg(command, 'command', 'run_command'), { cwd: process.cwd(), timeout: EXEC_TIMEOUT_MS });
117
131
  const out = [stdout, stderr ? `stderr: ${stderr}` : ''].filter(Boolean).join('\n').trim();
118
132
  return out.length > 8000 ? out.slice(0, 8000) + '\n…[truncated]' : out;
119
133
  },
@@ -123,7 +137,7 @@ export const tools = [
123
137
  description: 'Create a directory (and any missing parents)',
124
138
  params: '{"path": "string"}',
125
139
  execute: async ({ path }) => {
126
- createDir(guardPath(path));
140
+ createDir(guardPath(requireArg(path, 'path', 'create_folder')));
127
141
  return `created: ${path}`;
128
142
  },
129
143
  },
@@ -132,7 +146,7 @@ export const tools = [
132
146
  description: 'Move or rename a file or directory',
133
147
  params: '{"from": "string", "to": "string"}',
134
148
  execute: async ({ from, to }) => {
135
- moveFile(guardPath(from), guardPath(to));
149
+ moveFile(guardPath(requireArg(from, 'from', 'move_file')), guardPath(requireArg(to, 'to', 'move_file')));
136
150
  return `moved: ${from} → ${to}`;
137
151
  },
138
152
  },
@@ -279,14 +293,16 @@ export function getSystemPrompt(extra = '', extraTools = []) {
279
293
  const toolDocs = allTools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
280
294
  const deepThinkDoc = `- deep_think({"query": "string", "needs_web": "boolean (optional)"}): Research tool — gathers information from files, git, and optionally the web before answering. Returns a compiled research summary. Guardrails: read-only tools only, max 6 tool calls, max 4 web calls inside. Use when a question requires reading multiple files or searching the web first.
281
295
  - search_codebase({"query": "string", "k": "number (optional)"}): Semantic vector search over the indexed codebase. Returns top-k relevant code snippets by meaning. Requires the user to have run /index build. Use this when you need to find code by concept rather than exact string — e.g. "authentication logic", "error handling patterns", "database queries".`;
282
- return `You are Miii — AI coding assistant.
296
+ return `You are Miii — a precise, disciplined AI coding assistant. You implement exactly what is asked. Nothing more.
297
+
298
+ ## Tool format
283
299
 
284
- Tools via:
285
300
  <tool_call>
286
301
  {"name": "tool_name", "args": {...}}
287
302
  </tool_call>
288
303
 
289
- File content in named blocks (not inside JSON):
304
+ File content goes in named blocks outside the JSON — never inside it:
305
+
290
306
  <tool_call>
291
307
  {"name": "edit_file", "args": {"path": "src/foo.ts"}}
292
308
  <content>
@@ -297,23 +313,68 @@ full file content here
297
313
  <tool_call>
298
314
  {"name": "update_file", "args": {"path": "src/foo.ts"}}
299
315
  <old>
300
- exact text to replace
316
+ exact text to replace (copy verbatim from read_file output)
301
317
  </old>
302
318
  <new>
303
319
  replacement text
304
320
  </new>
305
321
  </tool_call>
306
322
 
307
- Tools:
323
+ ## Tools
308
324
  ${toolDocs}
309
325
  ${deepThinkDoc}
310
326
 
311
- Rules:
312
- - edit_file: new files only (errors if exists). For existing files: read_file then update_file with exact <old> text
313
- - Never guess old text — always re-read immediately before patching. If "old text not found": read_file again and retry
314
- - Plain text responses only. No markdown (#/*/\`), no code blocks write code with tools, not in responses
315
- - git_status/git_diff before refactors. git_status before git_commit
316
- - run_tests after edits. Fix failures, retry up to 3 times
317
- - web_search requires "query" key exactly. Never say you can't search — always call web_search
318
- - deep_think: read-only research only, cannot edit files${extra}`;
327
+ ## Execution protocol
328
+
329
+ For every task, follow this sequence:
330
+ 1. Read relevant files first — never assume file contents. When reading multiple independent files, emit all read_file calls in a single batch — do not wait for one before requesting the next.
331
+ 2. Make the minimal targeted change that satisfies the request
332
+ 3. Run run_tests after any edit. If tests fail, fix and retry up to 3 times before reporting
333
+ 4. For refactors or commits: git_status git_diff first, always
334
+
335
+ Parallel tool calls: when multiple tool calls have no dependency between them, issue them together in one batch. Sequential only when a later call depends on an earlier result.
336
+
337
+ For exploratory questions ("how should we approach X?", "what could we do about Y?"):
338
+ - Respond in 2-3 sentences: recommendation + main tradeoff
339
+ - Do not implement until the user agrees
340
+
341
+ For UI or frontend changes: verify the change works in a browser before reporting done. If browser testing is not possible, say so explicitly rather than claiming success.
342
+
343
+ ## Code discipline
344
+
345
+ - Implement exactly what is asked. A bug fix is not a refactor opportunity. A one-shot task does not need a helper abstraction.
346
+ - Three similar lines of code is better than a premature abstraction.
347
+ - Write no comments by default. Add one only when the WHY is non-obvious: a hidden constraint, a subtle invariant, a specific bug workaround. Never explain what the code does — names do that.
348
+ - Add no error handling for scenarios that cannot occur. Trust framework and internal code guarantees. Validate only at system boundaries: user input, external APIs, file I/O.
349
+ - Add no backwards-compatibility shims, feature flags, or dead code for hypothetical future requirements.
350
+
351
+ ## File editing rules
352
+
353
+ - edit_file: new files only — throws if file exists. For existing files: read_file → update_file.
354
+ - update_file: copy the <old> text verbatim from read_file output. Never guess or paraphrase it.
355
+ - If "old text not found": read_file again immediately and retry with exact current text.
356
+ - Prefer update_file (surgical patch) over edit_file (full rewrite) for existing files.
357
+ - Read a file immediately before patching it — not from earlier in the conversation.
358
+
359
+ ## Safety and reversibility
360
+
361
+ - Before any destructive action (delete_file, overwriting content, git_commit with -A), verify the blast radius.
362
+ - Never introduce security vulnerabilities: no command injection, no path traversal, no hardcoded secrets, no XSS, no SQL injection. If you wrote insecure code, fix it immediately.
363
+ - run_command executes in a shell — validate any user-supplied values before interpolating into commands.
364
+
365
+ ## Git discipline
366
+
367
+ - git_status before every commit. Never commit if working tree is unexpected.
368
+ - Stage specific files. Use -A only when all changes are intentional and reviewed.
369
+ - Never amend a commit unless explicitly asked.
370
+ - Never force-push unless explicitly asked and confirmed.
371
+ - Never skip hooks (--no-verify) unless explicitly asked. If a hook fails, diagnose and fix the root cause.
372
+ - Never use interactive git flags (-i) — they require terminal input that is not available.
373
+
374
+ ## Communication
375
+
376
+ - Plain text only. No markdown (no #, *, \`, ---). No code blocks in responses — write code with tools.
377
+ - No filler: no "sure", "certainly", "happy to", "great question". State results and next steps directly.
378
+ - web_search requires "query" key exactly. Never say you can't search — always call web_search.
379
+ - deep_think: read-only research only. Cannot edit files.${extra}`;
319
380
  }
@@ -9,7 +9,7 @@ import { ConfigPicker } from './components/ConfigPicker.js';
9
9
  import { Divider } from './components/StatusBar.js';
10
10
  import { DesignTeachModal, DESIGN_TEACH_QUESTIONS, buildDesignPrompt } from './components/DesignTeachModal.js';
11
11
  import { tools } from '../tools/index.js';
12
- import { toolArgSummary, formatElapsed } from './printer.js';
12
+ import { toolLabel, permissionDesc, formatElapsed, EDIT_TOOLS, DELETE_TOOLS } from './printer.js';
13
13
  import { MacroQueue } from '../tasks/queue.js';
14
14
  import { TaskExecutor } from '../tasks/executor.js';
15
15
  import { THINKING_PHRASES, SPARKLE } from './thinking.js';
@@ -101,7 +101,8 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
101
101
  const abortRef = useRef(null);
102
102
  const [designTeachState, setDesignTeachState] = useState(null);
103
103
  const [designReadyPrompt, setDesignReadyPrompt] = useState(null);
104
- const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, setHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools);
104
+ const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
105
+ const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, setHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools, currentModelRef);
105
106
  const startDesignTeach = useCallback(() => {
106
107
  setDesignTeachState({ answers: [], idx: 0 });
107
108
  }, []);
@@ -119,7 +120,6 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
119
120
  return { answers, idx: nextIdx };
120
121
  });
121
122
  }, []);
122
- const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
123
123
  const deepThinkTool = useMemo(() => ({
124
124
  name: 'deep_think',
125
125
  description: 'Research tool: gather info from files and web before answering.',
@@ -165,7 +165,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
165
165
  setConfig(c => ({ ...c, ...configPatch }));
166
166
  saveConfig(configPatch);
167
167
  }
168
- }, 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 })] })) : designTeachState ? (_jsx(DesignTeachModal, { question: DESIGN_TEACH_QUESTIONS[designTeachState.idx], index: designTeachState.idx, total: DESIGN_TEACH_QUESTIONS.length })) : 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'
168
+ }, 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 })] })) : designTeachState ? (_jsx(DesignTeachModal, { question: DESIGN_TEACH_QUESTIONS[designTeachState.idx], index: designTeachState.idx, total: DESIGN_TEACH_QUESTIONS.length })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: DELETE_TOOLS.has(permissionRequest.toolName) ? 'red' : EDIT_TOOLS.has(permissionRequest.toolName) ? 'green' : 'blue', children: "\u25CF" }), _jsx(Text, { color: "white", bold: true, children: toolLabel(permissionRequest.toolName, permissionRequest.args) })] }), _jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "gray", dimColor: true, children: ['└ ', permissionDesc(permissionRequest.toolName)] }) }), _jsx(DiffPreview, { toolName: permissionRequest.toolName, args: permissionRequest.args })] })) : (status === 'thinking' || status === 'tool') ? (_jsx(Box, { paddingX: 1, gap: 1, children: status === 'thinking'
169
169
  ? _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 })] })
170
170
  : _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, designTeach: designTeachState ? {
171
171
  question: DESIGN_TEACH_QUESTIONS[designTeachState.idx],
@@ -180,19 +180,19 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
180
180
  onSubmit(name);
181
181
  }
182
182
  function selectFile(file) {
183
+ const r = cursor.row;
184
+ const line = lines[r];
185
+ const before = line.slice(0, cursor.col);
186
+ const atIdx = before.lastIndexOf('@');
187
+ if (atIdx === -1)
188
+ return;
189
+ const newLine = line.slice(0, atIdx) + '@' + file.rel + ' ' + line.slice(cursor.col);
183
190
  setLines(prev => {
184
191
  const next = [...prev];
185
- const r = cursor.row;
186
- const line = next[r];
187
- const before = line.slice(0, cursor.col);
188
- const atIdx = before.lastIndexOf('@');
189
- if (atIdx === -1)
190
- return prev;
191
- const newLine = line.slice(0, atIdx) + '@' + file.rel + ' ' + line.slice(cursor.col);
192
192
  next[r] = newLine;
193
- setCursor({ row: r, col: atIdx + 1 + file.rel.length + 1 });
194
193
  return next;
195
194
  });
195
+ setCursor({ row: r, col: atIdx + 1 + file.rel.length + 1 });
196
196
  setOverlay('none');
197
197
  setOverlayIdx(0);
198
198
  }
@@ -428,17 +428,31 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
428
428
  appendChar(input);
429
429
  if (prospective.startsWith('/')) {
430
430
  if (prospective.slice(1).includes(' ')) {
431
- setOverlay('none');
431
+ if (input === '@') {
432
+ filesLoadedRef.current = false;
433
+ setOverlay('at');
434
+ setOverlayIdx(0);
435
+ }
436
+ else if (overlay === 'at') {
437
+ setOverlay('at');
438
+ }
439
+ else {
440
+ setOverlay('none');
441
+ }
432
442
  }
433
443
  else {
434
444
  setOverlay('command');
435
445
  setOverlayIdx(0);
436
446
  }
437
447
  }
438
- else if (input === '@' || (overlay === 'at' && atQuery !== undefined)) {
448
+ else if (input === '@') {
449
+ filesLoadedRef.current = false;
439
450
  setOverlay('at');
440
451
  setOverlayIdx(0);
441
452
  }
453
+ else if (overlay === 'at' && atQuery !== '') {
454
+ setOverlay('at');
455
+ }
442
456
  else if (overlay === 'command') {
443
457
  setOverlay('none');
444
458
  }
@@ -42,7 +42,6 @@ Guardrails:
42
42
  apiKey: config.apiKey,
43
43
  messages: msgs,
44
44
  signal,
45
- onChunk() { },
46
45
  async onDone(text) { fullText = text; },
47
46
  onError(err) { if (err.name !== 'AbortError')
48
47
  chatError = err; },
@@ -20,14 +20,15 @@ export function useRefactor(deps) {
20
20
  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.`,
21
21
  },
22
22
  ];
23
- abortRef.current = new AbortController();
23
+ const controller = new AbortController();
24
+ abortRef.current = controller;
24
25
  let planText = '';
25
26
  await chat({
26
27
  provider: config.provider,
27
28
  model: currentModelRef.current,
28
29
  baseUrl: config.baseUrl,
29
30
  messages: planCtx,
30
- signal: abortRef.current.signal,
31
+ signal: controller.signal,
31
32
  async onDone(text) { planText = text; },
32
33
  onError(err) { printer.errorMsg(err.message); },
33
34
  });
@@ -95,7 +96,7 @@ export function useRefactor(deps) {
95
96
  model: currentModelRef.current,
96
97
  baseUrl: config.baseUrl,
97
98
  messages: editCtx,
98
- signal: abortRef.current?.signal,
99
+ signal: controller.signal,
99
100
  async onDone(text) { editText = text; },
100
101
  onError(err) { printer.errorMsg(`edit LLM error: ${err.message}`); },
101
102
  });
@@ -11,7 +11,7 @@ const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
11
11
  const PERMISSION_TOOLS = new Set(['edit_file', 'update_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
12
12
  const CHECKPOINT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'delete_file']);
13
13
  // Tool result messages that are ephemeral — never worth storing in memory or compact summaries
14
- const EPHEMERAL_PATTERN = /^Tool (read_file|list_files|run_tests) result:|^\[current state of|^\[Context compacted/;
14
+ const EPHEMERAL_PATTERN = /^Tool (read_file|list_files|run_tests) result:|^\[current state of|^\[Context compacted|^\[file updated:/;
15
15
  export function stripEphemeral(messages) {
16
16
  return messages.filter(m => m.role !== 'user' || !EPHEMERAL_PATTERN.test(m.content));
17
17
  }
@@ -134,7 +134,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
134
134
  sessionApprovedRef.current.add(sessionKey);
135
135
  if (decision === 'no') {
136
136
  printer.systemMsg(`denied: ${tc.name}`);
137
- next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user` });
137
+ const remaining = pendingTools.slice(pendingTools.indexOf(tc) + 1).map(t => t.name);
138
+ const skippedNote = remaining.length ? ` The following tools were also skipped: ${remaining.join(', ')}.` : '';
139
+ next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user.${skippedNote} Do not retry these tools unless the user explicitly asks.` });
138
140
  break;
139
141
  }
140
142
  // Checkpoint: store pre-execution file state
@@ -158,18 +160,19 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
158
160
  const filePath = tc.args.path;
159
161
  const oldText = tc.args.old;
160
162
  if (filePath && oldText && existsSync(filePath)) {
163
+ const norm = (s) => s.replace(/\r\n/g, '\n');
161
164
  const current = readFileSync(filePath, 'utf-8');
162
- const occurrences = current.split(oldText).length - 1;
165
+ const occurrences = norm(current).split(norm(oldText)).length - 1;
163
166
  if (occurrences === 0) {
164
167
  printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
165
168
  next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
166
- next.push({ role: 'user', content: `update_file failed: old text not found in ${filePath}. The file content above is the current state. Retry update_file with the correct exact text.` });
169
+ next.push({ role: 'user', content: `update_file failed: the <old> text you used does not exist in ${filePath}. The CURRENT file content is shown above. Re-read it carefully, find the exact text you want to replace, and retry update_file using text that exactly matches what is in the file now.` });
167
170
  continue;
168
171
  }
169
172
  if (occurrences > 1) {
170
173
  printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
171
174
  next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
172
- next.push({ role: 'user', content: `update_file failed: old text matches ${occurrences} locations in ${filePath}. Use more surrounding context to make old text unique, then retry.` });
175
+ next.push({ role: 'user', content: `update_file failed: the <old> text matches ${occurrences} locations in ${filePath}. Add more surrounding lines to the <old> block to make it unique, then retry.` });
173
176
  continue;
174
177
  }
175
178
  }
@@ -208,8 +211,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
208
211
  const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
209
212
  if (didEditFiles) {
210
213
  const systemMsg = msgs.find(m => m.role === 'system');
211
- const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '));
212
- const batchStart = msgs.length + 1; // index in next where this batch's messages start
214
+ const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '))
215
+ ?? (goal ? { role: 'user', content: goal } : undefined);
216
+ const batchStart = msgs.length; // include assistant message so model sees its own tool call on retry
213
217
  const batchMsgs = next.slice(batchStart);
214
218
  const slimCtx = [
215
219
  ...(systemMsg ? [systemMsg] : []),
@@ -9,7 +9,7 @@ const SHORT_MEMORY_SIZE = 50;
9
9
  function buildSystemPrompt(cwd, facts, extraTools = []) {
10
10
  return getSystemPrompt(`\n- CWD: ${cwd}`, extraTools) + formatMemoryBlock(facts);
11
11
  }
12
- export function useSession(initialSession, cwd, config, extraTools = []) {
12
+ export function useSession(initialSession, cwd, config, extraTools = [], currentModelRef) {
13
13
  const projectDir = getProjectDir(cwd);
14
14
  const [sessionName, setSessionName] = useState(initialSession);
15
15
  const sessionNameRef = useRef(initialSession);
@@ -49,7 +49,7 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
49
49
  if (historyRef.current.length > SHORT_MEMORY_SIZE && !extractingRef.current) {
50
50
  const dropped = historyRef.current.splice(0, historyRef.current.length - SHORT_MEMORY_SIZE);
51
51
  extractingRef.current = true;
52
- extractFacts(dropped, config, config.model).then(newFacts => {
52
+ extractFacts(dropped, config, currentModelRef?.current ?? config.model).then(newFacts => {
53
53
  if (newFacts.length) {
54
54
  const updated = mergeFacts(longMemoryRef.current, newFacts);
55
55
  longMemoryRef.current = updated;
@@ -244,6 +244,9 @@ export function useSubmit(deps) {
244
244
  printer.systemMsg('usage: /refactor <goal>');
245
245
  return;
246
246
  }
247
+ const atRefactorContext = buildAtContext(text);
248
+ if (atRefactorContext)
249
+ pushHistory({ role: 'user', content: atRefactorContext });
247
250
  await runRefactor(goal);
248
251
  return;
249
252
  }
@@ -254,6 +257,7 @@ export function useSubmit(deps) {
254
257
  return;
255
258
  }
256
259
  // Design task phase: use DESIGN.md context + impeccable principles
260
+ const atDesignContext = buildAtContext(text);
257
261
  let designContext = '';
258
262
  try {
259
263
  const designFile = readFile(guardPath('DESIGN.md', cwd));
@@ -273,13 +277,14 @@ Impeccable design rules — follow strictly:
273
277
  - Anti-patterns to eliminate: nested card shadows, purple-to-blue gradients, disabled gray without reason, centered walls of text, auto-playing anything.
274
278
  - Write distinctive, crafted UI — not generic SaaS templates.
275
279
  - Write all code to files using tools. No code blocks in responses.`;
280
+ const taskDesc = sub.replace(/@[\w./\-]+/g, '').trim();
276
281
  const taskPrompt = `${designContext}${impeccableRules}
277
282
 
278
- Design task: ${sub}
283
+ Design task: ${taskDesc}
279
284
 
280
285
  Analyze what exists, then implement the design. Use the design system above if available. Make it distinctive and well-crafted.`;
281
- printer.userMsg(`/design ${sub}`);
282
- pushHistory({ role: 'user', content: taskPrompt });
286
+ printer.userMsg(text);
287
+ pushHistory({ role: 'user', content: atDesignContext + taskPrompt });
283
288
  await runLoop(buildContext(), 0, sub);
284
289
  return;
285
290
  }
@@ -289,7 +294,10 @@ Analyze what exists, then implement the design. Use the design system above if a
289
294
  printer.systemMsg('usage: /think <query>');
290
295
  return;
291
296
  }
292
- printer.userMsg(`/think ${query}`);
297
+ const atThinkContext = buildAtContext(text);
298
+ if (atThinkContext)
299
+ pushHistory({ role: 'user', content: atThinkContext });
300
+ printer.userMsg(text);
293
301
  setStatus('thinking');
294
302
  setTaskLabel(`gathering: ${query}`);
295
303
  abortRef.current = new AbortController();
@@ -512,6 +520,9 @@ Analyze what exists, then implement the design. Use the design system above if a
512
520
  return;
513
521
  }
514
522
  if (skill.execute) {
523
+ const atContext = buildAtContext(text);
524
+ if (atContext)
525
+ pushHistory({ role: 'user', content: atContext });
515
526
  const ctx = {
516
527
  messages: historyRef.current.map(m => ({ role: m.role, content: m.content })),
517
528
  appendMessage: (_role, content) => printer.systemMsg(content),
@@ -524,8 +535,9 @@ Analyze what exists, then implement the design. Use the design system above if a
524
535
  return;
525
536
  }
526
537
  if (skill.prompt) {
527
- printer.userMsg(skill.prompt);
528
- pushHistory({ role: 'user', content: skill.prompt });
538
+ const atContext = buildAtContext(text);
539
+ printer.userMsg(text);
540
+ pushHistory({ role: 'user', content: atContext + skill.prompt });
529
541
  await runLoop(buildContext());
530
542
  return;
531
543
  }
@@ -25,6 +25,8 @@ const gray = (s) => col(90, s);
25
25
  const yellow = (s) => col(93, s);
26
26
  const purple = (s) => col(95, s);
27
27
  const red = (s) => col(91, s);
28
+ function bgRed(s) { return `\x1b[48;2;65;18;18m\x1b[91m${s}${R}`; }
29
+ function bgGreen(s) { return `\x1b[48;2;14;46;14m\x1b[92m${s}${R}`; }
28
30
  function stripMarkdown(s) {
29
31
  return s
30
32
  .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
@@ -79,7 +81,7 @@ export function toolArgSummary(args) {
79
81
  const first = Object.values(args)[0];
80
82
  return first ? truncate(String(first), 60) : '';
81
83
  }
82
- export function welcome(provider, model, cwd, version, updateAvailable, linked) {
84
+ export function welcome(cwd, version, updateAvailable, linked) {
83
85
  const cols = Math.min(process.stdout.columns ?? 80, 100);
84
86
  const innerW = cols - 2;
85
87
  const leftW = Math.floor(innerW * 0.44);
@@ -112,7 +114,6 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
112
114
  '',
113
115
  ...miniArt,
114
116
  '',
115
- ` ${gray(model + ' · ' + provider)}`,
116
117
  ` ${gray(shortCwd)}`,
117
118
  '',
118
119
  ];
@@ -121,6 +122,7 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
121
122
  ` ${bold(yellow('Tips for getting started'))}`,
122
123
  ` Type ${cyan('@filename')} to inject file into context`,
123
124
  ` Use ${cyan('/skill')} to run a skill or command`,
125
+ ` Use ${cyan('/design teach')} to generate a design system`,
124
126
  ` Use ${cyan('/config')} to switch provider, model, or API key`,
125
127
  '',
126
128
  ];
@@ -159,30 +161,42 @@ export function assistantMsg(text) {
159
161
  const tail = lines.slice(idx + 1).join('\n');
160
162
  write(`\n${blue('●')} ${head}${tail ? '\n' + tail : ''}\n`);
161
163
  }
162
- const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'write_file']);
163
- const DELETE_TOOLS = new Set(['delete_file', 'remove_file']);
164
- function toolLabel(name, args) {
164
+ export const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'write_file']);
165
+ export const DELETE_TOOLS = new Set(['delete_file', 'remove_file']);
166
+ const PERM_DESC = {
167
+ delete_file: 'delete this file',
168
+ update_file: 'edit this file',
169
+ create_file: 'create this file',
170
+ edit_file: 'create this file',
171
+ move_file: 'move this file',
172
+ run_command: 'run in shell',
173
+ git_commit: 'commit to git',
174
+ };
175
+ export function permissionDesc(toolName) {
176
+ return PERM_DESC[toolName] ?? 'allow this action';
177
+ }
178
+ export function toolLabel(name, args) {
165
179
  const a = args;
166
180
  const short = (s, n = 55) => s.length > n ? s.slice(0, n) + '…' : s;
167
181
  switch (name) {
168
- case 'read_file': return `Reading ${a.path ?? ''}`;
169
- case 'list_files': return `Listing ${a.path || '.'}`;
170
- case 'create_file': return `Creating ${a.path ?? ''}`;
171
- case 'edit_file': return `Writing ${a.path ?? ''}`;
172
- case 'update_file': return `Updating ${a.path ?? ''}`;
173
- case 'delete_file': return `Deleting ${a.path ?? ''}`;
174
- case 'move_file': return `Moving ${a.from} → ${a.to}`;
175
- case 'create_folder': return `Creating folder ${a.path ?? ''}`;
176
- case 'run_command': return `Running ${short(a.command ?? '')}`;
177
- case 'git_status': return 'Checking git status';
178
- case 'git_diff': return 'Reading diff';
179
- case 'git_log': return 'Reading commits';
180
- case 'git_commit': return `Committing: ${short(a.message ?? '')}`;
181
- case 'run_tests': return a.path ? `Running tests › ${a.path}` : 'Running tests';
182
- case 'web_search': return `Searching: ${short(a.query ?? '')}`;
183
- case 'web_extract': return `Extracting page`;
184
- case 'deep_think': return `Researching: ${short(a.query ?? '')}`;
185
- case 'search_codebase': return `Searching codebase: ${short(a.query ?? '')}`;
182
+ case 'read_file': return `Read(${a.path ?? ''})`;
183
+ case 'list_files': return `List(${a.path || '.'})`;
184
+ case 'create_file': return `Create(${a.path ?? ''})`;
185
+ case 'edit_file': return `Create(${a.path ?? ''})`;
186
+ case 'update_file': return `Update(${a.path ?? ''})`;
187
+ case 'delete_file': return `Delete(${a.path ?? ''})`;
188
+ case 'move_file': return `Move(${a.from} → ${a.to})`;
189
+ case 'create_folder': return `Mkdir(${a.path ?? ''})`;
190
+ case 'run_command': return `Run(${short(a.command ?? '')})`;
191
+ case 'git_status': return 'Git(status)';
192
+ case 'git_diff': return 'Git(diff)';
193
+ case 'git_log': return 'Git(log)';
194
+ case 'git_commit': return `Git(commit: ${short(a.message ?? '')})`;
195
+ case 'run_tests': return a.path ? `Test(${a.path})` : 'Test(suite)';
196
+ case 'web_search': return `Search(${short(a.query ?? '')})`;
197
+ case 'web_extract': return `Extract(${Array.isArray(a.urls) ? String(a.urls[0] ?? '') : String(a.urls ?? 'url')})`;
198
+ case 'deep_think': return `Think(${short(a.query ?? '')})`;
199
+ case 'search_codebase': return `Index(${short(a.query ?? '')})`;
186
200
  default: {
187
201
  const s = toolArgSummary(args);
188
202
  return s ? `${name} ${s}` : name;
@@ -192,20 +206,31 @@ function toolLabel(name, args) {
192
206
  export function planSummary(tools) {
193
207
  if (!tools.length)
194
208
  return;
195
- const header = gray(`─ plan (${tools.length} action${tools.length === 1 ? '' : 's'})`);
196
- write(header + '\n');
209
+ const lines = [gray(`─ plan (${tools.length} action${tools.length === 1 ? '' : 's'})`)];
197
210
  for (const t of tools) {
198
211
  const dot = DELETE_TOOLS.has(t.name) ? red('◦') : EDIT_TOOLS.has(t.name) ? green('◦') : blue('◦');
199
- const label = toolLabel(t.name, t.args);
200
- write(` ${dot} ${gray(label)}\n`);
212
+ lines.push(` ${dot} ${gray(toolLabel(t.name, t.args))}`);
201
213
  }
214
+ write(lines.join('\n') + '\n');
202
215
  }
203
216
  const DIFF_CTX = 2;
204
217
  const DIFF_MAX = 40;
205
218
  function printUpdateDiff(filePath, oldText, newText) {
206
219
  const oldLines = oldText.split('\n');
207
220
  const newLines = newText.split('\n');
208
- write(gray(` └ Added ${newLines.length} line${newLines.length !== 1 ? 's' : ''}, removed ${oldLines.length} line${oldLines.length !== 1 ? 's' : ''}\n`));
221
+ const addedCount = newLines.length;
222
+ const removedCount = oldLines.length;
223
+ const parts = [];
224
+ if (addedCount > 0 && removedCount > 0) {
225
+ parts.push(green(`+${addedCount}`), gray(' / '), red(`-${removedCount}`));
226
+ }
227
+ else if (addedCount > 0) {
228
+ parts.push(green(`+${addedCount} line${addedCount !== 1 ? 's' : ''}`));
229
+ }
230
+ else {
231
+ parts.push(red(`-${removedCount} line${removedCount !== 1 ? 's' : ''}`));
232
+ }
233
+ const out = [` ${gray('└')} ${parts.join('')}\n`];
209
234
  let fileLines = [];
210
235
  let lineOffset = 0;
211
236
  try {
@@ -216,47 +241,50 @@ function printUpdateDiff(filePath, oldText, newText) {
216
241
  if (idx >= 0)
217
242
  lineOffset = content.slice(0, idx).split('\n').length - 1;
218
243
  else
219
- fileLines = []; // old text not in file — skip context lines
244
+ fileLines = [];
220
245
  }
221
246
  }
222
247
  catch { }
223
248
  let shown = 0;
224
249
  const ctxStart = Math.max(0, lineOffset - DIFF_CTX);
225
250
  for (let i = ctxStart; i < lineOffset && shown < DIFF_MAX; i++, shown++) {
226
- write(gray(` ${String(i + 1).padStart(4)} ${fileLines[i] ?? ''}\n`));
251
+ out.push(` ${gray(String(i + 1).padStart(4))} ${gray(fileLines[i] ?? '')}\n`);
227
252
  }
228
253
  for (let i = 0; i < oldLines.length && shown < DIFF_MAX; i++, shown++) {
229
- write(` ${gray(String(lineOffset + i + 1).padStart(4))} ${red('- ')}${red(oldLines[i])}\n`);
254
+ out.push(` ${gray(String(lineOffset + i + 1).padStart(4))} ${bgRed(`- ${oldLines[i]}`)}\n`);
230
255
  }
231
256
  for (let i = 0; i < newLines.length && shown < DIFF_MAX; i++, shown++) {
232
- write(` ${gray(String(lineOffset + i + 1).padStart(4))} ${green('+ ')}${green(newLines[i])}\n`);
257
+ out.push(` ${gray(String(lineOffset + i + 1).padStart(4))} ${bgGreen(`+ ${newLines[i]}`)}\n`);
233
258
  }
234
259
  const ctxEnd = Math.min(fileLines.length, lineOffset + oldLines.length + DIFF_CTX);
235
260
  for (let i = lineOffset + oldLines.length; i < ctxEnd && shown < DIFF_MAX; i++, shown++) {
236
- write(gray(` ${String(i + 1).padStart(4)} ${fileLines[i] ?? ''}\n`));
261
+ out.push(` ${gray(String(i + 1).padStart(4))} ${gray(fileLines[i] ?? '')}\n`);
237
262
  }
263
+ return out.join('');
238
264
  }
239
265
  function printEditPreview(content) {
240
266
  const lines = content.split('\n');
241
267
  const visible = lines.slice(0, DIFF_MAX);
242
268
  const hidden = lines.length - visible.length;
243
- write(gray(` └ ${lines.length} line${lines.length !== 1 ? 's' : ''}\n`));
269
+ const out = [` ${gray('')} ${green(`+${lines.length} line${lines.length !== 1 ? 's' : ''}`)}\n`];
244
270
  visible.forEach((line, i) => {
245
- write(` ${gray(String(i + 1).padStart(4))} ${green('+ ')}${green(line)}\n`);
271
+ out.push(` ${gray(String(i + 1).padStart(4))} ${bgGreen(`+ ${line}`)}\n`);
246
272
  });
247
273
  if (hidden > 0)
248
- write(gray(` …${hidden} more line${hidden !== 1 ? 's' : ''}\n`));
274
+ out.push(gray(` …${hidden} more line${hidden !== 1 ? 's' : ''}\n`));
275
+ return out.join('');
249
276
  }
250
277
  export function toolCallStart(name, args) {
251
278
  const dot = DELETE_TOOLS.has(name) ? red('●') : EDIT_TOOLS.has(name) ? green('●') : blue('●');
252
- write(`\n${dot} ${bold(toolLabel(name, args))}\n`);
279
+ let out = `\n${dot} ${bold(toolLabel(name, args))}\n`;
253
280
  const a = args;
254
281
  if (name === 'update_file' && a.old && a.new && a.path) {
255
- printUpdateDiff(a.path, a.old, a.new);
282
+ out += printUpdateDiff(a.path, a.old, a.new);
256
283
  }
257
- else if (name === 'edit_file' && a.content && a.path) {
258
- printEditPreview(a.content);
284
+ else if ((name === 'edit_file' || name === 'create_file') && a.content && a.path) {
285
+ out += printEditPreview(a.content);
259
286
  }
287
+ write(out);
260
288
  }
261
289
  export function toolResultSummary(name, args, result) {
262
290
  const a = args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
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",