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.
@@ -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
- // patch_file uses guardPath which restricts to CWD — use a local scratch file
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
- // ─── patch_file ───────────────────────────────────────────────────────────────
27
- describe('patch_file', () => {
28
- const patchTool = tools.find(t => t.name === 'patch_file');
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 patchTool.execute({ path: SCRATCH, old: 'hello world', new: 'hello earth' });
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(patchTool.execute({ path: SCRATCH, old: 'no such text', new: 'x' }))
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(patchTool.execute({ path: SCRATCH, old: 'hello world', new: 'hi' }))
47
+ await expect(updateTool.execute({ path: SCRATCH, old: 'hello world', new: 'hi' }))
48
48
  .rejects.toThrow('ambiguous');
49
49
  });
50
50
  });
@@ -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 patch_file over full rewrites","entry point is src/index.ts","decided to use Zod for validation"]`;
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')
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
- const MAX_FACTS = 200;
3
+ const MAX_FACTS = 50;
4
4
  function memoryPath(projectDir) {
5
5
  return join(projectDir, 'memory.json');
6
6
  }
@@ -98,7 +98,7 @@ function extractFileToolArgs(text, toolName) {
98
98
  .replace(/\\"/g, '"').replace(/\\\\/g, '\\');
99
99
  }
100
100
  }
101
- // For patch_file: extract old/new fields
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', 'patch_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 };
@@ -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',
@@ -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 patch_file to modify existing files.',
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 patch_file with <old> and <new> blocks to make targeted edits.\n` +
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: 'patch_file',
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
- return [stdout, stderr ? `stderr: ${stderr}` : ''].filter(Boolean).join('\n').trim();
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 — a fast, local AI coding assistant.
282
+ return `You are Miii — AI coding assistant.
282
283
 
283
- Use tools by emitting:
284
+ Tools via:
284
285
  <tool_call>
285
286
  {"name": "tool_name", "args": {...}}
286
287
  </tool_call>
287
288
 
288
- Put file content in named blocks (never inside JSON — avoids escaping errors):
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": "patch_file", "args": {"path": "src/foo.ts"}}
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 only works on NEW files it throws an error if the file exists. Never call it on existing files
315
- - To modify any existing file: call read_file first, then patch_file with the exact text from that read as the <old> block
316
- - Never guess or reuse old text from earlier in the conversation always re-read immediately before patching
317
- - If patch_file reports "old text not found", call read_file again and retry with the exact current text
318
- - Never delete without confirming
319
- - Use git_status and git_diff before any refactor to understand what has already changed
320
- - Use git_log to understand recent history before suggesting changes
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 blockswrite 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
  }
@@ -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 === 'patch_file' && (args.old != null || args.new != null)) {
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 { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools);
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, compactRequest, resolveCompact, } = useRunLoop(config, currentModelRef, pushHistory, allTools, abortRef);
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 })] })) : compactRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: "context is large" }), _jsxs(Text, { color: "gray", children: ["(~", compactRequest.messageCount, "k chars)"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "compact to keep responses fast, or keep full history" })] })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] }), _jsx(DiffPreview, { toolName: permissionRequest.toolName, args: permissionRequest.args })] })) : (status === 'thinking' || status === 'tool') ? (_jsx(Box, { paddingX: 1, gap: 1, children: status === 'thinking'
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, compactRequest: compactRequest, onCompactResponse: resolveCompact, onSubmit: handleSubmit, onAbort: handleAbort, history: historyRef.current.filter(m => m.role === 'user').map(m => m.content), watchActive: watchActive })] }));
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, compactRequest, onCompactResponse, onSubmit, onAbort, history = [], watchActive = false }) {
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 = (permissionRequest || compactRequest) ? 'yellow' : isProcessing ? 'yellow' : 'green';
451
+ const promptColor = permissionRequest ? 'yellow' : designTeach ? 'cyan' : isProcessing ? 'yellow' : 'green';
457
452
  const inHistory = historyIdx !== -1;
458
- const hint = compactRequest
459
- ? 'y compact n keep full context'
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" })] })) : compactRequest ? (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "green", bold: true, children: "y yes" }), _jsx(Text, { color: "red", bold: true, children: "n no" })] })) : pasteLines > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " line", pasteLines !== 1 ? 's' : ''] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] }), pastePreview && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", pastePreview, pasteRef.current.split('\n')[0].length > cols - 6 ? '…' : ''] }))] })) : lines.length === 1 && !lines[0] ? (watchActive && isActive
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, contextSize } from '../../tasks/compactor.js';
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', 'patch_file', 'delete_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', 'patch_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
12
- const CHECKPOINT_TOOLS = new Set(['edit_file', 'patch_file', 'create_file', 'delete_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
- const approved = await new Promise(resolve => {
64
- compactResolveRef.current = resolve;
65
- setCompactRequest({ messageCount: Math.round(contextSize(contextMsgs) / 1000) });
66
- });
67
- if (approved) {
68
- printer.systemMsg('compacting context…');
69
- const toCompact = stripEphemeral(contextMsgs);
70
- msgs = await compactContext(toCompact, {
71
- provider: config.provider,
72
- model: currentModelRef.current,
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 patch_file to actually write the changes to disk.',
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 patch_file, verify old text still matches before executing.
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 === 'patch_file') {
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: `patch_file failed: old text not found in ${filePath}. The file content above is the current state. Retry patch_file with the correct exact text.` });
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: `patch_file failed: old text matches ${occurrences} locations in ${filePath}. Use more surrounding context to make old text unique, then retry.` });
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 fresh = readFileSync(filePath, 'utf-8');
201
- next.push({ role: 'user', content: `[current state of ${filePath} after edit]\n${fresh}` });
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 = 40;
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') {
@@ -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', 'patch_file', 'create_file', 'write_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 'patch_file': return `Editing ${a.path ?? ''}`;
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 'patch_file':
277
+ case 'update_file':
223
278
  summary = lines[0] ?? 'Applied patch';
224
279
  break;
225
280
  case 'delete_file':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "type": "module",
5
5
  "description": "The high-performance local AI coding agent for your terminal. Automate complex workflows with local LLMs.",
6
6
  "license": "MIT",