miii-cli 1.1.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/init.js +1 -1
- package/dist/mcp/client.js +6 -2
- package/dist/sessions.js +2 -1
- package/dist/tools/index.js +5 -3
- package/dist/tui/InputBar.js +55 -8
- package/dist/tui/components/InputArea.js +8 -3
- package/dist/tui/hooks/useSubmit.js +10 -1
- package/dist/tui/hooks/useWatch.js +119 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,6 +164,7 @@ No API keys. No account. No sign-up form. First run walks you through setup inte
|
|
|
164
164
|
| `/plan <topic>` | Structured planning mode before you write a line |
|
|
165
165
|
| `/model <name>` | Hot-swap your LLM mid-conversation |
|
|
166
166
|
| `/session <name>` | Switch between named project sessions |
|
|
167
|
+
| `/watch <path>` | Monitor files for changes and trigger agent reactions |
|
|
167
168
|
| `@filename` | Inject any file directly into context |
|
|
168
169
|
|
|
169
170
|
---
|
package/dist/init.js
CHANGED
|
@@ -15,7 +15,7 @@ import { loadMCPTools } from './mcp/client.js';
|
|
|
15
15
|
import { needsSetup, runSetup } from './setup.js';
|
|
16
16
|
const require = createRequire(import.meta.url);
|
|
17
17
|
const UPDATE_CACHE = join(homedir(), '.config', 'miii', 'update-check.json');
|
|
18
|
-
const CHECK_INTERVAL_MS =
|
|
18
|
+
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1h
|
|
19
19
|
function semverGt(a, b) {
|
|
20
20
|
const pa = a.split('.').map(Number);
|
|
21
21
|
const pb = b.split('.').map(Number);
|
package/dist/mcp/client.js
CHANGED
|
@@ -68,9 +68,13 @@ export class MCPClient {
|
|
|
68
68
|
send(method, params) {
|
|
69
69
|
return new Promise((resolve, reject) => {
|
|
70
70
|
const id = this.nextId++;
|
|
71
|
-
|
|
71
|
+
let timer;
|
|
72
|
+
this.pending.set(id, {
|
|
73
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
74
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
75
|
+
});
|
|
72
76
|
this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
73
|
-
setTimeout(() => {
|
|
77
|
+
timer = setTimeout(() => {
|
|
74
78
|
if (this.pending.has(id)) {
|
|
75
79
|
this.pending.delete(id);
|
|
76
80
|
reject(new Error(`MCP timeout: ${method}`));
|
package/dist/sessions.js
CHANGED
|
@@ -52,9 +52,10 @@ export function loadSession(projectDir, name) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
export function saveSession(projectDir, name, messages) {
|
|
55
|
+
const safeName = sanitizeName(name);
|
|
55
56
|
ensureProjectDir(projectDir);
|
|
56
57
|
try {
|
|
57
|
-
writeFileSync(join(sessionsDir(projectDir), `${
|
|
58
|
+
writeFileSync(join(sessionsDir(projectDir), `${safeName}.json`), JSON.stringify(messages), { mode: 0o600 });
|
|
58
59
|
}
|
|
59
60
|
catch { }
|
|
60
61
|
}
|
package/dist/tools/index.js
CHANGED
|
@@ -68,8 +68,10 @@ export const tools = [
|
|
|
68
68
|
execute: async ({ path, old: oldStr, new: newStr }) => {
|
|
69
69
|
const safe = guardPath(path);
|
|
70
70
|
const current = readFile(safe);
|
|
71
|
-
if (
|
|
72
|
-
throw new Error(`file not found
|
|
71
|
+
if (current === null)
|
|
72
|
+
throw new Error(`file not found: ${path}`);
|
|
73
|
+
if (current === '')
|
|
74
|
+
throw new Error(`file empty: ${path}`);
|
|
73
75
|
const old = oldStr;
|
|
74
76
|
const count = current.split(old).length - 1;
|
|
75
77
|
if (count === 0) {
|
|
@@ -79,7 +81,7 @@ export const tools = [
|
|
|
79
81
|
if (count > 1) {
|
|
80
82
|
throw new Error(`ambiguous: ${count} matches found in ${path} — extend <old> block with more surrounding lines to make it unique`);
|
|
81
83
|
}
|
|
82
|
-
const updated = current.replace(old, newStr);
|
|
84
|
+
const updated = current.replace(old, String(newStr));
|
|
83
85
|
writeFile(safe, updated);
|
|
84
86
|
// Compute affected line range for the snippet
|
|
85
87
|
const startLine = current.slice(0, current.indexOf(old)).split('\n').length;
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -16,6 +16,7 @@ import { useRunLoop } from './hooks/useRunLoop.js';
|
|
|
16
16
|
import { useRefactor } from './hooks/useRefactor.js';
|
|
17
17
|
import { useGit } from './hooks/useGit.js';
|
|
18
18
|
import { useSubmit } from './hooks/useSubmit.js';
|
|
19
|
+
import { useWatch } from './hooks/useWatch.js';
|
|
19
20
|
import { runDeepThink } from './deepThink.js';
|
|
20
21
|
import { setInkInstance } from './printer.js';
|
|
21
22
|
import { createSearchCodebaseTool } from '../index/tool.js';
|
|
@@ -30,16 +31,60 @@ function formatElapsed(ms) {
|
|
|
30
31
|
const rem = s % 60;
|
|
31
32
|
return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
|
|
32
33
|
}
|
|
33
|
-
const MAX_DIFF_LINES =
|
|
34
|
+
const MAX_DIFF_LINES = 40;
|
|
35
|
+
const DIFF_CTX = 2;
|
|
36
|
+
function lineDiff(oldText, newText) {
|
|
37
|
+
const a = oldText.split('\n');
|
|
38
|
+
const b = newText.split('\n');
|
|
39
|
+
const m = a.length, n = b.length;
|
|
40
|
+
if (m * n > 10000) {
|
|
41
|
+
return [...a.map(line => ({ type: 'del', line })), ...b.map(line => ({ type: 'add', line }))];
|
|
42
|
+
}
|
|
43
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
44
|
+
for (let i = m - 1; i >= 0; i--)
|
|
45
|
+
for (let j = n - 1; j >= 0; j--)
|
|
46
|
+
dp[i][j] = a[i] === b[j] ? 1 + dp[i + 1][j + 1] : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
47
|
+
const result = [];
|
|
48
|
+
let i = 0, j = 0;
|
|
49
|
+
while (i < m || j < n) {
|
|
50
|
+
if (i < m && j < n && a[i] === b[j]) {
|
|
51
|
+
result.push({ type: 'eq', line: a[i++] });
|
|
52
|
+
j++;
|
|
53
|
+
}
|
|
54
|
+
else if (j < n && (i >= m || dp[i + 1][j] <= dp[i][j + 1])) {
|
|
55
|
+
result.push({ type: 'add', line: b[j++] });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
result.push({ type: 'del', line: a[i++] });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
function diffHunks(diff) {
|
|
64
|
+
const changedIdxs = diff.reduce((acc, d, i) => { if (d.type !== 'eq')
|
|
65
|
+
acc.push(i); return acc; }, []);
|
|
66
|
+
if (!changedIdxs.length)
|
|
67
|
+
return [];
|
|
68
|
+
const inHunk = new Set();
|
|
69
|
+
for (const ci of changedIdxs)
|
|
70
|
+
for (let k = Math.max(0, ci - DIFF_CTX); k <= Math.min(diff.length - 1, ci + DIFF_CTX); k++)
|
|
71
|
+
inHunk.add(k);
|
|
72
|
+
return diff.filter((_, i) => inHunk.has(i));
|
|
73
|
+
}
|
|
34
74
|
function DiffPreview({ toolName, args }) {
|
|
35
|
-
if (toolName === 'patch_file' && (args.old || args.new)) {
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
75
|
+
if (toolName === 'patch_file' && (args.old != null || args.new != null)) {
|
|
76
|
+
const path = String(args.path ?? '');
|
|
77
|
+
const diff = diffHunks(lineDiff(String(args.old ?? ''), String(args.new ?? '')));
|
|
78
|
+
const visible = diff.slice(0, MAX_DIFF_LINES);
|
|
79
|
+
const hidden = diff.length - visible.length;
|
|
80
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [" ", path] }), visible.map((d, i) => (_jsxs(Text, { color: d.type === 'del' ? 'red' : d.type === 'add' ? 'green' : 'gray', dimColor: d.type === 'eq', children: [d.type === 'del' ? '- ' : d.type === 'add' ? '+ ' : ' ', d.line.slice(0, 76)] }, i))), hidden > 0 && _jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", hidden, " more line", hidden === 1 ? '' : 's'] })] }));
|
|
39
81
|
}
|
|
40
82
|
if ((toolName === 'edit_file' || toolName === 'create_file') && args.content) {
|
|
41
|
-
const
|
|
42
|
-
|
|
83
|
+
const path = String(args.path ?? '');
|
|
84
|
+
const lines = String(args.content).split('\n');
|
|
85
|
+
const visible = lines.slice(0, MAX_DIFF_LINES);
|
|
86
|
+
const hidden = lines.length - visible.length;
|
|
87
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [" ", path] }), visible.map((line, i) => (_jsxs(Text, { color: "green", children: ["+ ", line.slice(0, 76)] }, i))), hidden > 0 && _jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", hidden, " more line", hidden === 1 ? '' : 's'] })] }));
|
|
43
88
|
}
|
|
44
89
|
return null;
|
|
45
90
|
}
|
|
@@ -79,6 +124,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
79
124
|
setStatus, setTaskLabel, setCurrentTool, pushHistory,
|
|
80
125
|
});
|
|
81
126
|
const { handleGit } = useGit({ pushHistory, buildContext, runLoop });
|
|
127
|
+
const { watchActive, startWatch, stopWatch } = useWatch(cwd, { runLoop, buildContext, pushHistory });
|
|
82
128
|
const { handleSubmit } = useSubmit({
|
|
83
129
|
config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel,
|
|
84
130
|
historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef,
|
|
@@ -87,6 +133,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
87
133
|
setStatus, setTaskLabel, setCurrentTool,
|
|
88
134
|
runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig,
|
|
89
135
|
setConfigOpen, updateMemory,
|
|
136
|
+
startWatch, stopWatch, watchActive,
|
|
90
137
|
});
|
|
91
138
|
const skillList = skills.list();
|
|
92
139
|
return (_jsxs(Box, { flexDirection: "column", children: [configOpen ? (_jsxs(_Fragment, { children: [_jsx(ConfigPicker, { config: config, currentModel: currentModel, tavilyKey: tavilyKey, onUpdate: ({ model, ...configPatch }) => {
|
|
@@ -98,5 +145,5 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
98
145
|
}
|
|
99
146
|
}, 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') ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { children: status === 'thinking'
|
|
100
147
|
? _jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", children: [SPARKLE[tick % SPARKLE.length], " "] }), _jsx(Text, { color: "gray", dimColor: true, italic: true, children: THINKING_PHRASES[phraseSeq[Math.floor(tick / 62) % phraseSeq.length]] })] })
|
|
101
|
-
: _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_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) })] }));
|
|
148
|
+
: _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_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 })] }));
|
|
102
149
|
}
|
|
@@ -23,6 +23,7 @@ const BUILTIN_COMMANDS = [
|
|
|
23
23
|
{ ns: 'builtin', name: 'plan', description: 'enter planning mode — AI helps think through a goal step-by-step' },
|
|
24
24
|
{ ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor — plans, reads, then edits — /refactor <goal>' },
|
|
25
25
|
{ ns: 'builtin', name: 'think', description: 'deep research before answering — reads files + optional web — /think <query>' },
|
|
26
|
+
{ ns: 'builtin', name: 'watch', description: 'watch for file changes, run tests, auto-fix failures — /watch stop to cancel' },
|
|
26
27
|
// ── Git ───────────────────────────────────────────────────────────────────
|
|
27
28
|
{ ns: 'git', name: 'status', description: 'show git working tree status (modified, staged, untracked)' },
|
|
28
29
|
{ ns: 'git', name: 'diff', description: 'show unstaged changes as a diff' },
|
|
@@ -55,7 +56,7 @@ function wordEndAfter(line, col) {
|
|
|
55
56
|
i++;
|
|
56
57
|
return i;
|
|
57
58
|
}
|
|
58
|
-
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [] }) {
|
|
59
|
+
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [], watchActive = false }) {
|
|
59
60
|
const [lines, setLines] = useState(['']);
|
|
60
61
|
const [cursor, setCursor] = useState({ row: 0, col: 0 });
|
|
61
62
|
const [overlay, setOverlay] = useState('none');
|
|
@@ -468,11 +469,15 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
468
469
|
? `history ${historyIdx + 1}/${history.length} ↑↓ navigate esc clear`
|
|
469
470
|
: planningMode
|
|
470
471
|
? 'planning mode /plan:done exit'
|
|
471
|
-
:
|
|
472
|
+
: watchActive
|
|
473
|
+
? 'watch active /watch stop to cancel'
|
|
474
|
+
: '? for shortcuts';
|
|
472
475
|
const pastePreview = pasteRef.current
|
|
473
476
|
? pasteRef.current.split('\n')[0].slice(0, cols - 6)
|
|
474
477
|
: '';
|
|
475
|
-
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] ? (
|
|
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
|
|
479
|
+
? _jsxs(Text, { children: [_jsx(Text, { color: "cyan", dimColor: true, children: "watching\u2026 " }), _jsx(Text, { children: "\u2588" })] })
|
|
480
|
+
: _jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
|
|
476
481
|
? viewportLine(line, cursor.col, availWidth, isActive)
|
|
477
482
|
: line.length > availWidth ? '…' + line.slice(line.length - availWidth + 1) : line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Text, { color: "gray", dimColor: true, children: [" ", hint] })] }));
|
|
478
483
|
}
|
|
@@ -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, } = 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, } = depsRef.current;
|
|
41
41
|
const cmd = text.trim();
|
|
42
42
|
if (cmd === '?') {
|
|
43
43
|
printer.systemMsg('shortcuts:\n' +
|
|
@@ -431,6 +431,15 @@ export function useSubmit(deps) {
|
|
|
431
431
|
printer.systemMsg('usage: /index build | /index status | /index search <query> | /index clear');
|
|
432
432
|
return;
|
|
433
433
|
}
|
|
434
|
+
if (cmd === '/watch' || cmd.startsWith('/watch ')) {
|
|
435
|
+
const sub = cmd.slice(6).trim();
|
|
436
|
+
if (sub === 'stop') {
|
|
437
|
+
stopWatch();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
startWatch();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
434
443
|
if (text.startsWith('/')) {
|
|
435
444
|
const [slashCmd, ...rest] = text.slice(1).split(' ');
|
|
436
445
|
const skill = skills.get(slashCmd);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { watch } from 'fs';
|
|
3
|
+
import { tools as staticTools } from '../../tools/index.js';
|
|
4
|
+
import * as printer from '../printer.js';
|
|
5
|
+
const WATCH_DEBOUNCE_MS = 600;
|
|
6
|
+
const IGNORE_DIRS = new Set([
|
|
7
|
+
'node_modules', '.git', 'dist', '.next', 'build', 'coverage',
|
|
8
|
+
'__pycache__', '.turbo', '.cache', '.parcel-cache', 'out',
|
|
9
|
+
]);
|
|
10
|
+
const WATCH_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|c|cpp|h|hpp|css|scss)$/;
|
|
11
|
+
function testsFailed(output) {
|
|
12
|
+
// Explicit pass with no failures → passing
|
|
13
|
+
if (/\b0 fail/i.test(output))
|
|
14
|
+
return false;
|
|
15
|
+
if (/\d+ pass/i.test(output) && !/\d+ fail/i.test(output))
|
|
16
|
+
return false;
|
|
17
|
+
return /\d+ fail|FAIL\b|✕|✗|\bfailing\b|AssertionError/i.test(output);
|
|
18
|
+
}
|
|
19
|
+
export function useWatch(cwd, deps) {
|
|
20
|
+
const [watchActive, setWatchActive] = useState(false);
|
|
21
|
+
const watcherRef = useRef(null);
|
|
22
|
+
const debounceRef = useRef(null);
|
|
23
|
+
const changedRef = useRef(new Set());
|
|
24
|
+
const fixRunningRef = useRef(false);
|
|
25
|
+
// Always-fresh deps via ref — watcher callback is set up once
|
|
26
|
+
const depsRef = useRef(deps);
|
|
27
|
+
useEffect(() => { depsRef.current = deps; });
|
|
28
|
+
const stopWatch = useCallback(() => {
|
|
29
|
+
if (debounceRef.current) {
|
|
30
|
+
clearTimeout(debounceRef.current);
|
|
31
|
+
debounceRef.current = null;
|
|
32
|
+
}
|
|
33
|
+
watcherRef.current?.close();
|
|
34
|
+
watcherRef.current = null;
|
|
35
|
+
changedRef.current.clear();
|
|
36
|
+
fixRunningRef.current = false;
|
|
37
|
+
setWatchActive(false);
|
|
38
|
+
printer.systemMsg('watch: stopped');
|
|
39
|
+
}, []);
|
|
40
|
+
const startWatch = useCallback(() => {
|
|
41
|
+
if (watcherRef.current) {
|
|
42
|
+
printer.systemMsg('watch: already active — /watch stop to cancel');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let watcher;
|
|
46
|
+
try {
|
|
47
|
+
watcher = watch(cwd, { recursive: true }, (_event, filename) => {
|
|
48
|
+
if (!filename)
|
|
49
|
+
return;
|
|
50
|
+
const parts = filename.split('/');
|
|
51
|
+
if (parts.some(p => IGNORE_DIRS.has(p) || p.startsWith('.')))
|
|
52
|
+
return;
|
|
53
|
+
if (!WATCH_EXT.test(filename))
|
|
54
|
+
return;
|
|
55
|
+
changedRef.current.add(filename);
|
|
56
|
+
if (debounceRef.current)
|
|
57
|
+
clearTimeout(debounceRef.current);
|
|
58
|
+
debounceRef.current = setTimeout(async () => {
|
|
59
|
+
debounceRef.current = null;
|
|
60
|
+
const changed = [...changedRef.current];
|
|
61
|
+
changedRef.current.clear();
|
|
62
|
+
const testTool = staticTools.find(t => t.name === 'run_tests');
|
|
63
|
+
if (!testTool)
|
|
64
|
+
return;
|
|
65
|
+
const label = changed.length > 3
|
|
66
|
+
? `${changed.slice(0, 3).join(', ')} +${changed.length - 3} more`
|
|
67
|
+
: changed.join(', ');
|
|
68
|
+
printer.systemMsg(`watch: ${label} — running tests`);
|
|
69
|
+
if (fixRunningRef.current) {
|
|
70
|
+
printer.systemMsg('watch: fix in progress — skipping this cycle');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
fixRunningRef.current = true;
|
|
74
|
+
try {
|
|
75
|
+
const result = await testTool.execute({});
|
|
76
|
+
if (!result || result.startsWith('(no '))
|
|
77
|
+
return;
|
|
78
|
+
if (testsFailed(result)) {
|
|
79
|
+
printer.systemMsg('watch: tests failing — triggering fix');
|
|
80
|
+
const { pushHistory, buildContext, runLoop } = depsRef.current;
|
|
81
|
+
const fixMsg = `Tests are failing after changes to: ${changed.join(', ')}\n\n` +
|
|
82
|
+
`Test output:\n${result}\n\n` +
|
|
83
|
+
`Read the failing files and fix the issues.`;
|
|
84
|
+
pushHistory({ role: 'user', content: fixMsg });
|
|
85
|
+
await runLoop(buildContext(), 0, 'fix failing tests');
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
printer.systemMsg('watch: tests passing');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
printer.errorMsg(`watch: ${e}`);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
fixRunningRef.current = false;
|
|
96
|
+
}
|
|
97
|
+
}, WATCH_DEBOUNCE_MS);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
printer.errorMsg(`watch: failed to start: ${e}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
watcher.on('error', (err) => {
|
|
105
|
+
printer.errorMsg(`watch: ${err.message}`);
|
|
106
|
+
stopWatch();
|
|
107
|
+
});
|
|
108
|
+
watcherRef.current = watcher;
|
|
109
|
+
setWatchActive(true);
|
|
110
|
+
printer.systemMsg(`watch: active — monitoring ${cwd.replace(process.env.HOME ?? '', '~')}`);
|
|
111
|
+
}, [cwd, stopWatch]);
|
|
112
|
+
// Cleanup on unmount
|
|
113
|
+
useEffect(() => () => {
|
|
114
|
+
watcherRef.current?.close();
|
|
115
|
+
if (debounceRef.current)
|
|
116
|
+
clearTimeout(debounceRef.current);
|
|
117
|
+
}, []);
|
|
118
|
+
return { watchActive, startWatch, stopWatch };
|
|
119
|
+
}
|