miii-cli 1.0.1 → 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.
@@ -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,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
- { 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: '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 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' },
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 = 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';
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
- const msgs = shouldCompact(contextMsgs) ? compactContext(contextMsgs, goal) : contextMsgs;
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}`) + formatMemoryBlock(facts);
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
  }