miii-cli 1.2.1 → 1.2.3
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/__tests__/integration.test.js +7 -7
- package/dist/memory/extractor.js +1 -1
- package/dist/memory/store.js +1 -1
- package/dist/parser/stream-parser.js +2 -2
- package/dist/skills/loader.js +11 -1
- package/dist/tools/index.js +16 -33
- package/dist/tui/InputBar.js +40 -6
- package/dist/tui/components/DesignTeachModal.js +88 -0
- package/dist/tui/components/InputArea.js +11 -16
- package/dist/tui/hooks/useRunLoop.js +25 -69
- package/dist/tui/hooks/useSession.js +6 -2
- package/dist/tui/hooks/useSubmit.js +65 -2
- package/dist/tui/printer.js +58 -3
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import { writeFileSync, unlinkSync, readFileSync } from 'fs';
|
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { looksCodeRelated } from '../tui/git-context.js';
|
|
5
5
|
import { tools } from '../tools/index.js';
|
|
6
|
-
//
|
|
6
|
+
// update_file uses guardPath which restricts to CWD — use a local scratch file
|
|
7
7
|
const SCRATCH = join(process.cwd(), '.miii-test-scratch.txt');
|
|
8
8
|
// ─── looksCodeRelated ─────────────────────────────────────────────────────────
|
|
9
9
|
describe('looksCodeRelated', () => {
|
|
@@ -23,9 +23,9 @@ describe('looksCodeRelated', () => {
|
|
|
23
23
|
expect(looksCodeRelated('what is the weather like in london today')).toBe(false);
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
-
// ───
|
|
27
|
-
describe('
|
|
28
|
-
const
|
|
26
|
+
// ─── update_file ───────────────────────────────────────────────────────────────
|
|
27
|
+
describe('update_file', () => {
|
|
28
|
+
const updateTool = tools.find(t => t.name === 'update_file');
|
|
29
29
|
afterEach(() => {
|
|
30
30
|
try {
|
|
31
31
|
unlinkSync(SCRATCH);
|
|
@@ -34,17 +34,17 @@ describe('patch_file', () => {
|
|
|
34
34
|
});
|
|
35
35
|
it('applies a unique patch correctly', async () => {
|
|
36
36
|
writeFileSync(SCRATCH, 'hello world\ngoodbye world\n');
|
|
37
|
-
await
|
|
37
|
+
await updateTool.execute({ path: SCRATCH, old: 'hello world', new: 'hello earth' });
|
|
38
38
|
expect(readFileSync(SCRATCH, 'utf-8')).toBe('hello earth\ngoodbye world\n');
|
|
39
39
|
});
|
|
40
40
|
it('throws when old text not found', async () => {
|
|
41
41
|
writeFileSync(SCRATCH, 'hello world\n');
|
|
42
|
-
await expect(
|
|
42
|
+
await expect(updateTool.execute({ path: SCRATCH, old: 'no such text', new: 'x' }))
|
|
43
43
|
.rejects.toThrow('old text not found');
|
|
44
44
|
});
|
|
45
45
|
it('throws on ambiguous match (2+ occurrences)', async () => {
|
|
46
46
|
writeFileSync(SCRATCH, 'hello world\nhello world\n');
|
|
47
|
-
await expect(
|
|
47
|
+
await expect(updateTool.execute({ path: SCRATCH, old: 'hello world', new: 'hi' }))
|
|
48
48
|
.rejects.toThrow('ambiguous');
|
|
49
49
|
});
|
|
50
50
|
});
|
package/dist/memory/extractor.js
CHANGED
|
@@ -6,7 +6,7 @@ Skip: trivial exchanges, transient state, tool output noise.
|
|
|
6
6
|
Max 8 facts. Be specific and concrete.
|
|
7
7
|
|
|
8
8
|
Example output:
|
|
9
|
-
["User prefers
|
|
9
|
+
["User prefers update_file over full rewrites","entry point is src/index.ts","decided to use Zod for validation"]`;
|
|
10
10
|
export function extractFacts(messages, config, model) {
|
|
11
11
|
const lines = messages
|
|
12
12
|
.filter(m => m.role !== 'system')
|
package/dist/memory/store.js
CHANGED
|
@@ -98,7 +98,7 @@ function extractFileToolArgs(text, toolName) {
|
|
|
98
98
|
.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
-
// For
|
|
101
|
+
// For update_file: extract old/new fields
|
|
102
102
|
const oldM = text.match(/"old"\s*:\s*"((?:[^"\\]|\\[\s\S])*)"/);
|
|
103
103
|
if (oldM)
|
|
104
104
|
args.old = oldM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
@@ -127,7 +127,7 @@ export function extractBareToolCall(text) {
|
|
|
127
127
|
pos = start + 1;
|
|
128
128
|
}
|
|
129
129
|
// Fallback: content-aware extraction for file-writing tools (immune to unescaped chars)
|
|
130
|
-
for (const name of ['edit_file', 'create_file', '
|
|
130
|
+
for (const name of ['edit_file', 'create_file', 'update_file']) {
|
|
131
131
|
const args = extractFileToolArgs(text, name);
|
|
132
132
|
if (args)
|
|
133
133
|
return { name, args };
|
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
|
{
|
|
@@ -79,6 +84,11 @@ const builtin = [
|
|
|
79
84
|
return `moved: ${from} → ${to}`;
|
|
80
85
|
},
|
|
81
86
|
},
|
|
87
|
+
{
|
|
88
|
+
name: 'test',
|
|
89
|
+
ns: 'builtin',
|
|
90
|
+
description: 'Run test suite — usage: /test [path]',
|
|
91
|
+
},
|
|
82
92
|
{
|
|
83
93
|
name: 'touch',
|
|
84
94
|
ns: 'default',
|
package/dist/tools/index.js
CHANGED
|
@@ -46,13 +46,13 @@ export const tools = [
|
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
name: 'edit_file',
|
|
49
|
-
description: 'Write a new file — only for files that do not exist yet. Use
|
|
49
|
+
description: 'Write a new file — only for files that do not exist yet. Use update_file to modify existing files.',
|
|
50
50
|
params: '{"path": "string", "content": "string"}',
|
|
51
51
|
execute: async ({ path, content }) => {
|
|
52
52
|
const safe = guardPath(path);
|
|
53
53
|
if (existsSync(safe)) {
|
|
54
54
|
throw new Error(`edit_file cannot overwrite existing file: ${path}\n` +
|
|
55
|
-
`Use
|
|
55
|
+
`Use update_file with <old> and <new> blocks to make targeted edits.\n` +
|
|
56
56
|
`Call read_file first to get the exact current text.`);
|
|
57
57
|
}
|
|
58
58
|
const text = content;
|
|
@@ -62,7 +62,7 @@ export const tools = [
|
|
|
62
62
|
},
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
|
-
name: '
|
|
65
|
+
name: 'update_file',
|
|
66
66
|
description: 'Replace an exact unique string in an existing file. Always call read_file first to get the exact text.',
|
|
67
67
|
params: '{"path": "string", "old": "string", "new": "string"}',
|
|
68
68
|
execute: async ({ path, old: oldStr, new: newStr }) => {
|
|
@@ -114,7 +114,8 @@ export const tools = [
|
|
|
114
114
|
params: '{"command": "string"}',
|
|
115
115
|
execute: async ({ command }) => {
|
|
116
116
|
const { stdout, stderr } = await run(command, { cwd: process.cwd(), timeout: EXEC_TIMEOUT_MS });
|
|
117
|
-
|
|
117
|
+
const out = [stdout, stderr ? `stderr: ${stderr}` : ''].filter(Boolean).join('\n').trim();
|
|
118
|
+
return out.length > 8000 ? out.slice(0, 8000) + '\n…[truncated]' : out;
|
|
118
119
|
},
|
|
119
120
|
},
|
|
120
121
|
{
|
|
@@ -278,16 +279,14 @@ export function getSystemPrompt(extra = '', extraTools = []) {
|
|
|
278
279
|
const toolDocs = allTools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
|
|
279
280
|
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
281
|
- 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 —
|
|
282
|
+
return `You are Miii — AI coding assistant.
|
|
282
283
|
|
|
283
|
-
|
|
284
|
+
Tools via:
|
|
284
285
|
<tool_call>
|
|
285
286
|
{"name": "tool_name", "args": {...}}
|
|
286
287
|
</tool_call>
|
|
287
288
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
For edit_file / create_file use <content> block:
|
|
289
|
+
File content in named blocks (not inside JSON):
|
|
291
290
|
<tool_call>
|
|
292
291
|
{"name": "edit_file", "args": {"path": "src/foo.ts"}}
|
|
293
292
|
<content>
|
|
@@ -295,9 +294,8 @@ full file content here
|
|
|
295
294
|
</content>
|
|
296
295
|
</tool_call>
|
|
297
296
|
|
|
298
|
-
For patch_file use <old> and <new> blocks:
|
|
299
297
|
<tool_call>
|
|
300
|
-
{"name": "
|
|
298
|
+
{"name": "update_file", "args": {"path": "src/foo.ts"}}
|
|
301
299
|
<old>
|
|
302
300
|
exact text to replace
|
|
303
301
|
</old>
|
|
@@ -311,26 +309,11 @@ ${toolDocs}
|
|
|
311
309
|
${deepThinkDoc}
|
|
312
310
|
|
|
313
311
|
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, patch_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}`;
|
|
312
|
+
- edit_file: new files only (errors if exists). For existing files: read_file then update_file with exact <old> text
|
|
313
|
+
- Never guess old text — always re-read immediately before patching. If "old text not found": read_file again and retry
|
|
314
|
+
- Plain text responses only. No markdown (#/*/\`), no code blocks — write code with tools, not in responses
|
|
315
|
+
- git_status/git_diff before refactors. git_status before git_commit
|
|
316
|
+
- run_tests after edits. Fix failures, retry up to 3 times
|
|
317
|
+
- web_search requires "query" key exactly. Never say you can't search — always call web_search
|
|
318
|
+
- deep_think: read-only research only, cannot edit files${extra}`;
|
|
336
319
|
}
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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
12
|
import { toolArgSummary, formatElapsed } from './printer.js';
|
|
10
13
|
import { MacroQueue } from '../tasks/queue.js';
|
|
@@ -64,7 +67,7 @@ function diffHunks(diff) {
|
|
|
64
67
|
return diff.filter((_, i) => inHunk.has(i));
|
|
65
68
|
}
|
|
66
69
|
function DiffPreview({ toolName, args }) {
|
|
67
|
-
if (toolName === '
|
|
70
|
+
if (toolName === 'update_file' && (args.old != null || args.new != null)) {
|
|
68
71
|
const path = String(args.path ?? '');
|
|
69
72
|
const diff = diffHunks(lineDiff(String(args.old ?? ''), String(args.new ?? '')));
|
|
70
73
|
const visible = diff.slice(0, MAX_DIFF_LINES);
|
|
@@ -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", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] }), _jsx(DiffPreview, { toolName: permissionRequest.toolName, args: permissionRequest.args })] })) : (status === 'thinking' || status === 'tool') ? (_jsx(Box, { paddingX: 1, gap: 1, children: status === 'thinking'
|
|
139
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');
|
|
@@ -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
|
|
@@ -453,10 +448,10 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
453
448
|
const cols = stdout.columns ?? 80;
|
|
454
449
|
const availWidth = Math.max(20, cols - 4); // paddingX(2) + "> "(2)
|
|
455
450
|
const isProcessing = status !== 'idle';
|
|
456
|
-
const promptColor =
|
|
451
|
+
const promptColor = permissionRequest ? 'yellow' : designTeach ? 'cyan' : isProcessing ? 'yellow' : 'green';
|
|
457
452
|
const inHistory = historyIdx !== -1;
|
|
458
|
-
const hint =
|
|
459
|
-
? '
|
|
453
|
+
const hint = designTeach
|
|
454
|
+
? 'enter submit answer esc skip'
|
|
460
455
|
: permissionRequest
|
|
461
456
|
? 'y approve once a approve for session n deny'
|
|
462
457
|
: isProcessing
|
|
@@ -475,7 +470,7 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
475
470
|
const pastePreview = pasteRef.current
|
|
476
471
|
? pasteRef.current.split('\n')[0].slice(0, cols - 6)
|
|
477
472
|
: '';
|
|
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" })] })) :
|
|
473
|
+
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
474
|
? _jsxs(Text, { children: [_jsx(Text, { color: "cyan", dimColor: true, children: "watching\u2026 " }), _jsx(Text, { children: "\u2588" })] })
|
|
480
475
|
: _jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
|
|
481
476
|
? viewportLine(line, cursor.col, availWidth, isActive)
|
|
@@ -3,44 +3,39 @@ 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
|
-
const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', '
|
|
9
|
+
const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'update_file', 'delete_file']);
|
|
10
10
|
const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
|
|
11
|
-
const PERMISSION_TOOLS = new Set(['edit_file', '
|
|
12
|
-
const CHECKPOINT_TOOLS = new Set(['edit_file', '
|
|
11
|
+
const PERMISSION_TOOLS = new Set(['edit_file', 'update_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
|
|
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
14
|
const EPHEMERAL_PATTERN = /^Tool (read_file|list_files|run_tests) result:|^\[current state of|^\[Context compacted/;
|
|
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({
|
|
@@ -115,7 +102,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
115
102
|
if (hasFencedCode && depth < MAX_TOOL_DEPTH - 1) {
|
|
116
103
|
const nudge = {
|
|
117
104
|
role: 'user',
|
|
118
|
-
content: 'You showed code in your response but did not use any file tools. Use edit_file or
|
|
105
|
+
content: 'You showed code in your response but did not use any file tools. Use edit_file or update_file to actually write the changes to disk.',
|
|
119
106
|
};
|
|
120
107
|
await runLoop([...msgs, { role: 'assistant', content: fullText }, nudge], depth + 1, goal);
|
|
121
108
|
return;
|
|
@@ -165,9 +152,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
165
152
|
}
|
|
166
153
|
if (tool) {
|
|
167
154
|
try {
|
|
168
|
-
// Guard: for
|
|
155
|
+
// Guard: for update_file, verify old text still matches before executing.
|
|
169
156
|
// If stale, inject fresh file content and skip — model will retry.
|
|
170
|
-
if (tc.name === '
|
|
157
|
+
if (tc.name === 'update_file') {
|
|
171
158
|
const filePath = tc.args.path;
|
|
172
159
|
const oldText = tc.args.old;
|
|
173
160
|
if (filePath && oldText && existsSync(filePath)) {
|
|
@@ -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: `
|
|
166
|
+
next.push({ role: 'user', content: `update_file failed: old text not found in ${filePath}. The file content above is the current state. Retry update_file with the correct exact text.` });
|
|
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: `
|
|
172
|
+
next.push({ role: 'user', content: `update_file failed: old text matches ${occurrences} locations in ${filePath}. Use more surrounding context to make old text unique, then retry.` });
|
|
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
|
}
|
|
@@ -217,33 +203,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
217
203
|
finally {
|
|
218
204
|
setCurrentTool(undefined);
|
|
219
205
|
}
|
|
220
|
-
// Auto-run tests after file edits
|
|
221
|
-
const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
|
|
222
|
-
if (didEditFiles) {
|
|
223
|
-
const testTool = staticTools.find(t => t.name === 'run_tests');
|
|
224
|
-
if (testTool) {
|
|
225
|
-
setCurrentTool('run_tests');
|
|
226
|
-
try {
|
|
227
|
-
printer.toolCallStart('run_tests', {});
|
|
228
|
-
const testResult = await testTool.execute({});
|
|
229
|
-
if (testResult && !testResult.startsWith('(no test script') && !testResult.startsWith('(no package.json')) {
|
|
230
|
-
printer.toolResultSummary('run_tests', {}, testResult);
|
|
231
|
-
printer.toolMsg('run_tests', testResult);
|
|
232
|
-
next.push({ role: 'user', content: `Test results after edits:\n${testResult}` });
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
catch (e) {
|
|
236
|
-
const err = `run_tests error: ${e}`;
|
|
237
|
-
printer.errorMsg(err);
|
|
238
|
-
next.push({ role: 'user', content: err });
|
|
239
|
-
}
|
|
240
|
-
finally {
|
|
241
|
-
setCurrentTool(undefined);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
206
|
// For file-edit turns: slim context (system + goal + fresh file states + recent results)
|
|
246
207
|
// For non-edit turns: full next (model needs full conversational context)
|
|
208
|
+
const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
|
|
247
209
|
if (didEditFiles) {
|
|
248
210
|
const systemMsg = msgs.find(m => m.role === 'system');
|
|
249
211
|
const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '));
|
|
@@ -275,11 +237,6 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
275
237
|
permissionResolveRef.current = null;
|
|
276
238
|
setPermissionRequest(null);
|
|
277
239
|
}
|
|
278
|
-
if (compactResolveRef.current) {
|
|
279
|
-
compactResolveRef.current(false);
|
|
280
|
-
compactResolveRef.current = null;
|
|
281
|
-
setCompactRequest(null);
|
|
282
|
-
}
|
|
283
240
|
// Restore checkpointed files
|
|
284
241
|
if (checkpointRef.current.size > 0) {
|
|
285
242
|
let restored = 0;
|
|
@@ -309,6 +266,5 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
309
266
|
thinkingStartRef,
|
|
310
267
|
runLoop, handleAbort,
|
|
311
268
|
permissionRequest, resolvePermission,
|
|
312
|
-
compactRequest, resolveCompact,
|
|
313
269
|
};
|
|
314
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') {
|
|
@@ -244,6 +247,42 @@ export function useSubmit(deps) {
|
|
|
244
247
|
await runRefactor(goal);
|
|
245
248
|
return;
|
|
246
249
|
}
|
|
250
|
+
if (cmd === '/design' || cmd.startsWith('/design ')) {
|
|
251
|
+
const sub = cmd.slice(7).trim();
|
|
252
|
+
if (!sub || sub === 'teach') {
|
|
253
|
+
startDesignTeach();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Design task phase: use DESIGN.md context + impeccable principles
|
|
257
|
+
let designContext = '';
|
|
258
|
+
try {
|
|
259
|
+
const designFile = readFile(guardPath('DESIGN.md', cwd));
|
|
260
|
+
if (designFile)
|
|
261
|
+
designContext = `\n\nProject design system (from DESIGN.md):\n${designFile}\n`;
|
|
262
|
+
}
|
|
263
|
+
catch { }
|
|
264
|
+
const impeccableRules = `
|
|
265
|
+
Impeccable design rules — follow strictly:
|
|
266
|
+
- Typography: purposeful font selection, modular scale, intentional pairing. No Inter/Roboto by default.
|
|
267
|
+
- Color: OKLCH-based system, tinted neutrals, 4.5:1 contrast minimum. No generic gray-on-white.
|
|
268
|
+
- Spatial design: consistent spacing scale, clear visual hierarchy, intentional whitespace.
|
|
269
|
+
- Motion: contemporary easing (cubic-bezier not linear), respect prefers-reduced-motion.
|
|
270
|
+
- Interaction: visible focus states, loading patterns, meaningful hover states.
|
|
271
|
+
- Responsive: mobile-first, fluid typography where appropriate.
|
|
272
|
+
- UX copy: precise microcopy in labels, errors, empty states. No lorem ipsum.
|
|
273
|
+
- Anti-patterns to eliminate: nested card shadows, purple-to-blue gradients, disabled gray without reason, centered walls of text, auto-playing anything.
|
|
274
|
+
- Write distinctive, crafted UI — not generic SaaS templates.
|
|
275
|
+
- Write all code to files using tools. No code blocks in responses.`;
|
|
276
|
+
const taskPrompt = `${designContext}${impeccableRules}
|
|
277
|
+
|
|
278
|
+
Design task: ${sub}
|
|
279
|
+
|
|
280
|
+
Analyze what exists, then implement the design. Use the design system above if available. Make it distinctive and well-crafted.`;
|
|
281
|
+
printer.userMsg(`/design ${sub}`);
|
|
282
|
+
pushHistory({ role: 'user', content: taskPrompt });
|
|
283
|
+
await runLoop(buildContext(), 0, sub);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
247
286
|
if (cmd.startsWith('/think ') || cmd === '/think') {
|
|
248
287
|
const query = cmd.slice(6).trim();
|
|
249
288
|
if (!query) {
|
|
@@ -431,6 +470,30 @@ export function useSubmit(deps) {
|
|
|
431
470
|
printer.systemMsg('usage: /index build | /index status | /index search <query> | /index clear');
|
|
432
471
|
return;
|
|
433
472
|
}
|
|
473
|
+
if (cmd === '/test' || cmd.startsWith('/test ')) {
|
|
474
|
+
const testPath = cmd.slice(5).trim();
|
|
475
|
+
const testTool = (await import('../../tools/index.js')).tools.find(t => t.name === 'run_tests');
|
|
476
|
+
if (!testTool) {
|
|
477
|
+
printer.errorMsg('run_tests tool not found');
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
setStatus('tool');
|
|
481
|
+
setCurrentTool('run_tests');
|
|
482
|
+
try {
|
|
483
|
+
printer.toolCallStart('run_tests', testPath ? { path: testPath } : {});
|
|
484
|
+
const result = await testTool.execute(testPath ? { path: testPath } : {});
|
|
485
|
+
printer.toolResultSummary('run_tests', {}, result);
|
|
486
|
+
printer.toolMsg('run_tests', result);
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
printer.errorMsg(`run_tests: ${e}`);
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
setCurrentTool(undefined);
|
|
493
|
+
setStatus('idle');
|
|
494
|
+
}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
434
497
|
if (cmd === '/watch' || cmd.startsWith('/watch ')) {
|
|
435
498
|
const sub = cmd.slice(6).trim();
|
|
436
499
|
if (sub === 'stop') {
|
package/dist/tui/printer.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// ANSI-formatted stdout output — goes into terminal scrollback
|
|
2
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
3
|
let _inkWrite = null;
|
|
3
4
|
export function setInkInstance(inkWrite) {
|
|
4
5
|
_inkWrite = inkWrite;
|
|
@@ -158,7 +159,7 @@ export function assistantMsg(text) {
|
|
|
158
159
|
const tail = lines.slice(idx + 1).join('\n');
|
|
159
160
|
write(`\n${blue('●')} ${head}${tail ? '\n' + tail : ''}\n`);
|
|
160
161
|
}
|
|
161
|
-
const EDIT_TOOLS = new Set(['edit_file', '
|
|
162
|
+
const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'write_file']);
|
|
162
163
|
const DELETE_TOOLS = new Set(['delete_file', 'remove_file']);
|
|
163
164
|
function toolLabel(name, args) {
|
|
164
165
|
const a = args;
|
|
@@ -168,7 +169,7 @@ function toolLabel(name, args) {
|
|
|
168
169
|
case 'list_files': return `Listing ${a.path || '.'}`;
|
|
169
170
|
case 'create_file': return `Creating ${a.path ?? ''}`;
|
|
170
171
|
case 'edit_file': return `Writing ${a.path ?? ''}`;
|
|
171
|
-
case '
|
|
172
|
+
case 'update_file': return `Updating ${a.path ?? ''}`;
|
|
172
173
|
case 'delete_file': return `Deleting ${a.path ?? ''}`;
|
|
173
174
|
case 'move_file': return `Moving ${a.from} → ${a.to}`;
|
|
174
175
|
case 'create_folder': return `Creating folder ${a.path ?? ''}`;
|
|
@@ -199,9 +200,63 @@ export function planSummary(tools) {
|
|
|
199
200
|
write(` ${dot} ${gray(label)}\n`);
|
|
200
201
|
}
|
|
201
202
|
}
|
|
203
|
+
const DIFF_CTX = 2;
|
|
204
|
+
const DIFF_MAX = 40;
|
|
205
|
+
function printUpdateDiff(filePath, oldText, newText) {
|
|
206
|
+
const oldLines = oldText.split('\n');
|
|
207
|
+
const newLines = newText.split('\n');
|
|
208
|
+
write(gray(` └ Added ${newLines.length} line${newLines.length !== 1 ? 's' : ''}, removed ${oldLines.length} line${oldLines.length !== 1 ? 's' : ''}\n`));
|
|
209
|
+
let fileLines = [];
|
|
210
|
+
let lineOffset = 0;
|
|
211
|
+
try {
|
|
212
|
+
if (existsSync(filePath)) {
|
|
213
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
214
|
+
fileLines = content.split('\n');
|
|
215
|
+
const idx = content.indexOf(oldText);
|
|
216
|
+
if (idx >= 0)
|
|
217
|
+
lineOffset = content.slice(0, idx).split('\n').length - 1;
|
|
218
|
+
else
|
|
219
|
+
fileLines = []; // old text not in file — skip context lines
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch { }
|
|
223
|
+
let shown = 0;
|
|
224
|
+
const ctxStart = Math.max(0, lineOffset - DIFF_CTX);
|
|
225
|
+
for (let i = ctxStart; i < lineOffset && shown < DIFF_MAX; i++, shown++) {
|
|
226
|
+
write(gray(` ${String(i + 1).padStart(4)} ${fileLines[i] ?? ''}\n`));
|
|
227
|
+
}
|
|
228
|
+
for (let i = 0; i < oldLines.length && shown < DIFF_MAX; i++, shown++) {
|
|
229
|
+
write(` ${gray(String(lineOffset + i + 1).padStart(4))} ${red('- ')}${red(oldLines[i])}\n`);
|
|
230
|
+
}
|
|
231
|
+
for (let i = 0; i < newLines.length && shown < DIFF_MAX; i++, shown++) {
|
|
232
|
+
write(` ${gray(String(lineOffset + i + 1).padStart(4))} ${green('+ ')}${green(newLines[i])}\n`);
|
|
233
|
+
}
|
|
234
|
+
const ctxEnd = Math.min(fileLines.length, lineOffset + oldLines.length + DIFF_CTX);
|
|
235
|
+
for (let i = lineOffset + oldLines.length; i < ctxEnd && shown < DIFF_MAX; i++, shown++) {
|
|
236
|
+
write(gray(` ${String(i + 1).padStart(4)} ${fileLines[i] ?? ''}\n`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function printEditPreview(content) {
|
|
240
|
+
const lines = content.split('\n');
|
|
241
|
+
const visible = lines.slice(0, DIFF_MAX);
|
|
242
|
+
const hidden = lines.length - visible.length;
|
|
243
|
+
write(gray(` └ ${lines.length} line${lines.length !== 1 ? 's' : ''}\n`));
|
|
244
|
+
visible.forEach((line, i) => {
|
|
245
|
+
write(` ${gray(String(i + 1).padStart(4))} ${green('+ ')}${green(line)}\n`);
|
|
246
|
+
});
|
|
247
|
+
if (hidden > 0)
|
|
248
|
+
write(gray(` …${hidden} more line${hidden !== 1 ? 's' : ''}\n`));
|
|
249
|
+
}
|
|
202
250
|
export function toolCallStart(name, args) {
|
|
203
251
|
const dot = DELETE_TOOLS.has(name) ? red('●') : EDIT_TOOLS.has(name) ? green('●') : blue('●');
|
|
204
252
|
write(`\n${dot} ${bold(toolLabel(name, args))}\n`);
|
|
253
|
+
const a = args;
|
|
254
|
+
if (name === 'update_file' && a.old && a.new && a.path) {
|
|
255
|
+
printUpdateDiff(a.path, a.old, a.new);
|
|
256
|
+
}
|
|
257
|
+
else if (name === 'edit_file' && a.content && a.path) {
|
|
258
|
+
printEditPreview(a.content);
|
|
259
|
+
}
|
|
205
260
|
}
|
|
206
261
|
export function toolResultSummary(name, args, result) {
|
|
207
262
|
const a = args;
|
|
@@ -219,7 +274,7 @@ export function toolResultSummary(name, args, result) {
|
|
|
219
274
|
summary = `Created file · ${n} line${n === 1 ? '' : 's'}`;
|
|
220
275
|
break;
|
|
221
276
|
}
|
|
222
|
-
case '
|
|
277
|
+
case 'update_file':
|
|
223
278
|
summary = lines[0] ?? 'Applied patch';
|
|
224
279
|
break;
|
|
225
280
|
case 'delete_file':
|