miii-cli 1.0.1 → 1.1.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 +84 -56
- 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/tasks/compactor.js +65 -26
- 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 +86 -44
- package/dist/tui/hooks/useRunLoop.js +94 -10
- package/dist/tui/hooks/useSession.js +6 -6
- package/dist/tui/hooks/useSubmit.js +88 -23
- package/dist/tui/printer.js +72 -2
- package/package.json +1 -1
package/dist/tasks/compactor.js
CHANGED
|
@@ -1,52 +1,91 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
1
|
+
import { chat } from '../llm/stream.js';
|
|
2
|
+
const COMPACT_THRESHOLD = 18;
|
|
3
|
+
const KEEP_RECENT = 6;
|
|
3
4
|
export function shouldCompact(messages) {
|
|
4
5
|
return messages.length > COMPACT_THRESHOLD;
|
|
5
6
|
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
const COMPACT_SYSTEM = `You are a context summarizer for an AI coding agent session.
|
|
8
|
+
Your job: produce a dense, structured summary of the conversation so the agent can continue the task without losing context.
|
|
9
|
+
|
|
10
|
+
Output format (use exactly these headers):
|
|
11
|
+
|
|
12
|
+
## Task
|
|
13
|
+
One sentence: what the user asked for.
|
|
14
|
+
|
|
15
|
+
## Completed
|
|
16
|
+
Bullet list of actions taken (files edited, commands run, decisions made). Be specific — include file paths and outcomes.
|
|
17
|
+
|
|
18
|
+
## Current State
|
|
19
|
+
What is true right now: which files were changed, what tests showed, what is working or broken.
|
|
20
|
+
|
|
21
|
+
## Remaining
|
|
22
|
+
What still needs to be done, if anything.
|
|
23
|
+
|
|
24
|
+
## Key Context
|
|
25
|
+
Any constraints, errors encountered, important facts the agent must remember to continue correctly.
|
|
26
|
+
|
|
27
|
+
Be factual. No padding. Include file paths, error messages, and command outputs verbatim when relevant.`;
|
|
28
|
+
export async function compactContext(messages, cfg, goal) {
|
|
16
29
|
if (messages.length <= COMPACT_THRESHOLD)
|
|
17
30
|
return messages;
|
|
18
31
|
const system = messages[0]?.role === 'system' ? messages[0] : null;
|
|
32
|
+
const recent = messages.slice(messages.length - KEEP_RECENT);
|
|
33
|
+
const toSummarize = messages.slice(system ? 1 : 0, messages.length - KEEP_RECENT);
|
|
34
|
+
// Build conversation transcript for the summarizer
|
|
35
|
+
const transcript = toSummarize.map(m => {
|
|
36
|
+
const role = m.role === 'assistant' ? 'Assistant' : 'User';
|
|
37
|
+
const body = m.content.length > 2000 ? m.content.slice(0, 2000) + '\n[truncated]' : m.content;
|
|
38
|
+
return `### ${role}\n${body}`;
|
|
39
|
+
}).join('\n\n');
|
|
40
|
+
const userPrompt = [
|
|
41
|
+
goal ? `The user's goal: ${goal}\n` : '',
|
|
42
|
+
`Conversation to summarize:\n\n${transcript}`,
|
|
43
|
+
].join('');
|
|
44
|
+
let summary = '';
|
|
45
|
+
await chat({
|
|
46
|
+
...cfg,
|
|
47
|
+
messages: [
|
|
48
|
+
{ role: 'system', content: COMPACT_SYSTEM },
|
|
49
|
+
{ role: 'user', content: userPrompt },
|
|
50
|
+
],
|
|
51
|
+
onDone: (text) => { summary = text.trim(); },
|
|
52
|
+
onError: () => { },
|
|
53
|
+
});
|
|
54
|
+
// Fallback to dumb compaction if LLM fails
|
|
55
|
+
if (!summary)
|
|
56
|
+
return dumbCompact(messages, goal);
|
|
57
|
+
const summaryMsg = {
|
|
58
|
+
role: 'user',
|
|
59
|
+
content: `[Context compacted — ${toSummarize.length} messages summarised]\n\n${summary}`,
|
|
60
|
+
};
|
|
61
|
+
return [
|
|
62
|
+
...(system ? [system] : []),
|
|
63
|
+
summaryMsg,
|
|
64
|
+
...recent,
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
function dumbCompact(messages, goal) {
|
|
68
|
+
const system = messages[0]?.role === 'system' ? messages[0] : null;
|
|
19
69
|
const userGoal = messages.find(m => m.role === 'user' && !m.content.startsWith('['));
|
|
20
|
-
const anchorCount = (system ? 1 : 0) + (userGoal ? 1 : 0);
|
|
21
|
-
const middle = messages.slice(anchorCount, messages.length - KEEP_RECENT);
|
|
22
70
|
const recent = messages.slice(messages.length - KEEP_RECENT);
|
|
71
|
+
const middle = messages.slice((system ? 1 : 0) + (userGoal ? 1 : 0), messages.length - KEEP_RECENT);
|
|
23
72
|
const toolResults = middle
|
|
24
73
|
.filter(m => m.role === 'user' && m.content.startsWith('Tool '))
|
|
25
|
-
.map(m => {
|
|
26
|
-
const lines = m.content.split('\n');
|
|
27
|
-
return `• ${lines[0]}`; // just the "Tool X result:" line
|
|
28
|
-
});
|
|
29
|
-
const assistantSummaries = middle
|
|
30
|
-
.filter(m => m.role === 'assistant' && m.content.trim().length > 0)
|
|
31
|
-
.map(m => m.content.slice(0, 120).replace(/\n/g, ' '));
|
|
74
|
+
.map(m => `• ${m.content.split('\n')[0]}`);
|
|
32
75
|
const parts = [`[context compacted — ${middle.length} messages summarised]`];
|
|
33
76
|
if (goal)
|
|
34
77
|
parts.push(`Goal: ${goal}`);
|
|
35
78
|
if (toolResults.length)
|
|
36
79
|
parts.push(`Completed:\n${toolResults.join('\n')}`);
|
|
37
|
-
if (assistantSummaries.length)
|
|
38
|
-
parts.push(`Last reasoning: ${assistantSummaries.at(-1)}`);
|
|
39
|
-
const summary = { role: 'user', content: parts.join('\n\n') };
|
|
40
80
|
return [
|
|
41
81
|
...(system ? [system] : []),
|
|
42
82
|
...(userGoal ? [userGoal] : []),
|
|
43
|
-
|
|
83
|
+
{ role: 'user', content: parts.join('\n\n') },
|
|
44
84
|
...recent,
|
|
45
85
|
];
|
|
46
86
|
}
|
|
47
87
|
/**
|
|
48
88
|
* Build a fresh isolated context for a single-file edit step.
|
|
49
|
-
* Keeps context tiny — avoids cross-file noise polluting the model.
|
|
50
89
|
*/
|
|
51
90
|
export function fileEditContext(systemPrompt, goal, filePath, fileContent, instruction) {
|
|
52
91
|
return [
|
package/dist/tools/index.js
CHANGED
|
@@ -246,8 +246,9 @@ export const tools = [
|
|
|
246
246
|
},
|
|
247
247
|
},
|
|
248
248
|
];
|
|
249
|
-
export function getSystemPrompt(extra = '') {
|
|
250
|
-
const
|
|
249
|
+
export function getSystemPrompt(extra = '', extraTools = []) {
|
|
250
|
+
const allTools = extraTools.length ? [...tools, ...extraTools] : tools;
|
|
251
|
+
const toolDocs = allTools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
|
|
251
252
|
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.
|
|
252
253
|
- 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".`;
|
|
253
254
|
return `You are Miii — a fast, local AI coding assistant.
|
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,38 @@ 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: '
|
|
11
|
-
{ ns: 'builtin', name: '
|
|
12
|
-
{ ns: 'builtin', name: '
|
|
13
|
-
{ ns: 'builtin', name: '
|
|
14
|
-
{ ns: 'builtin', name: '
|
|
15
|
-
|
|
16
|
-
{ ns: 'builtin', name: '
|
|
17
|
-
{ ns: 'builtin', name: '
|
|
18
|
-
{ ns: 'builtin', name: '
|
|
19
|
-
|
|
20
|
-
{ ns: 'builtin', name: '
|
|
21
|
-
{ ns: '
|
|
22
|
-
|
|
23
|
-
{ ns: '
|
|
24
|
-
{ ns: '
|
|
25
|
-
{ ns: '
|
|
26
|
-
|
|
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: 'compact', description: 'summarise conversation history now using the LLM — frees context before miii asks' },
|
|
11
|
+
{ ns: 'builtin', name: 'clear', description: 'wipe chat history for the current session' },
|
|
12
|
+
{ ns: 'builtin', name: 'sessions', description: 'list all saved sessions with message counts' },
|
|
13
|
+
{ ns: 'builtin', name: 'session', description: 'switch to a saved session — /session <name>' },
|
|
14
|
+
{ ns: 'builtin', name: 'exit', description: 'exit miii (saves session first)' },
|
|
15
|
+
// ── Config ───────────────────────────────────────────────────────────────
|
|
16
|
+
{ ns: 'builtin', name: 'config', description: 'open config picker — change provider, model, API key, base URL, Tavily key with arrow-key navigation' },
|
|
17
|
+
{ ns: 'builtin', name: 'model', description: 'quickly switch model for this session — /model <name>' },
|
|
18
|
+
{ ns: 'builtin', name: 'version', description: 'show the current miii version' },
|
|
19
|
+
// ── Skills ───────────────────────────────────────────────────────────────
|
|
20
|
+
{ ns: 'builtin', name: 'skills', description: 'install, uninstall, or list npm skill packages' },
|
|
21
|
+
{ ns: 'builtin', name: 'list', description: 'list all loaded skills and their descriptions' },
|
|
22
|
+
// ── AI modes ─────────────────────────────────────────────────────────────
|
|
23
|
+
{ ns: 'builtin', name: 'plan', description: 'enter planning mode — AI helps think through a goal step-by-step' },
|
|
24
|
+
{ ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor — plans, reads, then edits — /refactor <goal>' },
|
|
25
|
+
{ ns: 'builtin', name: 'think', description: 'deep research before answering — reads files + optional web — /think <query>' },
|
|
26
|
+
// ── Git ───────────────────────────────────────────────────────────────────
|
|
27
|
+
{ ns: 'git', name: 'status', description: 'show git working tree status (modified, staged, untracked)' },
|
|
28
|
+
{ ns: 'git', name: 'diff', description: 'show unstaged changes as a diff' },
|
|
29
|
+
{ ns: 'git', name: 'diff --staged', description: 'show staged changes ready to commit' },
|
|
30
|
+
{ ns: 'git', name: 'log', description: 'show recent commit history (last 10)' },
|
|
31
|
+
{ ns: 'git', name: 'review', description: 'AI code review of current uncommitted changes' },
|
|
32
|
+
{ ns: 'git', name: 'branch', description: 'list local branches' },
|
|
33
|
+
{ ns: 'git', name: 'commit', description: 'stage everything and commit — /git commit <message>' },
|
|
28
34
|
];
|
|
29
35
|
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' },
|
|
36
|
+
{ ns: 'plan', name: 'next', description: 'suggest the next concrete steps to take' },
|
|
37
|
+
{ ns: 'plan', name: 'breakdown', description: 'break the current goal into specific subtasks' },
|
|
38
|
+
{ ns: 'plan', name: 'review', description: 'critique the plan so far — find gaps and risks' },
|
|
39
|
+
{ ns: 'plan', name: 'done', description: 'exit planning mode and return to normal chat' },
|
|
34
40
|
];
|
|
35
41
|
const PASTE_MIN_CHARS = 120;
|
|
36
42
|
function wordStartBefore(line, col) {
|
|
@@ -49,7 +55,7 @@ function wordEndAfter(line, col) {
|
|
|
49
55
|
i++;
|
|
50
56
|
return i;
|
|
51
57
|
}
|
|
52
|
-
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, onSubmit, onAbort, history = [] }) {
|
|
58
|
+
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [] }) {
|
|
53
59
|
const [lines, setLines] = useState(['']);
|
|
54
60
|
const [cursor, setCursor] = useState({ row: 0, col: 0 });
|
|
55
61
|
const [overlay, setOverlay] = useState('none');
|
|
@@ -192,11 +198,26 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
192
198
|
useInput((input, key) => {
|
|
193
199
|
if (permissionRequest && onPermissionResponse) {
|
|
194
200
|
if (input === 'y' || input === 'Y') {
|
|
195
|
-
onPermissionResponse(
|
|
201
|
+
onPermissionResponse('yes');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (input === 'a' || input === 'A') {
|
|
205
|
+
onPermissionResponse('session');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (input === 'n' || input === 'N' || key.escape) {
|
|
209
|
+
onPermissionResponse('no');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (compactRequest && onCompactResponse) {
|
|
215
|
+
if (input === 'y' || input === 'Y') {
|
|
216
|
+
onCompactResponse(true);
|
|
196
217
|
return;
|
|
197
218
|
}
|
|
198
219
|
if (input === 'n' || input === 'N' || key.escape) {
|
|
199
|
-
|
|
220
|
+
onCompactResponse(false);
|
|
200
221
|
return;
|
|
201
222
|
}
|
|
202
223
|
return;
|
|
@@ -429,29 +450,50 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
429
450
|
});
|
|
430
451
|
const { stdout } = useStdout();
|
|
431
452
|
const cols = stdout.columns ?? 80;
|
|
453
|
+
const availWidth = Math.max(20, cols - 4); // paddingX(2) + "> "(2)
|
|
432
454
|
const isProcessing = status !== 'idle';
|
|
433
|
-
const promptColor = permissionRequest ? 'yellow' : isProcessing ? 'yellow' : 'green';
|
|
455
|
+
const promptColor = (permissionRequest || compactRequest) ? 'yellow' : isProcessing ? 'yellow' : 'green';
|
|
434
456
|
const inHistory = historyIdx !== -1;
|
|
435
|
-
const hint =
|
|
436
|
-
? 'y
|
|
437
|
-
:
|
|
438
|
-
? '
|
|
439
|
-
:
|
|
440
|
-
? '
|
|
441
|
-
:
|
|
442
|
-
? '
|
|
443
|
-
:
|
|
444
|
-
?
|
|
445
|
-
:
|
|
446
|
-
?
|
|
447
|
-
:
|
|
457
|
+
const hint = compactRequest
|
|
458
|
+
? 'y compact n keep full context'
|
|
459
|
+
: permissionRequest
|
|
460
|
+
? 'y approve once a approve for session n deny'
|
|
461
|
+
: isProcessing
|
|
462
|
+
? 'esc interrupt'
|
|
463
|
+
: pasteLines > 0
|
|
464
|
+
? 'backspace remove paste enter send'
|
|
465
|
+
: overlay !== 'none'
|
|
466
|
+
? '↑↓ navigate enter select esc close'
|
|
467
|
+
: inHistory
|
|
468
|
+
? `history ${historyIdx + 1}/${history.length} ↑↓ navigate esc clear`
|
|
469
|
+
: planningMode
|
|
470
|
+
? 'planning mode /plan:done exit'
|
|
471
|
+
: '? for shortcuts';
|
|
448
472
|
const pastePreview = pasteRef.current
|
|
449
473
|
? pasteRef.current.split('\n')[0].slice(0, cols - 6)
|
|
450
474
|
: '';
|
|
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, {
|
|
452
|
-
?
|
|
453
|
-
: line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Text, { color: "gray", dimColor: true, children: [" ", hint] })] }));
|
|
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] ? (_jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
|
|
476
|
+
? viewportLine(line, cursor.col, availWidth, isActive)
|
|
477
|
+
: 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] })] }));
|
|
454
478
|
}
|
|
455
479
|
function renderLineWithCursor(line, col, showCursor) {
|
|
456
480
|
return line.slice(0, col) + (showCursor ? '█' : '') + line.slice(col);
|
|
457
481
|
}
|
|
482
|
+
function viewportLine(line, col, width, active) {
|
|
483
|
+
// If line fits, render normally
|
|
484
|
+
if (line.length < width)
|
|
485
|
+
return renderLineWithCursor(line, col, active);
|
|
486
|
+
// Slide window so cursor stays in view, roughly centered
|
|
487
|
+
let start = Math.max(0, col - Math.floor(width / 2));
|
|
488
|
+
if (start + width > line.length + 1) {
|
|
489
|
+
start = Math.max(0, line.length + 1 - width);
|
|
490
|
+
}
|
|
491
|
+
const hasLeft = start > 0;
|
|
492
|
+
const sliceW = width - (hasLeft ? 1 : 0) - 1; // -1 for right indicator space
|
|
493
|
+
const slice = line.slice(start, start + sliceW);
|
|
494
|
+
const hasRight = start + sliceW < line.length;
|
|
495
|
+
const adjCol = col - start;
|
|
496
|
+
return (hasLeft ? '…' : '') +
|
|
497
|
+
renderLineWithCursor(slice, Math.max(0, Math.min(adjCol, slice.length)), active) +
|
|
498
|
+
(hasRight ? '…' : '');
|
|
499
|
+
}
|