miii-cli 1.0.0 → 1.0.2
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 +72 -49
- package/dist/config.js +18 -1
- package/dist/init.js +17 -1
- package/dist/llm/stream.js +41 -0
- package/dist/mcp/client.js +110 -0
- package/dist/setup.js +183 -0
- package/dist/tools/index.js +3 -2
- package/dist/tui/InputBar.js +27 -12
- package/dist/tui/components/ConfigPicker.js +178 -0
- package/dist/tui/components/InputArea.js +58 -40
- package/dist/tui/hooks/useRunLoop.js +61 -2
- package/dist/tui/hooks/useSession.js +6 -6
- package/dist/tui/hooks/useSubmit.js +62 -25
- package/package.json +2 -2
package/dist/tui/InputBar.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useRef, useMemo, useEffect } from 'react';
|
|
2
|
+
import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
|
3
3
|
import { Box, Text, useStdout } from 'ink';
|
|
4
4
|
import { InputArea } from './components/InputArea.js';
|
|
5
5
|
import { ModelPicker } from './components/ModelPicker.js';
|
|
6
|
+
import { ConfigPicker } from './components/ConfigPicker.js';
|
|
6
7
|
import { Divider } from './components/StatusBar.js';
|
|
7
|
-
import { tools } from '../tools/index.js';
|
|
8
|
+
import { tools, getSystemPrompt } from '../tools/index.js';
|
|
8
9
|
import { toolArgSummary } from './printer.js';
|
|
9
10
|
import { MacroQueue } from '../tasks/queue.js';
|
|
10
11
|
import { TaskExecutor } from '../tasks/executor.js';
|
|
@@ -18,6 +19,8 @@ import { useSubmit } from './hooks/useSubmit.js';
|
|
|
18
19
|
import { runDeepThink } from './deepThink.js';
|
|
19
20
|
import { setInkInstance } from './printer.js';
|
|
20
21
|
import { createSearchCodebaseTool } from '../index/tool.js';
|
|
22
|
+
import { saveConfig } from '../config.js';
|
|
23
|
+
import { getTavilyKey, saveTavilyKey } from '../tavily/client.js';
|
|
21
24
|
function formatElapsed(ms) {
|
|
22
25
|
const s = Math.floor(ms / 1000);
|
|
23
26
|
if (s < 60)
|
|
@@ -26,18 +29,22 @@ function formatElapsed(ms) {
|
|
|
26
29
|
const rem = s % 60;
|
|
27
30
|
return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
|
|
28
31
|
}
|
|
29
|
-
export function InputBar({ config, skills, cwd, session, version }) {
|
|
32
|
+
export function InputBar({ config: initialConfig, skills, cwd, session, version, mcpTools = [] }) {
|
|
33
|
+
const [config, setConfig] = useState(initialConfig);
|
|
30
34
|
const { stdout, write: stdoutWrite } = useStdout();
|
|
31
35
|
const cols = stdout.columns ?? 80;
|
|
32
36
|
useEffect(() => { setInkInstance(stdoutWrite); }, []);
|
|
33
37
|
const phraseSeq = useMemo(() => Array.from({ length: 100 }, () => Math.floor(Math.random() * THINKING_PHRASES.length)), []);
|
|
34
38
|
const [planningMode, setPlanningMode] = useState(false);
|
|
39
|
+
const [configOpen, setConfigOpen] = useState(false);
|
|
40
|
+
const [tavilyKey, setTavilyKey] = useState(() => getTavilyKey());
|
|
35
41
|
const macroQueueRef = useRef(new MacroQueue());
|
|
36
42
|
const executorRef = useRef(new TaskExecutor(tools));
|
|
37
43
|
const lastGitStatusRef = useRef('');
|
|
38
44
|
const abortRef = useRef(null);
|
|
39
|
-
const { setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, renameFromMessage, } = useSession(session, cwd, config);
|
|
40
|
-
const
|
|
45
|
+
const { setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, renameFromMessage, } = useSession(session, cwd, config, mcpTools);
|
|
46
|
+
const sysPrompt = useCallback((extra = '') => getSystemPrompt(`\n- CWD: ${cwd}${extra}`, mcpTools), [mcpTools, cwd]);
|
|
47
|
+
const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
|
|
41
48
|
const deepThinkTool = useMemo(() => ({
|
|
42
49
|
name: 'deep_think',
|
|
43
50
|
description: 'Research tool: gather info from files and web before answering.',
|
|
@@ -48,8 +55,8 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
48
55
|
},
|
|
49
56
|
}), [config]);
|
|
50
57
|
const searchTool = useMemo(() => createSearchCodebaseTool(config, cwd), [config, cwd]);
|
|
51
|
-
const allTools = useMemo(() => [...tools, deepThinkTool, searchTool], [deepThinkTool, searchTool]);
|
|
52
|
-
const { status, setStatus, tick, currentTool, setCurrentTool, taskLabel, setTaskLabel, thinkingStartRef, runLoop, handleAbort, permissionRequest, resolvePermission, } = useRunLoop(config, currentModelRef, pushHistory, allTools, abortRef);
|
|
58
|
+
const allTools = useMemo(() => [...tools, deepThinkTool, searchTool, ...mcpTools], [deepThinkTool, searchTool, mcpTools]);
|
|
59
|
+
const { status, setStatus, tick, currentTool, setCurrentTool, taskLabel, setTaskLabel, thinkingStartRef, runLoop, handleAbort, permissionRequest, resolvePermission, compactRequest, resolveCompact, } = useRunLoop(config, currentModelRef, pushHistory, allTools, abortRef);
|
|
53
60
|
const { runRefactor } = useRefactor({
|
|
54
61
|
config, currentModelRef, systemPromptRef, abortRef,
|
|
55
62
|
macroQueueRef, executorRef,
|
|
@@ -59,13 +66,21 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
59
66
|
const { handleSubmit } = useSubmit({
|
|
60
67
|
config, skills, cwd, version, currentModelRef, setCurrentModel,
|
|
61
68
|
historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef,
|
|
62
|
-
|
|
63
|
-
setSessionName, renameFromMessage,
|
|
69
|
+
setPlanningMode, runLoop, buildContext, pushHistory,
|
|
70
|
+
setSessionName, renameFromMessage,
|
|
64
71
|
setStatus, setTaskLabel, setCurrentTool,
|
|
65
|
-
runRefactor, handleGit, lastGitStatusRef,
|
|
72
|
+
runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig,
|
|
73
|
+
setConfigOpen,
|
|
66
74
|
});
|
|
67
75
|
const skillList = skills.list();
|
|
68
|
-
return (_jsxs(Box, { flexDirection: "column", children: [
|
|
76
|
+
return (_jsxs(Box, { flexDirection: "column", children: [configOpen ? (_jsxs(_Fragment, { children: [_jsx(ConfigPicker, { config: config, currentModel: currentModel, tavilyKey: tavilyKey, onUpdate: ({ model, ...configPatch }) => {
|
|
77
|
+
if (model)
|
|
78
|
+
setCurrentModel(model);
|
|
79
|
+
if (Object.keys(configPatch).length) {
|
|
80
|
+
setConfig(c => ({ ...c, ...configPatch }));
|
|
81
|
+
saveConfig(configPatch);
|
|
82
|
+
}
|
|
83
|
+
}, 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, " messages)"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "compact to keep responses fast, or keep full history" })] })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, 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) })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { children: status === 'thinking'
|
|
69
84
|
? _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]] })] })
|
|
70
|
-
: _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, onSubmit: handleSubmit, onAbort: handleAbort, history: historyRef.current.filter(m => m.role === 'user').map(m => m.content) })] }));
|
|
85
|
+
: _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) })] }));
|
|
71
86
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
const PROVIDERS = [
|
|
5
|
+
{ key: 'ollama', label: 'Ollama', desc: 'local · free · air-gapped' },
|
|
6
|
+
{ key: 'anthropic', label: 'Anthropic', desc: 'Claude API (cloud)' },
|
|
7
|
+
{ key: 'openai-compat', label: 'OpenAI / Custom', desc: 'OpenAI or compatible endpoint' },
|
|
8
|
+
];
|
|
9
|
+
const MENU_ITEMS = [
|
|
10
|
+
{ key: 'provider', label: 'Provider' },
|
|
11
|
+
{ key: 'model', label: 'Model' },
|
|
12
|
+
{ key: 'key', label: 'API Key' },
|
|
13
|
+
{ key: 'url', label: 'Base URL' },
|
|
14
|
+
{ key: 'tavily', label: 'Tavily Key' },
|
|
15
|
+
];
|
|
16
|
+
function truncate(s, n) {
|
|
17
|
+
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
18
|
+
}
|
|
19
|
+
export function ConfigPicker({ config, currentModel, tavilyKey, onUpdate, onTavilyKey, onClose }) {
|
|
20
|
+
const [screen, setScreen] = useState('menu');
|
|
21
|
+
const [menuIdx, setMenuIdx] = useState(0);
|
|
22
|
+
const [provIdx, setProvIdx] = useState(() => PROVIDERS.findIndex(p => p.key === config.provider) ?? 0);
|
|
23
|
+
const [textInput, setTextInput] = useState('');
|
|
24
|
+
const [ollamaModels, setOllamaModels] = useState([]);
|
|
25
|
+
const [modelIdx, setModelIdx] = useState(0);
|
|
26
|
+
// Reset models list when provider changes so stale ollama models don't show
|
|
27
|
+
useEffect(() => { setOllamaModels([]); }, [config.provider]);
|
|
28
|
+
// Fetch Ollama models when entering model screen on ollama provider
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (screen !== 'model' || config.provider !== 'ollama')
|
|
31
|
+
return;
|
|
32
|
+
if (!config.baseUrl)
|
|
33
|
+
return;
|
|
34
|
+
setOllamaModels([]);
|
|
35
|
+
fetch(`${config.baseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) })
|
|
36
|
+
.then(r => r.json())
|
|
37
|
+
.then((d) => {
|
|
38
|
+
const names = (d.models ?? []).map(m => m.name).filter(Boolean);
|
|
39
|
+
if (names.length) {
|
|
40
|
+
setOllamaModels(names);
|
|
41
|
+
const ci = names.indexOf(currentModel);
|
|
42
|
+
setModelIdx(ci >= 0 ? ci : 0);
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.catch(() => { });
|
|
46
|
+
}, [screen, config.provider, config.baseUrl]);
|
|
47
|
+
function goMenu() { setScreen('menu'); setTextInput(''); }
|
|
48
|
+
function openScreen(s) {
|
|
49
|
+
if (s === 'key')
|
|
50
|
+
setTextInput(config.apiKey ?? '');
|
|
51
|
+
if (s === 'url')
|
|
52
|
+
setTextInput(config.baseUrl);
|
|
53
|
+
if (s === 'tavily')
|
|
54
|
+
setTextInput(tavilyKey ?? '');
|
|
55
|
+
if (s === 'model' && config.provider !== 'ollama')
|
|
56
|
+
setTextInput(currentModel);
|
|
57
|
+
setScreen(s);
|
|
58
|
+
}
|
|
59
|
+
useInput((input, key) => {
|
|
60
|
+
if (key.escape) {
|
|
61
|
+
if (screen !== 'menu') {
|
|
62
|
+
goMenu();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
onClose();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// ── Menu ────────────────────────────────────────────────────────────────
|
|
69
|
+
if (screen === 'menu') {
|
|
70
|
+
if (key.upArrow) {
|
|
71
|
+
setMenuIdx(i => Math.max(0, i - 1));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (key.downArrow) {
|
|
75
|
+
setMenuIdx(i => Math.min(MENU_ITEMS.length - 1, i + 1));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (key.return) {
|
|
79
|
+
openScreen(MENU_ITEMS[menuIdx].key);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// ── Provider radio ───────────────────────────────────────────────────────
|
|
85
|
+
if (screen === 'provider') {
|
|
86
|
+
if (key.upArrow) {
|
|
87
|
+
setProvIdx(i => Math.max(0, i - 1));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (key.downArrow) {
|
|
91
|
+
setProvIdx(i => Math.min(PROVIDERS.length - 1, i + 1));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (key.return) {
|
|
95
|
+
onUpdate({ provider: PROVIDERS[provIdx].key });
|
|
96
|
+
goMenu();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// ── Ollama model list ────────────────────────────────────────────────────
|
|
102
|
+
if (screen === 'model' && config.provider === 'ollama' && ollamaModels.length) {
|
|
103
|
+
if (key.upArrow) {
|
|
104
|
+
setModelIdx(i => Math.max(0, i - 1));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (key.downArrow) {
|
|
108
|
+
setModelIdx(i => Math.min(ollamaModels.length - 1, i + 1));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key.return) {
|
|
112
|
+
onUpdate({ model: ollamaModels[modelIdx] });
|
|
113
|
+
goMenu();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// ── Text input (model for cloud, key, url) ───────────────────────────────
|
|
119
|
+
if (key.return) {
|
|
120
|
+
const val = textInput.trim();
|
|
121
|
+
if (!val) {
|
|
122
|
+
goMenu();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (screen === 'model')
|
|
126
|
+
onUpdate({ model: val });
|
|
127
|
+
if (screen === 'key')
|
|
128
|
+
onUpdate({ apiKey: val });
|
|
129
|
+
if (screen === 'url')
|
|
130
|
+
onUpdate({ baseUrl: val });
|
|
131
|
+
if (screen === 'tavily')
|
|
132
|
+
onTavilyKey(val);
|
|
133
|
+
goMenu();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (key.backspace || key.delete) {
|
|
137
|
+
setTextInput(t => t.slice(0, -1));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (input && !key.ctrl && !key.meta) {
|
|
141
|
+
setTextInput(t => t + input);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
const keyDisplay = config.apiKey
|
|
146
|
+
? `${config.apiKey.slice(0, 10)}…`
|
|
147
|
+
: 'not set';
|
|
148
|
+
const tavilyDisplay = tavilyKey
|
|
149
|
+
? `${tavilyKey.slice(0, 10)}…`
|
|
150
|
+
: 'not set';
|
|
151
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: " config " }), screen !== 'menu' && (_jsxs(Text, { color: "gray", dimColor: true, children: [" \u203A ", screen, " esc back"] }))] }), screen === 'menu' && MENU_ITEMS.map((item, i) => {
|
|
152
|
+
const active = i === menuIdx;
|
|
153
|
+
let val = '';
|
|
154
|
+
if (item.key === 'provider')
|
|
155
|
+
val = config.provider;
|
|
156
|
+
if (item.key === 'model')
|
|
157
|
+
val = truncate(currentModel, 32);
|
|
158
|
+
if (item.key === 'key')
|
|
159
|
+
val = keyDisplay;
|
|
160
|
+
if (item.key === 'url')
|
|
161
|
+
val = truncate(config.baseUrl, 36);
|
|
162
|
+
if (item.key === 'tavily')
|
|
163
|
+
val = tavilyDisplay;
|
|
164
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: active ? 'cyan' : 'white', bold: active, children: [active ? '▶ ' : ' ', item.label.padEnd(12)] }), _jsx(Text, { color: active ? 'white' : 'gray', children: val })] }, item.key));
|
|
165
|
+
}), screen === 'provider' && PROVIDERS.map((p, i) => {
|
|
166
|
+
const active = i === provIdx;
|
|
167
|
+
const current = p.key === config.provider;
|
|
168
|
+
return (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: active ? 'cyan' : 'gray', children: [active ? '▶ ' : ' ', current ? '◉' : '○'] }), _jsx(Text, { color: active ? 'white' : 'gray', bold: active, children: p.label.padEnd(18) }), _jsx(Text, { color: "gray", dimColor: true, children: p.desc })] }, p.key));
|
|
169
|
+
}), screen === 'model' && config.provider === 'ollama' && (ollamaModels.length ? (ollamaModels.map((name, i) => {
|
|
170
|
+
const active = i === modelIdx;
|
|
171
|
+
const isCurrent = name === currentModel;
|
|
172
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: active ? 'cyan' : 'gray', children: active ? '▶ ' : ' ' }), _jsx(Text, { color: active ? 'white' : 'gray', bold: active, children: name }), isCurrent && _jsx(Text, { color: "green", children: " \u2713" })] }, name));
|
|
173
|
+
})) : (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "gray", dimColor: true, children: "fetching from Ollama\u2026" }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "model name: " }), _jsxs(Text, { children: [textInput, "\u2588"] })] })] }))), (screen === 'key' || screen === 'url' || screen === 'tavily' || (screen === 'model' && config.provider !== 'ollama')) && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: "cyan", children: [screen === 'key' ? 'api key' : screen === 'url' ? 'url' : screen === 'tavily' ? 'tavily key' : 'model', ": "] }), _jsxs(Text, { children: [textInput, "\u2588"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "enter to save esc to cancel" })] })), _jsx(Box, { marginTop: 1, borderTop: true, borderStyle: "single", borderColor: "gray", children: _jsx(Text, { color: "gray", dimColor: true, children: screen === 'menu'
|
|
174
|
+
? '↑↓ navigate enter edit esc close'
|
|
175
|
+
: screen === 'provider' || (screen === 'model' && ollamaModels.length > 0)
|
|
176
|
+
? '↑↓ select enter confirm esc back'
|
|
177
|
+
: 'type value enter save esc back' }) })] }));
|
|
178
|
+
}
|
|
@@ -5,32 +5,37 @@ import { listFiles } from '../../files/ops.js';
|
|
|
5
5
|
import { CommandPalette } from './CommandPalette.js';
|
|
6
6
|
import { AtPicker } from './AtPicker.js';
|
|
7
7
|
const BUILTIN_COMMANDS = [
|
|
8
|
-
|
|
9
|
-
{ ns: 'builtin', name: '
|
|
10
|
-
{ ns: 'builtin', name: 'clear', description: '
|
|
11
|
-
{ ns: 'builtin', name: 'sessions', description: 'list all saved sessions' },
|
|
12
|
-
{ ns: 'builtin', name: 'session', description: 'switch session
|
|
13
|
-
{ ns: 'builtin', name: 'exit', description: 'exit miii' },
|
|
14
|
-
|
|
15
|
-
{ ns: 'builtin', name: '
|
|
16
|
-
{ ns: 'builtin', name: '
|
|
17
|
-
{ ns: 'builtin', name: '
|
|
18
|
-
|
|
19
|
-
{ ns: 'builtin', name: '
|
|
20
|
-
{ ns: 'builtin', name: '
|
|
21
|
-
|
|
22
|
-
{ ns: '
|
|
23
|
-
{ ns: '
|
|
24
|
-
{ ns: '
|
|
25
|
-
|
|
26
|
-
{ ns: 'git', name: '
|
|
27
|
-
{ ns: 'git', name: '
|
|
8
|
+
// ── Session ──────────────────────────────────────────────────────────────
|
|
9
|
+
{ ns: 'builtin', name: 'new', description: 'start a fresh session with a new auto-named history' },
|
|
10
|
+
{ ns: 'builtin', name: 'clear', description: 'wipe chat history for the current session' },
|
|
11
|
+
{ ns: 'builtin', name: 'sessions', description: 'list all saved sessions with message counts' },
|
|
12
|
+
{ ns: 'builtin', name: 'session', description: 'switch to a saved session — /session <name>' },
|
|
13
|
+
{ ns: 'builtin', name: 'exit', description: 'exit miii (saves session first)' },
|
|
14
|
+
// ── Config ───────────────────────────────────────────────────────────────
|
|
15
|
+
{ ns: 'builtin', name: 'config', description: 'open config picker — change provider, model, API key, base URL, Tavily key with arrow-key navigation' },
|
|
16
|
+
{ ns: 'builtin', name: 'model', description: 'quickly switch model for this session — /model <name>' },
|
|
17
|
+
{ ns: 'builtin', name: 'version', description: 'show the current miii version' },
|
|
18
|
+
// ── Skills ───────────────────────────────────────────────────────────────
|
|
19
|
+
{ ns: 'builtin', name: 'skills', description: 'install, uninstall, or list npm skill packages' },
|
|
20
|
+
{ ns: 'builtin', name: 'list', description: 'list all loaded skills and their descriptions' },
|
|
21
|
+
// ── AI modes ─────────────────────────────────────────────────────────────
|
|
22
|
+
{ ns: 'builtin', name: 'plan', description: 'enter planning mode — AI helps think through a goal step-by-step' },
|
|
23
|
+
{ ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor — plans, reads, then edits — /refactor <goal>' },
|
|
24
|
+
{ ns: 'builtin', name: 'think', description: 'deep research before answering — reads files + optional web — /think <query>' },
|
|
25
|
+
// ── Git ───────────────────────────────────────────────────────────────────
|
|
26
|
+
{ ns: 'git', name: 'status', description: 'show git working tree status (modified, staged, untracked)' },
|
|
27
|
+
{ ns: 'git', name: 'diff', description: 'show unstaged changes as a diff' },
|
|
28
|
+
{ ns: 'git', name: 'diff --staged', description: 'show staged changes ready to commit' },
|
|
29
|
+
{ ns: 'git', name: 'log', description: 'show recent commit history (last 10)' },
|
|
30
|
+
{ ns: 'git', name: 'review', description: 'AI code review of current uncommitted changes' },
|
|
31
|
+
{ ns: 'git', name: 'branch', description: 'list local branches' },
|
|
32
|
+
{ ns: 'git', name: 'commit', description: 'stage everything and commit — /git commit <message>' },
|
|
28
33
|
];
|
|
29
34
|
const PLANNING_COMMANDS = [
|
|
30
|
-
{ ns: 'plan', name: 'next', description: 'suggest next concrete steps' },
|
|
31
|
-
{ ns: 'plan', name: 'breakdown', description: 'break current
|
|
32
|
-
{ ns: 'plan', name: 'review', description: '
|
|
33
|
-
{ ns: 'plan', name: 'done', description: 'exit planning mode' },
|
|
35
|
+
{ ns: 'plan', name: 'next', description: 'suggest the next concrete steps to take' },
|
|
36
|
+
{ ns: 'plan', name: 'breakdown', description: 'break the current goal into specific subtasks' },
|
|
37
|
+
{ ns: 'plan', name: 'review', description: 'critique the plan so far — find gaps and risks' },
|
|
38
|
+
{ ns: 'plan', name: 'done', description: 'exit planning mode and return to normal chat' },
|
|
34
39
|
];
|
|
35
40
|
const PASTE_MIN_CHARS = 120;
|
|
36
41
|
function wordStartBefore(line, col) {
|
|
@@ -49,7 +54,7 @@ function wordEndAfter(line, col) {
|
|
|
49
54
|
i++;
|
|
50
55
|
return i;
|
|
51
56
|
}
|
|
52
|
-
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, onSubmit, onAbort, history = [] }) {
|
|
57
|
+
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [] }) {
|
|
53
58
|
const [lines, setLines] = useState(['']);
|
|
54
59
|
const [cursor, setCursor] = useState({ row: 0, col: 0 });
|
|
55
60
|
const [overlay, setOverlay] = useState('none');
|
|
@@ -201,6 +206,17 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
201
206
|
}
|
|
202
207
|
return;
|
|
203
208
|
}
|
|
209
|
+
if (compactRequest && onCompactResponse) {
|
|
210
|
+
if (input === 'y' || input === 'Y') {
|
|
211
|
+
onCompactResponse(true);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (input === 'n' || input === 'N' || key.escape) {
|
|
215
|
+
onCompactResponse(false);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
204
220
|
if (key.escape) {
|
|
205
221
|
if (overlay !== 'none') {
|
|
206
222
|
setOverlay('none');
|
|
@@ -430,25 +446,27 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
430
446
|
const { stdout } = useStdout();
|
|
431
447
|
const cols = stdout.columns ?? 80;
|
|
432
448
|
const isProcessing = status !== 'idle';
|
|
433
|
-
const promptColor = permissionRequest ? 'yellow' : isProcessing ? 'yellow' : 'green';
|
|
449
|
+
const promptColor = (permissionRequest || compactRequest) ? 'yellow' : isProcessing ? 'yellow' : 'green';
|
|
434
450
|
const inHistory = historyIdx !== -1;
|
|
435
|
-
const hint =
|
|
436
|
-
? 'y
|
|
437
|
-
:
|
|
438
|
-
? '
|
|
439
|
-
:
|
|
440
|
-
? '
|
|
441
|
-
:
|
|
442
|
-
? '
|
|
443
|
-
:
|
|
444
|
-
?
|
|
445
|
-
:
|
|
446
|
-
?
|
|
447
|
-
:
|
|
451
|
+
const hint = compactRequest
|
|
452
|
+
? 'y compact n keep full context'
|
|
453
|
+
: permissionRequest
|
|
454
|
+
? 'y approve n deny'
|
|
455
|
+
: isProcessing
|
|
456
|
+
? 'esc interrupt'
|
|
457
|
+
: pasteLines > 0
|
|
458
|
+
? 'backspace remove paste enter send'
|
|
459
|
+
: overlay !== 'none'
|
|
460
|
+
? '↑↓ navigate enter select esc close'
|
|
461
|
+
: inHistory
|
|
462
|
+
? `history ${historyIdx + 1}/${history.length} ↑↓ navigate esc clear`
|
|
463
|
+
: planningMode
|
|
464
|
+
? 'planning mode /plan:done exit'
|
|
465
|
+
: '? for shortcuts';
|
|
448
466
|
const pastePreview = pasteRef.current
|
|
449
467
|
? pasteRef.current.split('\n')[0].slice(0, cols - 6)
|
|
450
468
|
: '';
|
|
451
|
-
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: 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] ? (_jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { wrap: "wrap", children: i === cursor.row
|
|
469
|
+
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 || 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] ? (_jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { wrap: "wrap", children: i === cursor.row
|
|
452
470
|
? renderLineWithCursor(line, cursor.col, isActive)
|
|
453
471
|
: line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Text, { color: "gray", dimColor: true, children: [" ", hint] })] }));
|
|
454
472
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
2
3
|
import { chat } from '../../llm/stream.js';
|
|
3
4
|
import { tools as staticTools } from '../../tools/index.js';
|
|
4
5
|
import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
|
|
@@ -8,6 +9,7 @@ const MAX_TOOL_DEPTH = 6;
|
|
|
8
9
|
const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'patch_file', 'delete_file']);
|
|
9
10
|
const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
|
|
10
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
13
|
export function useRunLoop(config, currentModelRef, pushHistory, extraTools = [], abortRef) {
|
|
12
14
|
const [status, setStatus] = useState('idle');
|
|
13
15
|
const [tick, setTick] = useState(0);
|
|
@@ -15,6 +17,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
15
17
|
const [taskLabel, setTaskLabel] = useState();
|
|
16
18
|
const [permissionRequest, setPermissionRequest] = useState(null);
|
|
17
19
|
const permissionResolveRef = useRef(null);
|
|
20
|
+
const [compactRequest, setCompactRequest] = useState(null);
|
|
21
|
+
const compactResolveRef = useRef(null);
|
|
22
|
+
const checkpointRef = useRef(new Map());
|
|
18
23
|
const thinkingStartRef = useRef(0);
|
|
19
24
|
const extraToolsRef = useRef(extraTools);
|
|
20
25
|
extraToolsRef.current = extraTools;
|
|
@@ -25,6 +30,11 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
25
30
|
permissionResolveRef.current = null;
|
|
26
31
|
setPermissionRequest(null);
|
|
27
32
|
}, []);
|
|
33
|
+
const resolveCompact = useCallback((approved) => {
|
|
34
|
+
compactResolveRef.current?.(approved);
|
|
35
|
+
compactResolveRef.current = null;
|
|
36
|
+
setCompactRequest(null);
|
|
37
|
+
}, []);
|
|
28
38
|
useEffect(() => {
|
|
29
39
|
if (status === 'idle')
|
|
30
40
|
return;
|
|
@@ -38,9 +48,20 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
38
48
|
return;
|
|
39
49
|
}
|
|
40
50
|
setStatus('thinking');
|
|
41
|
-
if (depth === 0)
|
|
51
|
+
if (depth === 0) {
|
|
42
52
|
thinkingStartRef.current = Date.now();
|
|
43
|
-
|
|
53
|
+
checkpointRef.current.clear();
|
|
54
|
+
}
|
|
55
|
+
let msgs = contextMsgs;
|
|
56
|
+
if (shouldCompact(contextMsgs)) {
|
|
57
|
+
const approved = await new Promise(resolve => {
|
|
58
|
+
compactResolveRef.current = resolve;
|
|
59
|
+
setCompactRequest({ messageCount: contextMsgs.length });
|
|
60
|
+
});
|
|
61
|
+
msgs = approved ? compactContext(contextMsgs, goal) : contextMsgs;
|
|
62
|
+
if (!approved)
|
|
63
|
+
printer.systemMsg('keeping full context — responses may be slower');
|
|
64
|
+
}
|
|
44
65
|
abortRef.current = new AbortController();
|
|
45
66
|
await chat({
|
|
46
67
|
provider: config.provider,
|
|
@@ -97,6 +118,18 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
97
118
|
next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user` });
|
|
98
119
|
break;
|
|
99
120
|
}
|
|
121
|
+
// Checkpoint: store pre-execution file state
|
|
122
|
+
if (CHECKPOINT_TOOLS.has(tc.name)) {
|
|
123
|
+
const path = tc.args.path;
|
|
124
|
+
if (path && !checkpointRef.current.has(path)) {
|
|
125
|
+
try {
|
|
126
|
+
checkpointRef.current.set(path, readFileSync(path, 'utf-8'));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
checkpointRef.current.set(path, null);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
100
133
|
}
|
|
101
134
|
if (tool) {
|
|
102
135
|
try {
|
|
@@ -161,6 +194,31 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
161
194
|
permissionResolveRef.current = null;
|
|
162
195
|
setPermissionRequest(null);
|
|
163
196
|
}
|
|
197
|
+
if (compactResolveRef.current) {
|
|
198
|
+
compactResolveRef.current(false);
|
|
199
|
+
compactResolveRef.current = null;
|
|
200
|
+
setCompactRequest(null);
|
|
201
|
+
}
|
|
202
|
+
// Restore checkpointed files
|
|
203
|
+
if (checkpointRef.current.size > 0) {
|
|
204
|
+
let restored = 0;
|
|
205
|
+
for (const [path, content] of checkpointRef.current) {
|
|
206
|
+
try {
|
|
207
|
+
if (content === null) {
|
|
208
|
+
if (existsSync(path))
|
|
209
|
+
unlinkSync(path);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
writeFileSync(path, content, 'utf-8');
|
|
213
|
+
}
|
|
214
|
+
restored++;
|
|
215
|
+
}
|
|
216
|
+
catch { }
|
|
217
|
+
}
|
|
218
|
+
checkpointRef.current.clear();
|
|
219
|
+
if (restored > 0)
|
|
220
|
+
printer.systemMsg(`restored ${restored} file(s) to pre-session state`);
|
|
221
|
+
}
|
|
164
222
|
setStatus('idle');
|
|
165
223
|
}, []);
|
|
166
224
|
return {
|
|
@@ -170,5 +228,6 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
170
228
|
thinkingStartRef,
|
|
171
229
|
runLoop, handleAbort,
|
|
172
230
|
permissionRequest, resolvePermission,
|
|
231
|
+
compactRequest, resolveCompact,
|
|
173
232
|
};
|
|
174
233
|
}
|
|
@@ -6,22 +6,22 @@ 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
8
|
const SHORT_MEMORY_SIZE = 40;
|
|
9
|
-
function buildSystemPrompt(cwd, facts) {
|
|
10
|
-
return getSystemPrompt(`\n- CWD: ${cwd}
|
|
9
|
+
function buildSystemPrompt(cwd, facts, extraTools = []) {
|
|
10
|
+
return getSystemPrompt(`\n- CWD: ${cwd}`, extraTools) + formatMemoryBlock(facts);
|
|
11
11
|
}
|
|
12
|
-
export function useSession(initialSession, cwd, config) {
|
|
12
|
+
export function useSession(initialSession, cwd, config, extraTools = []) {
|
|
13
13
|
const [sessionName, setSessionName] = useState(initialSession);
|
|
14
14
|
const sessionNameRef = useRef(initialSession);
|
|
15
15
|
const historyRef = useRef([]);
|
|
16
16
|
const saveTimerRef = useRef(null);
|
|
17
17
|
const firstMessageSentRef = useRef(false);
|
|
18
18
|
const longMemoryRef = useRef([]);
|
|
19
|
-
const systemPromptRef = useRef(buildSystemPrompt(cwd, []));
|
|
19
|
+
const systemPromptRef = useRef(buildSystemPrompt(cwd, [], extraTools));
|
|
20
20
|
useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
|
|
21
21
|
useEffect(() => {
|
|
22
22
|
const facts = loadLongMemory(initialSession);
|
|
23
23
|
longMemoryRef.current = facts;
|
|
24
|
-
systemPromptRef.current = buildSystemPrompt(cwd, facts);
|
|
24
|
+
systemPromptRef.current = buildSystemPrompt(cwd, facts, extraTools);
|
|
25
25
|
if (facts.length)
|
|
26
26
|
printer.systemMsg(`long memory: ${facts.length} facts loaded`);
|
|
27
27
|
const history = loadSession(initialSession);
|
|
@@ -51,7 +51,7 @@ export function useSession(initialSession, cwd, config) {
|
|
|
51
51
|
return;
|
|
52
52
|
const updated = mergeFacts(longMemoryRef.current, newFacts);
|
|
53
53
|
longMemoryRef.current = updated;
|
|
54
|
-
systemPromptRef.current = buildSystemPrompt(cwd, updated);
|
|
54
|
+
systemPromptRef.current = buildSystemPrompt(cwd, updated, extraTools);
|
|
55
55
|
saveLongMemory(sessionNameRef.current, updated);
|
|
56
56
|
});
|
|
57
57
|
}
|