miii-cli 1.2.2 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/memory/store.js +1 -1
- package/dist/skills/loader.js +6 -1
- package/dist/tools/index.js +32 -41
- package/dist/tui/InputBar.js +40 -6
- package/dist/tui/components/DesignTeachModal.js +88 -0
- package/dist/tui/components/InputArea.js +27 -26
- package/dist/tui/hooks/useRunLoop.js +20 -40
- package/dist/tui/hooks/useSession.js +6 -2
- package/dist/tui/hooks/useSubmit.js +56 -5
- package/dist/tui/printer.js +67 -38
- package/package.json +1 -1
package/dist/memory/store.js
CHANGED
package/dist/skills/loader.js
CHANGED
|
@@ -28,6 +28,11 @@ const builtin = [
|
|
|
28
28
|
return 'Normal mode.';
|
|
29
29
|
},
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
name: 'design',
|
|
33
|
+
ns: 'default',
|
|
34
|
+
description: 'Impeccable UI design — /design teach to learn brand, /design <task> to create',
|
|
35
|
+
},
|
|
31
36
|
{
|
|
32
37
|
name: 'review',
|
|
33
38
|
ns: 'default',
|
|
@@ -39,7 +44,7 @@ const builtin = [
|
|
|
39
44
|
ns: 'default',
|
|
40
45
|
description: 'Show available commands',
|
|
41
46
|
execute: (_, ctx) => {
|
|
42
|
-
return 'Built-in: /review /mkdir /mv /touch /models /sessions /session /clear /list /help\nType /list for all loaded skills.';
|
|
47
|
+
return 'Built-in: /review /mkdir /mv /touch /models /sessions /session /clear /list /help\n\nDesign:\n /design teach answer 7 questions → generates DESIGN.md (impeccable design system)\n /design <task> implement UI using DESIGN.md as brand context\n\nType /list for all loaded skills.';
|
|
43
48
|
},
|
|
44
49
|
},
|
|
45
50
|
{
|
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,7 @@ export const tools = [
|
|
|
14
20
|
params: '{"path": "string"}',
|
|
15
21
|
execute: async ({ path }) => {
|
|
16
22
|
try {
|
|
17
|
-
return readFile(guardPath(path));
|
|
23
|
+
return readFile(guardPath(requireArg(path, 'path', 'read_file')));
|
|
18
24
|
}
|
|
19
25
|
catch (e) {
|
|
20
26
|
throw new Error(`read_file: ${e}`);
|
|
@@ -26,7 +32,7 @@ export const tools = [
|
|
|
26
32
|
description: 'List directory contents',
|
|
27
33
|
params: '{"path": "string", "recursive": "boolean (optional)"}',
|
|
28
34
|
execute: async ({ path, recursive = false }) => {
|
|
29
|
-
const entries = listFiles(guardPath(path), recursive);
|
|
35
|
+
const entries = listFiles(guardPath(requireArg(path, 'path', 'list_files')), recursive);
|
|
30
36
|
if (!entries.length)
|
|
31
37
|
return '(empty)';
|
|
32
38
|
return entries.map(e => `${e.type === 'dir' ? 'd' : 'f'} ${e.rel}`).join('\n');
|
|
@@ -37,10 +43,10 @@ export const tools = [
|
|
|
37
43
|
description: 'Create a new file with content — fails if file already exists. Prefer edit_file for new files.',
|
|
38
44
|
params: '{"path": "string", "content": "string"}',
|
|
39
45
|
execute: async ({ path, content }) => {
|
|
40
|
-
const safe = guardPath(path);
|
|
46
|
+
const safe = guardPath(requireArg(path, 'path', 'create_file'));
|
|
41
47
|
if (existsSync(safe))
|
|
42
48
|
throw new Error(`file already exists: ${path}`);
|
|
43
|
-
writeFile(safe, content);
|
|
49
|
+
writeFile(safe, requireArg(content, 'content', 'create_file'));
|
|
44
50
|
return `created: ${path}`;
|
|
45
51
|
},
|
|
46
52
|
},
|
|
@@ -49,13 +55,13 @@ export const tools = [
|
|
|
49
55
|
description: 'Write a new file — only for files that do not exist yet. Use update_file to modify existing files.',
|
|
50
56
|
params: '{"path": "string", "content": "string"}',
|
|
51
57
|
execute: async ({ path, content }) => {
|
|
52
|
-
const safe = guardPath(path);
|
|
58
|
+
const safe = guardPath(requireArg(path, 'path', 'edit_file'));
|
|
53
59
|
if (existsSync(safe)) {
|
|
54
60
|
throw new Error(`edit_file cannot overwrite existing file: ${path}\n` +
|
|
55
61
|
`Use update_file with <old> and <new> blocks to make targeted edits.\n` +
|
|
56
62
|
`Call read_file first to get the exact current text.`);
|
|
57
63
|
}
|
|
58
|
-
const text = content;
|
|
64
|
+
const text = requireArg(content, 'content', 'edit_file');
|
|
59
65
|
writeFile(safe, text);
|
|
60
66
|
const lines = text.split('\n').length;
|
|
61
67
|
return `created: ${path} (${lines} line${lines === 1 ? '' : 's'})`;
|
|
@@ -66,13 +72,15 @@ export const tools = [
|
|
|
66
72
|
description: 'Replace an exact unique string in an existing file. Always call read_file first to get the exact text.',
|
|
67
73
|
params: '{"path": "string", "old": "string", "new": "string"}',
|
|
68
74
|
execute: async ({ path, old: oldStr, new: newStr }) => {
|
|
69
|
-
const safe = guardPath(path);
|
|
75
|
+
const safe = guardPath(requireArg(path, 'path', 'update_file'));
|
|
70
76
|
const current = readFile(safe);
|
|
71
77
|
if (current === null)
|
|
72
78
|
throw new Error(`file not found: ${path}`);
|
|
73
79
|
if (current === '')
|
|
74
80
|
throw new Error(`file empty: ${path}`);
|
|
75
|
-
const old = oldStr;
|
|
81
|
+
const old = requireArg(oldStr, 'old', 'update_file');
|
|
82
|
+
if (newStr === undefined || newStr === null)
|
|
83
|
+
throw new Error('update_file: "new" argument is required');
|
|
76
84
|
const count = current.split(old).length - 1;
|
|
77
85
|
if (count === 0) {
|
|
78
86
|
throw new Error(`old text not found in ${path} — file may have changed since last read.\n` +
|
|
@@ -104,7 +112,7 @@ export const tools = [
|
|
|
104
112
|
description: 'Delete a file',
|
|
105
113
|
params: '{"path": "string"}',
|
|
106
114
|
execute: async ({ path }) => {
|
|
107
|
-
deleteFile(guardPath(path));
|
|
115
|
+
deleteFile(guardPath(requireArg(path, 'path', 'delete_file')));
|
|
108
116
|
return `deleted: ${path}`;
|
|
109
117
|
},
|
|
110
118
|
},
|
|
@@ -113,8 +121,9 @@ export const tools = [
|
|
|
113
121
|
description: 'Run a shell command in cwd',
|
|
114
122
|
params: '{"command": "string"}',
|
|
115
123
|
execute: async ({ command }) => {
|
|
116
|
-
const { stdout, stderr } = await run(command, { cwd: process.cwd(), timeout: EXEC_TIMEOUT_MS });
|
|
117
|
-
|
|
124
|
+
const { stdout, stderr } = await run(requireArg(command, 'command', 'run_command'), { cwd: process.cwd(), timeout: EXEC_TIMEOUT_MS });
|
|
125
|
+
const out = [stdout, stderr ? `stderr: ${stderr}` : ''].filter(Boolean).join('\n').trim();
|
|
126
|
+
return out.length > 8000 ? out.slice(0, 8000) + '\n…[truncated]' : out;
|
|
118
127
|
},
|
|
119
128
|
},
|
|
120
129
|
{
|
|
@@ -122,7 +131,7 @@ export const tools = [
|
|
|
122
131
|
description: 'Create a directory (and any missing parents)',
|
|
123
132
|
params: '{"path": "string"}',
|
|
124
133
|
execute: async ({ path }) => {
|
|
125
|
-
createDir(guardPath(path));
|
|
134
|
+
createDir(guardPath(requireArg(path, 'path', 'create_folder')));
|
|
126
135
|
return `created: ${path}`;
|
|
127
136
|
},
|
|
128
137
|
},
|
|
@@ -131,7 +140,7 @@ export const tools = [
|
|
|
131
140
|
description: 'Move or rename a file or directory',
|
|
132
141
|
params: '{"from": "string", "to": "string"}',
|
|
133
142
|
execute: async ({ from, to }) => {
|
|
134
|
-
moveFile(guardPath(from), guardPath(to));
|
|
143
|
+
moveFile(guardPath(requireArg(from, 'from', 'move_file')), guardPath(requireArg(to, 'to', 'move_file')));
|
|
135
144
|
return `moved: ${from} → ${to}`;
|
|
136
145
|
},
|
|
137
146
|
},
|
|
@@ -278,16 +287,14 @@ export function getSystemPrompt(extra = '', extraTools = []) {
|
|
|
278
287
|
const toolDocs = allTools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
|
|
279
288
|
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.
|
|
280
289
|
- 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".`;
|
|
281
|
-
return `You are Miii —
|
|
290
|
+
return `You are Miii — AI coding assistant.
|
|
282
291
|
|
|
283
|
-
|
|
292
|
+
Tools via:
|
|
284
293
|
<tool_call>
|
|
285
294
|
{"name": "tool_name", "args": {...}}
|
|
286
295
|
</tool_call>
|
|
287
296
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
For edit_file / create_file use <content> block:
|
|
297
|
+
File content in named blocks (not inside JSON):
|
|
291
298
|
<tool_call>
|
|
292
299
|
{"name": "edit_file", "args": {"path": "src/foo.ts"}}
|
|
293
300
|
<content>
|
|
@@ -295,7 +302,6 @@ full file content here
|
|
|
295
302
|
</content>
|
|
296
303
|
</tool_call>
|
|
297
304
|
|
|
298
|
-
For update_file use <old> and <new> blocks:
|
|
299
305
|
<tool_call>
|
|
300
306
|
{"name": "update_file", "args": {"path": "src/foo.ts"}}
|
|
301
307
|
<old>
|
|
@@ -311,26 +317,11 @@ ${toolDocs}
|
|
|
311
317
|
${deepThinkDoc}
|
|
312
318
|
|
|
313
319
|
Rules:
|
|
314
|
-
- edit_file
|
|
315
|
-
-
|
|
316
|
-
-
|
|
317
|
-
-
|
|
318
|
-
-
|
|
319
|
-
-
|
|
320
|
-
-
|
|
321
|
-
- Always call git_status before git_commit to verify what will be staged
|
|
322
|
-
- Be concise
|
|
323
|
-
- Output plain text only — never use markdown formatting in your responses
|
|
324
|
-
- No headers (no #, ##), no bold (**text**), no italic (*text*), no bullet points with *, no horizontal rules (---)
|
|
325
|
-
- NEVER show file content or code in your text response — always use edit_file, update_file, or create_file tools to write code to files
|
|
326
|
-
- If you want to show the user code, write it to the file with a tool call instead
|
|
327
|
-
- No fenced code blocks (no \`\`\`). If you find yourself about to write a code block, use a tool call instead
|
|
328
|
-
- Use plain indentation and labels for structure. This is a terminal, not a chat UI
|
|
329
|
-
- After editing files that have tests, call run_tests to verify nothing broke
|
|
330
|
-
- If run_tests fails, read the failing test output and fix the code, then run_tests again (max 3 retries)
|
|
331
|
-
- You have web_search and web_extract tools — use them whenever the user asks about anything requiring internet access, current information, documentation, library versions, news, or external URLs
|
|
332
|
-
- NEVER say you cannot search the web — always call web_search instead
|
|
333
|
-
- web_search REQUIRES the "query" key exactly — never omit it, never use "q" or "search_query" or any other key name
|
|
334
|
-
- Use deep_think when the question requires gathering from multiple files or sources before you can answer well — it runs a safe read-only research phase and returns a summary you can reason over
|
|
335
|
-
- deep_think cannot edit files or run shell commands — it is purely for information gathering${extra}`;
|
|
320
|
+
- edit_file: new files only (errors if exists). For existing files: read_file then update_file with exact <old> text
|
|
321
|
+
- Never guess old text — always re-read immediately before patching. If "old text not found": read_file again and retry
|
|
322
|
+
- Plain text responses only. No markdown (#/*/\`), no code blocks — write code with tools, not in responses
|
|
323
|
+
- git_status/git_diff before refactors. git_status before git_commit
|
|
324
|
+
- run_tests after edits. Fix failures, retry up to 3 times
|
|
325
|
+
- web_search requires "query" key exactly. Never say you can't search — always call web_search
|
|
326
|
+
- deep_think: read-only research only, cannot edit files${extra}`;
|
|
336
327
|
}
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useRef, useMemo, useEffect } from 'react';
|
|
2
|
+
import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
3
5
|
import { Box, Text, useStdout } from 'ink';
|
|
4
6
|
import { InputArea } from './components/InputArea.js';
|
|
5
7
|
import { ModelPicker } from './components/ModelPicker.js';
|
|
6
8
|
import { ConfigPicker } from './components/ConfigPicker.js';
|
|
7
9
|
import { Divider } from './components/StatusBar.js';
|
|
10
|
+
import { DesignTeachModal, DESIGN_TEACH_QUESTIONS, buildDesignPrompt } from './components/DesignTeachModal.js';
|
|
8
11
|
import { tools } from '../tools/index.js';
|
|
9
|
-
import {
|
|
12
|
+
import { toolLabel, permissionDesc, formatElapsed, EDIT_TOOLS, DELETE_TOOLS } from './printer.js';
|
|
10
13
|
import { MacroQueue } from '../tasks/queue.js';
|
|
11
14
|
import { TaskExecutor } from '../tasks/executor.js';
|
|
12
15
|
import { THINKING_PHRASES, SPARKLE } from './thinking.js';
|
|
@@ -96,7 +99,26 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
96
99
|
const executorRef = useRef(new TaskExecutor(tools));
|
|
97
100
|
const lastGitStatusRef = useRef('');
|
|
98
101
|
const abortRef = useRef(null);
|
|
99
|
-
const
|
|
102
|
+
const [designTeachState, setDesignTeachState] = useState(null);
|
|
103
|
+
const [designReadyPrompt, setDesignReadyPrompt] = useState(null);
|
|
104
|
+
const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, setHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools);
|
|
105
|
+
const startDesignTeach = useCallback(() => {
|
|
106
|
+
setDesignTeachState({ answers: [], idx: 0 });
|
|
107
|
+
}, []);
|
|
108
|
+
const handleDesignAnswer = useCallback((answer) => {
|
|
109
|
+
setDesignTeachState(prev => {
|
|
110
|
+
if (!prev)
|
|
111
|
+
return null;
|
|
112
|
+
const answers = [...prev.answers, answer];
|
|
113
|
+
const nextIdx = prev.idx + 1;
|
|
114
|
+
if (nextIdx >= DESIGN_TEACH_QUESTIONS.length) {
|
|
115
|
+
const exists = existsSync(join(cwd, 'DESIGN.md'));
|
|
116
|
+
setDesignReadyPrompt(buildDesignPrompt(DESIGN_TEACH_QUESTIONS, answers, exists));
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return { answers, idx: nextIdx };
|
|
120
|
+
});
|
|
121
|
+
}, []);
|
|
100
122
|
const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
|
|
101
123
|
const deepThinkTool = useMemo(() => ({
|
|
102
124
|
name: 'deep_think',
|
|
@@ -109,7 +131,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
109
131
|
}), [config]);
|
|
110
132
|
const searchTool = useMemo(() => createSearchCodebaseTool(config, cwd), [config, cwd]);
|
|
111
133
|
const allTools = useMemo(() => [...tools, deepThinkTool, searchTool, ...mcpTools], [deepThinkTool, searchTool, mcpTools]);
|
|
112
|
-
const { status, setStatus, tick, currentTool, setCurrentTool, taskLabel, setTaskLabel, thinkingStartRef, runLoop, handleAbort, permissionRequest, resolvePermission,
|
|
134
|
+
const { status, setStatus, tick, currentTool, setCurrentTool, taskLabel, setTaskLabel, thinkingStartRef, runLoop, handleAbort, permissionRequest, resolvePermission, } = useRunLoop(config, currentModelRef, pushHistory, allTools, abortRef, setHistory);
|
|
113
135
|
const { runRefactor } = useRefactor({
|
|
114
136
|
config, currentModelRef, systemPromptRef, abortRef,
|
|
115
137
|
macroQueueRef, executorRef,
|
|
@@ -126,7 +148,15 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
126
148
|
runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig,
|
|
127
149
|
setConfigOpen, updateMemory,
|
|
128
150
|
startWatch, stopWatch, watchActive,
|
|
151
|
+
startDesignTeach,
|
|
129
152
|
});
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!designReadyPrompt)
|
|
155
|
+
return;
|
|
156
|
+
setDesignReadyPrompt(null);
|
|
157
|
+
pushHistory({ role: 'user', content: designReadyPrompt });
|
|
158
|
+
runLoop(buildContext(), 0, 'create or update DESIGN.md');
|
|
159
|
+
}, [designReadyPrompt, pushHistory, buildContext, runLoop]);
|
|
130
160
|
const skillList = skills.list();
|
|
131
161
|
return (_jsxs(Box, { flexDirection: "column", children: [configOpen ? (_jsxs(_Fragment, { children: [_jsx(ConfigPicker, { config: config, currentModel: currentModel, tavilyKey: tavilyKey, onUpdate: ({ model, ...configPatch }) => {
|
|
132
162
|
if (model)
|
|
@@ -135,7 +165,11 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
135
165
|
setConfig(c => ({ ...c, ...configPatch }));
|
|
136
166
|
saveConfig(configPatch);
|
|
137
167
|
}
|
|
138
|
-
}, onTavilyKey: (key) => { saveTavilyKey(key); setTavilyKey(key); }, onClose: () => { setConfigOpen(false); } }), _jsx(Divider, { cols: cols })] })) : pickerOpen ? (_jsxs(_Fragment, { children: [_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false); } }), _jsx(Divider, { cols: cols })] })) :
|
|
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'
|
|
139
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 })] })
|
|
140
|
-
: _jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }), _jsx(Text, { color: "gray", dimColor: true, children: formatElapsed(Date.now() - thinkingStartRef.current) }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] }) })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, permissionRequest: permissionRequest, onPermissionResponse: resolvePermission,
|
|
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
|
+
question: DESIGN_TEACH_QUESTIONS[designTeachState.idx],
|
|
172
|
+
index: designTeachState.idx,
|
|
173
|
+
total: DESIGN_TEACH_QUESTIONS.length,
|
|
174
|
+
} : null, onDesignTeachAnswer: handleDesignAnswer, onSubmit: handleSubmit, onAbort: handleAbort, history: historyRef.current.filter(m => m.role === 'user').map(m => m.content), watchActive: watchActive })] }));
|
|
141
175
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export const DESIGN_TEACH_QUESTIONS = [
|
|
4
|
+
'What does your product do? (one sentence)',
|
|
5
|
+
'Who are your primary users? (e.g. developers, consumers, small teams, enterprises)',
|
|
6
|
+
'How should it feel? List 3–5 words (e.g. bold, calm, precise, playful, minimal, trustworthy)',
|
|
7
|
+
'Any existing brand colors? (hex codes — or "none" to start fresh)',
|
|
8
|
+
'Any existing fonts? (font names — or "none" to choose new ones)',
|
|
9
|
+
'Interface type? (dashboard / marketing / app / docs / landing page / other)',
|
|
10
|
+
'Products or brands you want to look DIFFERENT from? (or "none")',
|
|
11
|
+
];
|
|
12
|
+
export function DesignTeachModal({ question, index, total }) {
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "\u25C6 design setup" }), _jsxs(Text, { color: "gray", dimColor: true, children: [index + 1, " of ", total] })] }), _jsx(Text, { color: "white", children: question })] }));
|
|
14
|
+
}
|
|
15
|
+
export function buildDesignPrompt(questions, answers, exists) {
|
|
16
|
+
const brief = questions.map((q, i) => `${q}\n→ ${answers[i] ?? '(skipped)'}`).join('\n\n');
|
|
17
|
+
const fileInstruction = exists
|
|
18
|
+
? `DESIGN.md already exists. First call read_file on DESIGN.md to get the current content, then call update_file to replace the entire content with the updated design system (use the full current file as the <old> block).`
|
|
19
|
+
: `Write DESIGN.md to the project root using the create_file tool.`;
|
|
20
|
+
return `You are an expert UI/UX designer following impeccable design principles. ${exists ? 'Update' : 'Create'} DESIGN.md based on this product brief.
|
|
21
|
+
|
|
22
|
+
Product Brief:
|
|
23
|
+
${brief}
|
|
24
|
+
|
|
25
|
+
Impeccable design philosophy (apply strictly):
|
|
26
|
+
- Typography: choose purposeful, distinctive fonts — avoid Inter/Roboto/generic defaults. Use modular scale. Consider display fonts for headings if brand personality supports it.
|
|
27
|
+
- Color: OKLCH-based system, tint neutrals with primary hue, minimum 4.5:1 contrast. No generic gray-on-white. Define primary, secondary, accent, 5 neutral steps, semantic colors (success/warning/error/info).
|
|
28
|
+
- Spatial: 4px-base spacing scale, clear hierarchy through deliberate whitespace.
|
|
29
|
+
- Motion: cubic-bezier curves (not linear/ease), respect prefers-reduced-motion.
|
|
30
|
+
- Interaction: always-visible focus states, meaningful hover/active transitions.
|
|
31
|
+
- Anti-patterns to eliminate: nested card shadows, purple-to-blue gradients, Inter everywhere, insufficient contrast, centered walls of text, auto-playing anything, generic SaaS aesthetics.
|
|
32
|
+
|
|
33
|
+
${exists ? 'Update' : 'Create'} DESIGN.md with ALL these sections:
|
|
34
|
+
|
|
35
|
+
## Product
|
|
36
|
+
[2-3 sentences: what it is, who uses it, core value]
|
|
37
|
+
|
|
38
|
+
## Brand Voice
|
|
39
|
+
[5 personality adjectives + 1 sentence on visual tone]
|
|
40
|
+
|
|
41
|
+
## Colors
|
|
42
|
+
Full OKLCH color system with hex equivalents:
|
|
43
|
+
- Primary: oklch(...) / #... [usage: CTAs, links, key actions]
|
|
44
|
+
- Secondary: oklch(...) / #...
|
|
45
|
+
- Accent: oklch(...) / #...
|
|
46
|
+
- Neutral-50 through Neutral-900 (tinted with primary hue)
|
|
47
|
+
- Success / Warning / Error / Info
|
|
48
|
+
Rationale: why these colors fit the brand personality.
|
|
49
|
+
|
|
50
|
+
## Typography
|
|
51
|
+
Heading font: [specific name] — why it fits this brand
|
|
52
|
+
Body font: [specific name] — why it fits
|
|
53
|
+
Scale:
|
|
54
|
+
- 3xl: 2.5rem / 700 — hero headings
|
|
55
|
+
- 2xl: 2rem / 600 — section headings
|
|
56
|
+
- xl: 1.5rem / 600 — card titles
|
|
57
|
+
- lg: 1.125rem / 500 — subheadings
|
|
58
|
+
- base: 1rem / 400 — body text
|
|
59
|
+
- sm: 0.875rem / 400 — captions, labels
|
|
60
|
+
- xs: 0.75rem / 400 — metadata
|
|
61
|
+
|
|
62
|
+
## Spacing
|
|
63
|
+
Token scale (4px base):
|
|
64
|
+
space-1: 4px | space-2: 8px | space-3: 12px | space-4: 16px
|
|
65
|
+
space-6: 24px | space-8: 32px | space-12: 48px | space-16: 64px | space-24: 96px
|
|
66
|
+
|
|
67
|
+
## Components
|
|
68
|
+
- Border-radius approach and why (sharp/medium/large/pill)
|
|
69
|
+
- Shadow style (none/subtle/elevated/dramatic)
|
|
70
|
+
- Button: primary, secondary, ghost — hover/active/focus/disabled states
|
|
71
|
+
- Input: default, focus, error, disabled states
|
|
72
|
+
- Card: background, border, shadow, padding
|
|
73
|
+
- Navigation: desktop + mobile pattern
|
|
74
|
+
|
|
75
|
+
## Motion
|
|
76
|
+
- Durations: fast 100ms / base 200ms / slow 350ms
|
|
77
|
+
- Easing: cubic-bezier(0.4, 0, 0.2, 1) standard, cubic-bezier(0, 0, 0.2, 1) decelerate
|
|
78
|
+
- Use cases: button click, hover transitions, modal open, page change
|
|
79
|
+
|
|
80
|
+
## Anti-patterns
|
|
81
|
+
8–10 specific things NOT to do for THIS product (derive from brand personality + interface type, not generic advice)
|
|
82
|
+
|
|
83
|
+
## Design Principles
|
|
84
|
+
4 guiding principles specific to this product — not generic platitudes
|
|
85
|
+
|
|
86
|
+
Be specific, opinionated, brand-appropriate. Every choice needs a reason. No placeholder text.
|
|
87
|
+
${fileInstruction}`;
|
|
88
|
+
}
|
|
@@ -56,7 +56,7 @@ function wordEndAfter(line, col) {
|
|
|
56
56
|
i++;
|
|
57
57
|
return i;
|
|
58
58
|
}
|
|
59
|
-
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse,
|
|
59
|
+
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, designTeach, onDesignTeachAnswer, onSubmit, onAbort, history = [], watchActive = false }) {
|
|
60
60
|
const [lines, setLines] = useState(['']);
|
|
61
61
|
const [cursor, setCursor] = useState({ row: 0, col: 0 });
|
|
62
62
|
const [overlay, setOverlay] = useState('none');
|
|
@@ -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
|
}
|
|
@@ -212,17 +212,6 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
212
212
|
}
|
|
213
213
|
return;
|
|
214
214
|
}
|
|
215
|
-
if (compactRequest && onCompactResponse) {
|
|
216
|
-
if (input === 'y' || input === 'Y') {
|
|
217
|
-
onCompactResponse(true);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
if (input === 'n' || input === 'N' || key.escape) {
|
|
221
|
-
onCompactResponse(false);
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
215
|
if (key.escape) {
|
|
227
216
|
if (overlay !== 'none') {
|
|
228
217
|
setOverlay('none');
|
|
@@ -277,6 +266,12 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
277
266
|
}
|
|
278
267
|
}
|
|
279
268
|
if (key.return) {
|
|
269
|
+
if (designTeach && onDesignTeachAnswer) {
|
|
270
|
+
const answer = fullInput.trim();
|
|
271
|
+
clearInput();
|
|
272
|
+
onDesignTeachAnswer(answer || '(skipped)');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
280
275
|
const typed = fullInput.trim();
|
|
281
276
|
const pasted = pasteRef.current;
|
|
282
277
|
const text = pasted
|
|
@@ -433,14 +428,20 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
433
428
|
appendChar(input);
|
|
434
429
|
if (prospective.startsWith('/')) {
|
|
435
430
|
if (prospective.slice(1).includes(' ')) {
|
|
436
|
-
|
|
431
|
+
if (input === '@' || overlay === 'at') {
|
|
432
|
+
setOverlay('at');
|
|
433
|
+
setOverlayIdx(0);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
setOverlay('none');
|
|
437
|
+
}
|
|
437
438
|
}
|
|
438
439
|
else {
|
|
439
440
|
setOverlay('command');
|
|
440
441
|
setOverlayIdx(0);
|
|
441
442
|
}
|
|
442
443
|
}
|
|
443
|
-
else if (input === '@' || (overlay === 'at' && atQuery !==
|
|
444
|
+
else if (input === '@' || (overlay === 'at' && atQuery !== '')) {
|
|
444
445
|
setOverlay('at');
|
|
445
446
|
setOverlayIdx(0);
|
|
446
447
|
}
|
|
@@ -453,10 +454,10 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
453
454
|
const cols = stdout.columns ?? 80;
|
|
454
455
|
const availWidth = Math.max(20, cols - 4); // paddingX(2) + "> "(2)
|
|
455
456
|
const isProcessing = status !== 'idle';
|
|
456
|
-
const promptColor =
|
|
457
|
+
const promptColor = permissionRequest ? 'yellow' : designTeach ? 'cyan' : isProcessing ? 'yellow' : 'green';
|
|
457
458
|
const inHistory = historyIdx !== -1;
|
|
458
|
-
const hint =
|
|
459
|
-
? '
|
|
459
|
+
const hint = designTeach
|
|
460
|
+
? 'enter submit answer esc skip'
|
|
460
461
|
: permissionRequest
|
|
461
462
|
? 'y approve once a approve for session n deny'
|
|
462
463
|
: isProcessing
|
|
@@ -475,7 +476,7 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
475
476
|
const pastePreview = pasteRef.current
|
|
476
477
|
? pasteRef.current.split('\n')[0].slice(0, cols - 6)
|
|
477
478
|
: '';
|
|
478
|
-
return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: promptColor, bold: true, children: '> ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: permissionRequest ? (_jsxs(Box, { gap: 3, children: [_jsx(Text, { color: "green", bold: true, children: "y once" }), _jsx(Text, { color: "cyan", bold: true, children: "a session" }), _jsx(Text, { color: "red", bold: true, children: "n deny" })] })) :
|
|
479
|
+
return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: promptColor, bold: true, children: '> ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: permissionRequest ? (_jsxs(Box, { gap: 3, children: [_jsx(Text, { color: "green", bold: true, children: "y once" }), _jsx(Text, { color: "cyan", bold: true, children: "a session" }), _jsx(Text, { color: "red", bold: true, children: "n deny" })] })) : pasteLines > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " line", pasteLines !== 1 ? 's' : ''] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] }), pastePreview && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", pastePreview, pasteRef.current.split('\n')[0].length > cols - 6 ? '…' : ''] }))] })) : lines.length === 1 && !lines[0] ? (watchActive && isActive
|
|
479
480
|
? _jsxs(Text, { children: [_jsx(Text, { color: "cyan", dimColor: true, children: "watching\u2026 " }), _jsx(Text, { children: "\u2588" })] })
|
|
480
481
|
: _jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
|
|
481
482
|
? viewportLine(line, cursor.col, availWidth, isActive)
|
|
@@ -3,7 +3,7 @@ import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
|
3
3
|
import { chat } from '../../llm/stream.js';
|
|
4
4
|
import { tools as staticTools } from '../../tools/index.js';
|
|
5
5
|
import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
|
|
6
|
-
import { shouldCompact, compactContext
|
|
6
|
+
import { shouldCompact, compactContext } from '../../tasks/compactor.js';
|
|
7
7
|
import * as printer from '../printer.js';
|
|
8
8
|
const MAX_TOOL_DEPTH = 10;
|
|
9
9
|
const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'update_file', 'delete_file']);
|
|
@@ -11,36 +11,31 @@ 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
|
}
|
|
18
|
-
export function useRunLoop(config, currentModelRef, pushHistory, extraTools = [], abortRef) {
|
|
18
|
+
export function useRunLoop(config, currentModelRef, pushHistory, extraTools = [], abortRef, replaceHistory) {
|
|
19
19
|
const [status, setStatus] = useState('idle');
|
|
20
20
|
const [tick, setTick] = useState(0);
|
|
21
21
|
const [currentTool, setCurrentTool] = useState();
|
|
22
22
|
const [taskLabel, setTaskLabel] = useState();
|
|
23
23
|
const [permissionRequest, setPermissionRequest] = useState(null);
|
|
24
24
|
const permissionResolveRef = useRef(null);
|
|
25
|
-
const [compactRequest, setCompactRequest] = useState(null);
|
|
26
|
-
const compactResolveRef = useRef(null);
|
|
27
25
|
const checkpointRef = useRef(new Map());
|
|
28
26
|
const sessionApprovedRef = useRef(new Set());
|
|
29
27
|
const thinkingStartRef = useRef(0);
|
|
30
28
|
const extraToolsRef = useRef(extraTools);
|
|
31
29
|
extraToolsRef.current = extraTools;
|
|
32
30
|
const pushHistoryRef = useRef(pushHistory);
|
|
31
|
+
const replaceHistoryRef = useRef(replaceHistory);
|
|
33
32
|
useEffect(() => { pushHistoryRef.current = pushHistory; }, [pushHistory]);
|
|
33
|
+
useEffect(() => { replaceHistoryRef.current = replaceHistory; }, [replaceHistory]);
|
|
34
34
|
const resolvePermission = useCallback((result) => {
|
|
35
35
|
permissionResolveRef.current?.(result);
|
|
36
36
|
permissionResolveRef.current = null;
|
|
37
37
|
setPermissionRequest(null);
|
|
38
38
|
}, []);
|
|
39
|
-
const resolveCompact = useCallback((approved) => {
|
|
40
|
-
compactResolveRef.current?.(approved);
|
|
41
|
-
compactResolveRef.current = null;
|
|
42
|
-
setCompactRequest(null);
|
|
43
|
-
}, []);
|
|
44
39
|
useEffect(() => {
|
|
45
40
|
if (status === 'idle')
|
|
46
41
|
return;
|
|
@@ -60,24 +55,16 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
60
55
|
}
|
|
61
56
|
let msgs = contextMsgs;
|
|
62
57
|
if (shouldCompact(contextMsgs)) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
baseUrl: config.baseUrl,
|
|
74
|
-
apiKey: config.apiKey,
|
|
75
|
-
}, goal);
|
|
76
|
-
printer.systemMsg(`compacted: ${contextMsgs.length} → ${msgs.length} messages`);
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
printer.systemMsg('keeping full context — responses may be slower');
|
|
80
|
-
}
|
|
58
|
+
printer.systemMsg('compacting context…');
|
|
59
|
+
const toCompact = stripEphemeral(contextMsgs);
|
|
60
|
+
msgs = await compactContext(toCompact, {
|
|
61
|
+
provider: config.provider,
|
|
62
|
+
model: currentModelRef.current,
|
|
63
|
+
baseUrl: config.baseUrl,
|
|
64
|
+
apiKey: config.apiKey,
|
|
65
|
+
}, goal);
|
|
66
|
+
printer.systemMsg(`compacted: ${contextMsgs.length} → ${msgs.length} messages`);
|
|
67
|
+
replaceHistoryRef.current?.(msgs.filter(m => m.role !== 'system'));
|
|
81
68
|
}
|
|
82
69
|
abortRef.current = new AbortController();
|
|
83
70
|
await chat({
|
|
@@ -176,13 +163,13 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
176
163
|
if (occurrences === 0) {
|
|
177
164
|
printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
|
|
178
165
|
next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
|
|
179
|
-
next.push({ role: 'user', content: `update_file failed: old text not
|
|
166
|
+
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.` });
|
|
180
167
|
continue;
|
|
181
168
|
}
|
|
182
169
|
if (occurrences > 1) {
|
|
183
170
|
printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
|
|
184
171
|
next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
|
|
185
|
-
next.push({ role: 'user', content: `update_file failed: old text matches ${occurrences} locations in ${filePath}.
|
|
172
|
+
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.` });
|
|
186
173
|
continue;
|
|
187
174
|
}
|
|
188
175
|
}
|
|
@@ -193,12 +180,11 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
193
180
|
if (SHOW_RESULT_TOOLS.has(tc.name))
|
|
194
181
|
printer.toolMsg(tc.name, result);
|
|
195
182
|
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
|
|
196
|
-
// After any file edit, inject fresh file state so next tool sees actual content
|
|
197
183
|
if (FILE_EDIT_TOOLS.has(tc.name)) {
|
|
198
184
|
const filePath = tc.args.path;
|
|
199
185
|
if (filePath && existsSync(filePath)) {
|
|
200
|
-
const
|
|
201
|
-
next.push({ role: 'user', content: `[
|
|
186
|
+
const lineCount = readFileSync(filePath, 'utf-8').split('\n').length;
|
|
187
|
+
next.push({ role: 'user', content: `[file updated: ${filePath} — ${lineCount} lines]` });
|
|
202
188
|
}
|
|
203
189
|
}
|
|
204
190
|
}
|
|
@@ -223,7 +209,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
223
209
|
if (didEditFiles) {
|
|
224
210
|
const systemMsg = msgs.find(m => m.role === 'system');
|
|
225
211
|
const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '));
|
|
226
|
-
const batchStart = msgs.length
|
|
212
|
+
const batchStart = msgs.length; // include assistant message so model sees its own tool call on retry
|
|
227
213
|
const batchMsgs = next.slice(batchStart);
|
|
228
214
|
const slimCtx = [
|
|
229
215
|
...(systemMsg ? [systemMsg] : []),
|
|
@@ -251,11 +237,6 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
251
237
|
permissionResolveRef.current = null;
|
|
252
238
|
setPermissionRequest(null);
|
|
253
239
|
}
|
|
254
|
-
if (compactResolveRef.current) {
|
|
255
|
-
compactResolveRef.current(false);
|
|
256
|
-
compactResolveRef.current = null;
|
|
257
|
-
setCompactRequest(null);
|
|
258
|
-
}
|
|
259
240
|
// Restore checkpointed files
|
|
260
241
|
if (checkpointRef.current.size > 0) {
|
|
261
242
|
let restored = 0;
|
|
@@ -285,6 +266,5 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
285
266
|
thinkingStartRef,
|
|
286
267
|
runLoop, handleAbort,
|
|
287
268
|
permissionRequest, resolvePermission,
|
|
288
|
-
compactRequest, resolveCompact,
|
|
289
269
|
};
|
|
290
270
|
}
|
|
@@ -5,7 +5,7 @@ import { getTavilyKey, saveTavilyKey } from '../../tavily/client.js';
|
|
|
5
5
|
import * as printer from '../printer.js';
|
|
6
6
|
import { loadLongMemory, saveLongMemory, mergeFacts, formatMemoryBlock } from '../../memory/store.js';
|
|
7
7
|
import { extractFacts } from '../../memory/extractor.js';
|
|
8
|
-
const SHORT_MEMORY_SIZE =
|
|
8
|
+
const SHORT_MEMORY_SIZE = 50;
|
|
9
9
|
function buildSystemPrompt(cwd, facts, extraTools = []) {
|
|
10
10
|
return getSystemPrompt(`\n- CWD: ${cwd}`, extraTools) + formatMemoryBlock(facts);
|
|
11
11
|
}
|
|
@@ -88,6 +88,10 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
|
|
|
88
88
|
ctx.push(extra);
|
|
89
89
|
return ctx;
|
|
90
90
|
}
|
|
91
|
+
function setHistory(msgs) {
|
|
92
|
+
historyRef.current = msgs;
|
|
93
|
+
scheduleSave();
|
|
94
|
+
}
|
|
91
95
|
function updateMemory(newFacts) {
|
|
92
96
|
if (!newFacts.length)
|
|
93
97
|
return;
|
|
@@ -101,6 +105,6 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
|
|
|
101
105
|
sessionName, setSessionName, sessionNameRef,
|
|
102
106
|
historyRef, saveTimerRef, systemPromptRef,
|
|
103
107
|
longMemoryRef,
|
|
104
|
-
pushHistory, buildContext, renameFromMessage, updateMemory,
|
|
108
|
+
pushHistory, setHistory, buildContext, renameFromMessage, updateMemory,
|
|
105
109
|
};
|
|
106
110
|
}
|
|
@@ -37,7 +37,7 @@ export function useSubmit(deps) {
|
|
|
37
37
|
const depsRef = useRef(deps);
|
|
38
38
|
depsRef.current = deps;
|
|
39
39
|
const handleSubmit = useCallback(async (text) => {
|
|
40
|
-
const { config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel, historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef, setPlanningMode, runLoop, buildContext, pushHistory, setSessionName, renameFromMessage, setStatus, setTaskLabel, setCurrentTool, runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig, setConfigOpen, updateMemory, startWatch, stopWatch, } = depsRef.current;
|
|
40
|
+
const { config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel, historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef, setPlanningMode, runLoop, buildContext, pushHistory, setSessionName, renameFromMessage, setStatus, setTaskLabel, setCurrentTool, runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig, setConfigOpen, updateMemory, startWatch, stopWatch, startDesignTeach, } = depsRef.current;
|
|
41
41
|
const cmd = text.trim();
|
|
42
42
|
if (cmd === '?') {
|
|
43
43
|
printer.systemMsg('shortcuts:\n' +
|
|
@@ -52,7 +52,10 @@ export function useSubmit(deps) {
|
|
|
52
52
|
' @filename inject file into context\n' +
|
|
53
53
|
' /cmd open command palette\n' +
|
|
54
54
|
' esc abort / clear input\n' +
|
|
55
|
-
' ctrl+c abort / exit'
|
|
55
|
+
' ctrl+c abort / exit\n' +
|
|
56
|
+
'\ndesign commands:\n' +
|
|
57
|
+
' /design teach answer 7 questions → generates DESIGN.md (impeccable system)\n' +
|
|
58
|
+
' /design <task> design or implement UI using DESIGN.md as brand context');
|
|
56
59
|
return;
|
|
57
60
|
}
|
|
58
61
|
if (cmd === '/version') {
|
|
@@ -241,16 +244,60 @@ export function useSubmit(deps) {
|
|
|
241
244
|
printer.systemMsg('usage: /refactor <goal>');
|
|
242
245
|
return;
|
|
243
246
|
}
|
|
247
|
+
const atRefactorContext = buildAtContext(text);
|
|
248
|
+
if (atRefactorContext)
|
|
249
|
+
pushHistory({ role: 'user', content: atRefactorContext });
|
|
244
250
|
await runRefactor(goal);
|
|
245
251
|
return;
|
|
246
252
|
}
|
|
253
|
+
if (cmd === '/design' || cmd.startsWith('/design ')) {
|
|
254
|
+
const sub = cmd.slice(7).trim();
|
|
255
|
+
if (!sub || sub === 'teach') {
|
|
256
|
+
startDesignTeach();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Design task phase: use DESIGN.md context + impeccable principles
|
|
260
|
+
const atDesignContext = buildAtContext(text);
|
|
261
|
+
let designContext = '';
|
|
262
|
+
try {
|
|
263
|
+
const designFile = readFile(guardPath('DESIGN.md', cwd));
|
|
264
|
+
if (designFile)
|
|
265
|
+
designContext = `\n\nProject design system (from DESIGN.md):\n${designFile}\n`;
|
|
266
|
+
}
|
|
267
|
+
catch { }
|
|
268
|
+
const impeccableRules = `
|
|
269
|
+
Impeccable design rules — follow strictly:
|
|
270
|
+
- Typography: purposeful font selection, modular scale, intentional pairing. No Inter/Roboto by default.
|
|
271
|
+
- Color: OKLCH-based system, tinted neutrals, 4.5:1 contrast minimum. No generic gray-on-white.
|
|
272
|
+
- Spatial design: consistent spacing scale, clear visual hierarchy, intentional whitespace.
|
|
273
|
+
- Motion: contemporary easing (cubic-bezier not linear), respect prefers-reduced-motion.
|
|
274
|
+
- Interaction: visible focus states, loading patterns, meaningful hover states.
|
|
275
|
+
- Responsive: mobile-first, fluid typography where appropriate.
|
|
276
|
+
- UX copy: precise microcopy in labels, errors, empty states. No lorem ipsum.
|
|
277
|
+
- Anti-patterns to eliminate: nested card shadows, purple-to-blue gradients, disabled gray without reason, centered walls of text, auto-playing anything.
|
|
278
|
+
- Write distinctive, crafted UI — not generic SaaS templates.
|
|
279
|
+
- Write all code to files using tools. No code blocks in responses.`;
|
|
280
|
+
const taskDesc = sub.replace(/@[\w./\-]+/g, '').trim();
|
|
281
|
+
const taskPrompt = `${designContext}${impeccableRules}
|
|
282
|
+
|
|
283
|
+
Design task: ${taskDesc}
|
|
284
|
+
|
|
285
|
+
Analyze what exists, then implement the design. Use the design system above if available. Make it distinctive and well-crafted.`;
|
|
286
|
+
printer.userMsg(text);
|
|
287
|
+
pushHistory({ role: 'user', content: atDesignContext + taskPrompt });
|
|
288
|
+
await runLoop(buildContext(), 0, sub);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
247
291
|
if (cmd.startsWith('/think ') || cmd === '/think') {
|
|
248
292
|
const query = cmd.slice(6).trim();
|
|
249
293
|
if (!query) {
|
|
250
294
|
printer.systemMsg('usage: /think <query>');
|
|
251
295
|
return;
|
|
252
296
|
}
|
|
253
|
-
|
|
297
|
+
const atThinkContext = buildAtContext(text);
|
|
298
|
+
if (atThinkContext)
|
|
299
|
+
pushHistory({ role: 'user', content: atThinkContext });
|
|
300
|
+
printer.userMsg(text);
|
|
254
301
|
setStatus('thinking');
|
|
255
302
|
setTaskLabel(`gathering: ${query}`);
|
|
256
303
|
abortRef.current = new AbortController();
|
|
@@ -473,6 +520,9 @@ export function useSubmit(deps) {
|
|
|
473
520
|
return;
|
|
474
521
|
}
|
|
475
522
|
if (skill.execute) {
|
|
523
|
+
const atContext = buildAtContext(text);
|
|
524
|
+
if (atContext)
|
|
525
|
+
pushHistory({ role: 'user', content: atContext });
|
|
476
526
|
const ctx = {
|
|
477
527
|
messages: historyRef.current.map(m => ({ role: m.role, content: m.content })),
|
|
478
528
|
appendMessage: (_role, content) => printer.systemMsg(content),
|
|
@@ -485,8 +535,9 @@ export function useSubmit(deps) {
|
|
|
485
535
|
return;
|
|
486
536
|
}
|
|
487
537
|
if (skill.prompt) {
|
|
488
|
-
|
|
489
|
-
|
|
538
|
+
const atContext = buildAtContext(text);
|
|
539
|
+
printer.userMsg(text);
|
|
540
|
+
pushHistory({ role: 'user', content: atContext + skill.prompt });
|
|
490
541
|
await runLoop(buildContext());
|
|
491
542
|
return;
|
|
492
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')
|
|
@@ -121,6 +123,7 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
|
|
|
121
123
|
` ${bold(yellow('Tips for getting started'))}`,
|
|
122
124
|
` Type ${cyan('@filename')} to inject file into context`,
|
|
123
125
|
` Use ${cyan('/skill')} to run a skill or command`,
|
|
126
|
+
` Use ${cyan('/design teach')} to generate a design system`,
|
|
124
127
|
` Use ${cyan('/config')} to switch provider, model, or API key`,
|
|
125
128
|
'',
|
|
126
129
|
];
|
|
@@ -159,30 +162,42 @@ export function assistantMsg(text) {
|
|
|
159
162
|
const tail = lines.slice(idx + 1).join('\n');
|
|
160
163
|
write(`\n${blue('●')} ${head}${tail ? '\n' + tail : ''}\n`);
|
|
161
164
|
}
|
|
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
|
-
|
|
165
|
+
export const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'write_file']);
|
|
166
|
+
export const DELETE_TOOLS = new Set(['delete_file', 'remove_file']);
|
|
167
|
+
const PERM_DESC = {
|
|
168
|
+
delete_file: 'delete this file',
|
|
169
|
+
update_file: 'edit this file',
|
|
170
|
+
create_file: 'create this file',
|
|
171
|
+
edit_file: 'create this file',
|
|
172
|
+
move_file: 'move this file',
|
|
173
|
+
run_command: 'run in shell',
|
|
174
|
+
git_commit: 'commit to git',
|
|
175
|
+
};
|
|
176
|
+
export function permissionDesc(toolName) {
|
|
177
|
+
return PERM_DESC[toolName] ?? 'allow this action';
|
|
178
|
+
}
|
|
179
|
+
export function toolLabel(name, args) {
|
|
165
180
|
const a = args;
|
|
166
181
|
const short = (s, n = 55) => s.length > n ? s.slice(0, n) + '…' : s;
|
|
167
182
|
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 `
|
|
183
|
+
case 'read_file': return `Read(${a.path ?? ''})`;
|
|
184
|
+
case 'list_files': return `List(${a.path || '.'})`;
|
|
185
|
+
case 'create_file': return `Create(${a.path ?? ''})`;
|
|
186
|
+
case 'edit_file': return `Create(${a.path ?? ''})`;
|
|
187
|
+
case 'update_file': return `Update(${a.path ?? ''})`;
|
|
188
|
+
case 'delete_file': return `Delete(${a.path ?? ''})`;
|
|
189
|
+
case 'move_file': return `Move(${a.from} → ${a.to})`;
|
|
190
|
+
case 'create_folder': return `Mkdir(${a.path ?? ''})`;
|
|
191
|
+
case 'run_command': return `Run(${short(a.command ?? '')})`;
|
|
192
|
+
case 'git_status': return 'Git(status)';
|
|
193
|
+
case 'git_diff': return 'Git(diff)';
|
|
194
|
+
case 'git_log': return 'Git(log)';
|
|
195
|
+
case 'git_commit': return `Git(commit: ${short(a.message ?? '')})`;
|
|
196
|
+
case 'run_tests': return a.path ? `Test(${a.path})` : 'Test(suite)';
|
|
197
|
+
case 'web_search': return `Search(${short(a.query ?? '')})`;
|
|
198
|
+
case 'web_extract': return `Extract(${Array.isArray(a.urls) ? String(a.urls[0] ?? '') : String(a.urls ?? 'url')})`;
|
|
199
|
+
case 'deep_think': return `Think(${short(a.query ?? '')})`;
|
|
200
|
+
case 'search_codebase': return `Index(${short(a.query ?? '')})`;
|
|
186
201
|
default: {
|
|
187
202
|
const s = toolArgSummary(args);
|
|
188
203
|
return s ? `${name} ${s}` : name;
|
|
@@ -192,20 +207,31 @@ function toolLabel(name, args) {
|
|
|
192
207
|
export function planSummary(tools) {
|
|
193
208
|
if (!tools.length)
|
|
194
209
|
return;
|
|
195
|
-
const
|
|
196
|
-
write(header + '\n');
|
|
210
|
+
const lines = [gray(`─ plan (${tools.length} action${tools.length === 1 ? '' : 's'})`)];
|
|
197
211
|
for (const t of tools) {
|
|
198
212
|
const dot = DELETE_TOOLS.has(t.name) ? red('◦') : EDIT_TOOLS.has(t.name) ? green('◦') : blue('◦');
|
|
199
|
-
|
|
200
|
-
write(` ${dot} ${gray(label)}\n`);
|
|
213
|
+
lines.push(` ${dot} ${gray(toolLabel(t.name, t.args))}`);
|
|
201
214
|
}
|
|
215
|
+
write(lines.join('\n') + '\n');
|
|
202
216
|
}
|
|
203
217
|
const DIFF_CTX = 2;
|
|
204
218
|
const DIFF_MAX = 40;
|
|
205
219
|
function printUpdateDiff(filePath, oldText, newText) {
|
|
206
220
|
const oldLines = oldText.split('\n');
|
|
207
221
|
const newLines = newText.split('\n');
|
|
208
|
-
|
|
222
|
+
const addedCount = newLines.length;
|
|
223
|
+
const removedCount = oldLines.length;
|
|
224
|
+
const parts = [];
|
|
225
|
+
if (addedCount > 0 && removedCount > 0) {
|
|
226
|
+
parts.push(green(`+${addedCount}`), gray(' / '), red(`-${removedCount}`));
|
|
227
|
+
}
|
|
228
|
+
else if (addedCount > 0) {
|
|
229
|
+
parts.push(green(`+${addedCount} line${addedCount !== 1 ? 's' : ''}`));
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
parts.push(red(`-${removedCount} line${removedCount !== 1 ? 's' : ''}`));
|
|
233
|
+
}
|
|
234
|
+
const out = [` ${gray('└')} ${parts.join('')}\n`];
|
|
209
235
|
let fileLines = [];
|
|
210
236
|
let lineOffset = 0;
|
|
211
237
|
try {
|
|
@@ -216,47 +242,50 @@ function printUpdateDiff(filePath, oldText, newText) {
|
|
|
216
242
|
if (idx >= 0)
|
|
217
243
|
lineOffset = content.slice(0, idx).split('\n').length - 1;
|
|
218
244
|
else
|
|
219
|
-
fileLines = [];
|
|
245
|
+
fileLines = [];
|
|
220
246
|
}
|
|
221
247
|
}
|
|
222
248
|
catch { }
|
|
223
249
|
let shown = 0;
|
|
224
250
|
const ctxStart = Math.max(0, lineOffset - DIFF_CTX);
|
|
225
251
|
for (let i = ctxStart; i < lineOffset && shown < DIFF_MAX; i++, shown++) {
|
|
226
|
-
|
|
252
|
+
out.push(` ${gray(String(i + 1).padStart(4))} ${gray(fileLines[i] ?? '')}\n`);
|
|
227
253
|
}
|
|
228
254
|
for (let i = 0; i < oldLines.length && shown < DIFF_MAX; i++, shown++) {
|
|
229
|
-
|
|
255
|
+
out.push(` ${gray(String(lineOffset + i + 1).padStart(4))} ${bgRed(`- ${oldLines[i]}`)}\n`);
|
|
230
256
|
}
|
|
231
257
|
for (let i = 0; i < newLines.length && shown < DIFF_MAX; i++, shown++) {
|
|
232
|
-
|
|
258
|
+
out.push(` ${gray(String(lineOffset + i + 1).padStart(4))} ${bgGreen(`+ ${newLines[i]}`)}\n`);
|
|
233
259
|
}
|
|
234
260
|
const ctxEnd = Math.min(fileLines.length, lineOffset + oldLines.length + DIFF_CTX);
|
|
235
261
|
for (let i = lineOffset + oldLines.length; i < ctxEnd && shown < DIFF_MAX; i++, shown++) {
|
|
236
|
-
|
|
262
|
+
out.push(` ${gray(String(i + 1).padStart(4))} ${gray(fileLines[i] ?? '')}\n`);
|
|
237
263
|
}
|
|
264
|
+
return out.join('');
|
|
238
265
|
}
|
|
239
266
|
function printEditPreview(content) {
|
|
240
267
|
const lines = content.split('\n');
|
|
241
268
|
const visible = lines.slice(0, DIFF_MAX);
|
|
242
269
|
const hidden = lines.length - visible.length;
|
|
243
|
-
|
|
270
|
+
const out = [` ${gray('└')} ${green(`+${lines.length} line${lines.length !== 1 ? 's' : ''}`)}\n`];
|
|
244
271
|
visible.forEach((line, i) => {
|
|
245
|
-
|
|
272
|
+
out.push(` ${gray(String(i + 1).padStart(4))} ${bgGreen(`+ ${line}`)}\n`);
|
|
246
273
|
});
|
|
247
274
|
if (hidden > 0)
|
|
248
|
-
|
|
275
|
+
out.push(gray(` …${hidden} more line${hidden !== 1 ? 's' : ''}\n`));
|
|
276
|
+
return out.join('');
|
|
249
277
|
}
|
|
250
278
|
export function toolCallStart(name, args) {
|
|
251
279
|
const dot = DELETE_TOOLS.has(name) ? red('●') : EDIT_TOOLS.has(name) ? green('●') : blue('●');
|
|
252
|
-
|
|
280
|
+
let out = `\n${dot} ${bold(toolLabel(name, args))}\n`;
|
|
253
281
|
const a = args;
|
|
254
282
|
if (name === 'update_file' && a.old && a.new && a.path) {
|
|
255
|
-
printUpdateDiff(a.path, a.old, a.new);
|
|
283
|
+
out += printUpdateDiff(a.path, a.old, a.new);
|
|
256
284
|
}
|
|
257
|
-
else if (name === 'edit_file' && a.content && a.path) {
|
|
258
|
-
printEditPreview(a.content);
|
|
285
|
+
else if ((name === 'edit_file' || name === 'create_file') && a.content && a.path) {
|
|
286
|
+
out += printEditPreview(a.content);
|
|
259
287
|
}
|
|
288
|
+
write(out);
|
|
260
289
|
}
|
|
261
290
|
export function toolResultSummary(name, args, result) {
|
|
262
291
|
const a = args;
|