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.
@@ -1,52 +1,91 @@
1
- const COMPACT_THRESHOLD = 18; // compact when context exceeds this many messages
2
- const KEEP_RECENT = 6; // always keep last N messages verbatim
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
- * Compact context to keep local models on track during long refactors.
8
- *
9
- * Strategy:
10
- * 1. Keep system prompt (index 0)
11
- * 2. Keep first user message (original goal)
12
- * 3. Summarise completed tool results in the middle into one message
13
- * 4. Keep last KEEP_RECENT messages verbatim (model's working memory)
14
- */
15
- export function compactContext(messages, goal) {
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
- summary,
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 [
@@ -246,8 +246,9 @@ export const tools = [
246
246
  },
247
247
  },
248
248
  ];
249
- export function getSystemPrompt(extra = '') {
250
- const toolDocs = tools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
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.
@@ -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 { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, openPicker, handleModelSelect, handleModelPull, } = useModelPicker(config);
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
- planningMode, setPlanningMode, runLoop, buildContext, pushHistory,
63
- setSessionName, renameFromMessage, openPicker,
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: [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 })] })) : 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'
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
- { ns: 'builtin', name: 'new', description: 'start a fresh session (auto-named)' },
9
- { ns: 'builtin', name: 'models', description: 'switch or pull Ollama models' },
10
- { ns: 'builtin', name: 'clear', description: 'clear chat history for current session' },
11
- { ns: 'builtin', name: 'sessions', description: 'list all saved sessions' },
12
- { ns: 'builtin', name: 'session', description: 'switch session /session <name>' },
13
- { ns: 'builtin', name: 'exit', description: 'exit miii' },
14
- { ns: 'builtin', name: 'model', description: 'switch model mid-session /model <name>' },
15
- { ns: 'builtin', name: 'version', description: 'show current miii version' },
16
- { ns: 'builtin', name: 'tavily-key', description: 'set Tavily API key for web search /tavily-key tvly-...' },
17
- { ns: 'builtin', name: 'skills', description: 'install/uninstall/list npm skills /skills install <name>' },
18
- { ns: 'builtin', name: 'list', description: 'list all loaded skills' },
19
- { ns: 'builtin', name: 'plan', description: 'start planning mode /plan [topic]' },
20
- { ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor /refactor <goal>' },
21
- { ns: 'git', name: 'status', description: 'show git working tree status' },
22
- { ns: 'git', name: 'diff', description: 'show unstaged diff' },
23
- { ns: 'git', name: 'diff --staged', description: 'show staged diff' },
24
- { ns: 'git', name: 'log', description: 'show recent commits' },
25
- { ns: 'git', name: 'review', description: 'review current changes with AI' },
26
- { ns: 'git', name: 'branch', description: 'list branches' },
27
- { ns: 'git', name: 'commit', description: 'stage all and commit /git commit <msg>' },
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 topic into subtasks' },
32
- { ns: 'plan', name: 'review', description: 'review and critique the plan so far' },
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(true);
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
- onPermissionResponse(false);
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 = permissionRequest
436
- ? 'y approve n deny'
437
- : isProcessing
438
- ? 'esc interrupt'
439
- : pasteLines > 0
440
- ? 'backspace remove paste enter send'
441
- : overlay !== 'none'
442
- ? '↑↓ navigate enter select esc close'
443
- : inHistory
444
- ? `history ${historyIdx + 1}/${history.length} ↑↓ navigate esc clear`
445
- : planningMode
446
- ? 'planning mode /plan:done exit'
447
- : '? for shortcuts';
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, { wrap: "wrap", children: i === cursor.row
452
- ? renderLineWithCursor(line, cursor.col, isActive)
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
+ }