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 +47 -2
- package/dist/mcp/client.js +5 -0
- package/dist/skills/loader.js +6 -2
- package/dist/tools/index.js +92 -31
- package/dist/tui/InputBar.js +4 -4
- package/dist/tui/components/InputArea.js +24 -10
- package/dist/tui/deepThink.js +0 -1
- package/dist/tui/hooks/useRefactor.js +4 -3
- package/dist/tui/hooks/useRunLoop.js +11 -7
- package/dist/tui/hooks/useSession.js +2 -2
- package/dist/tui/hooks/useSubmit.js +18 -6
- package/dist/tui/printer.js +68 -40
- package/package.json +1 -1
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(
|
|
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();
|
package/dist/mcp/client.js
CHANGED
|
@@ -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)) {
|
package/dist/skills/loader.js
CHANGED
|
@@ -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
|
-
|
|
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-/, '');
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
88
|
-
const oldLines =
|
|
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
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
}
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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 === '@'
|
|
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
|
}
|
package/dist/tui/deepThink.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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}.
|
|
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
|
-
|
|
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: ${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
}
|
package/dist/tui/printer.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 `
|
|
169
|
-
case 'list_files': return `
|
|
170
|
-
case 'create_file': return `
|
|
171
|
-
case 'edit_file': return `
|
|
172
|
-
case 'update_file': return `
|
|
173
|
-
case 'delete_file': return `
|
|
174
|
-
case 'move_file': return `
|
|
175
|
-
case 'create_folder': return `
|
|
176
|
-
case 'run_command': return `
|
|
177
|
-
case 'git_status': return '
|
|
178
|
-
case 'git_diff': return '
|
|
179
|
-
case 'git_log': return '
|
|
180
|
-
case 'git_commit': return `
|
|
181
|
-
case 'run_tests': return a.path ? `
|
|
182
|
-
case 'web_search': return `
|
|
183
|
-
case 'web_extract': return `
|
|
184
|
-
case 'deep_think': return `
|
|
185
|
-
case 'search_codebase': return `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = [];
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
+
const out = [` ${gray('└')} ${green(`+${lines.length} line${lines.length !== 1 ? 's' : ''}`)}\n`];
|
|
244
270
|
visible.forEach((line, i) => {
|
|
245
|
-
|
|
271
|
+
out.push(` ${gray(String(i + 1).padStart(4))} ${bgGreen(`+ ${line}`)}\n`);
|
|
246
272
|
});
|
|
247
273
|
if (hidden > 0)
|
|
248
|
-
|
|
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
|
-
|
|
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;
|