miii-cli 0.2.4 → 0.2.5

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.
@@ -0,0 +1,63 @@
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { listModels, pullModel } from '../../llm/ollama.js';
3
+ import * as printer from '../printer.js';
4
+ export function useModelPicker(config) {
5
+ const [currentModel, setCurrentModel] = useState(config.model);
6
+ const currentModelRef = useRef(config.model);
7
+ const [pickerOpen, setPickerOpen] = useState(true);
8
+ const [pickerModels, setPickerModels] = useState([]);
9
+ const [pickerLoading, setPickerLoading] = useState(false);
10
+ const [pickerError, setPickerError] = useState();
11
+ const [pullState, setPullState] = useState();
12
+ const pullAbortRef = useRef(null);
13
+ useEffect(() => { currentModelRef.current = currentModel; }, [currentModel]);
14
+ useEffect(() => {
15
+ setPickerLoading(true);
16
+ listModels(config.baseUrl)
17
+ .then(m => { setPickerModels(m); setPickerLoading(false); })
18
+ .catch(e => { setPickerError(String(e)); setPickerLoading(false); });
19
+ }, []);
20
+ const openPicker = useCallback(async () => {
21
+ setPickerOpen(true);
22
+ setPickerLoading(true);
23
+ setPickerError(undefined);
24
+ try {
25
+ setPickerModels(await listModels(config.baseUrl));
26
+ }
27
+ catch (e) {
28
+ setPickerError(String(e));
29
+ }
30
+ finally {
31
+ setPickerLoading(false);
32
+ }
33
+ }, [config.baseUrl]);
34
+ const handleModelSelect = useCallback((name) => {
35
+ setCurrentModel(name);
36
+ currentModelRef.current = name;
37
+ setPickerOpen(false);
38
+ printer.systemMsg(`model → ${name}`);
39
+ }, []);
40
+ const handleModelPull = useCallback(async (name) => {
41
+ setPullState({ name, status: 'starting...', pct: undefined });
42
+ pullAbortRef.current = new AbortController();
43
+ try {
44
+ await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal);
45
+ setPickerModels(await listModels(config.baseUrl));
46
+ setPullState(undefined);
47
+ setCurrentModel(name);
48
+ currentModelRef.current = name;
49
+ setPickerOpen(false);
50
+ printer.systemMsg(`pulled ${name} → active`);
51
+ }
52
+ catch (e) {
53
+ setPullState(undefined);
54
+ setPickerError(`pull failed: ${e}`);
55
+ }
56
+ }, [config.baseUrl]);
57
+ return {
58
+ currentModel, setCurrentModel, currentModelRef,
59
+ pickerOpen, setPickerOpen,
60
+ pickerModels, pickerLoading, pickerError, pullState,
61
+ openPicker, handleModelSelect, handleModelPull,
62
+ };
63
+ }
@@ -0,0 +1,137 @@
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { chat } from '../../llm/stream.js';
3
+ import { tools } from '../../tools/index.js';
4
+ import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
5
+ import { shouldCompact, compactContext } from '../../tasks/compactor.js';
6
+ import * as printer from '../printer.js';
7
+ const MAX_TOOL_DEPTH = 6;
8
+ const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'patch_file', 'delete_file']);
9
+ export function useRunLoop(config, currentModelRef, pushHistory) {
10
+ const [status, setStatus] = useState('idle');
11
+ const [tick, setTick] = useState(0);
12
+ const [currentTool, setCurrentTool] = useState();
13
+ const [taskLabel, setTaskLabel] = useState();
14
+ const abortRef = useRef(null);
15
+ const thinkingStartRef = useRef(0);
16
+ const pushHistoryRef = useRef(pushHistory);
17
+ useEffect(() => { pushHistoryRef.current = pushHistory; }, [pushHistory]);
18
+ useEffect(() => {
19
+ if (status === 'idle')
20
+ return;
21
+ const t = setInterval(() => setTick(n => n + 1), 80);
22
+ return () => clearInterval(t);
23
+ }, [status]);
24
+ const runLoop = useCallback(async (contextMsgs, depth = 0, goal) => {
25
+ if (depth >= MAX_TOOL_DEPTH) {
26
+ setStatus('idle');
27
+ return;
28
+ }
29
+ setStatus('thinking');
30
+ if (depth === 0)
31
+ thinkingStartRef.current = Date.now();
32
+ const msgs = shouldCompact(contextMsgs) ? compactContext(contextMsgs, goal) : contextMsgs;
33
+ abortRef.current = new AbortController();
34
+ await chat({
35
+ provider: config.provider,
36
+ model: currentModelRef.current,
37
+ baseUrl: config.baseUrl,
38
+ messages: msgs,
39
+ signal: abortRef.current.signal,
40
+ async onDone(fullText) {
41
+ const pendingTools = [];
42
+ const parser = new StreamParser();
43
+ for (const item of [...parser.feed(fullText), ...parser.flush()]) {
44
+ if (item.type === 'tool_call')
45
+ pendingTools.push({ name: item.toolName, args: item.toolArgs });
46
+ }
47
+ if (!pendingTools.length) {
48
+ const bare = extractBareToolCall(fullText);
49
+ if (bare)
50
+ pendingTools.push({ name: bare.name, args: bare.args });
51
+ }
52
+ printer.assistantMsg(fullText);
53
+ pushHistoryRef.current({ role: 'assistant', content: fullText });
54
+ if (!pendingTools.length) {
55
+ const hasFencedCode = /```[\w]*\n[\s\S]{50,}?\n```/.test(fullText);
56
+ if (hasFencedCode && depth < MAX_TOOL_DEPTH - 1) {
57
+ const nudge = {
58
+ role: 'user',
59
+ content: 'You showed code in your response but did not use any file tools. Use edit_file or patch_file to actually write the changes to disk.',
60
+ };
61
+ await runLoop([...msgs, { role: 'assistant', content: fullText }, nudge], depth + 1, goal);
62
+ return;
63
+ }
64
+ setStatus('idle');
65
+ return;
66
+ }
67
+ setStatus('tool');
68
+ const next = [...msgs, { role: 'assistant', content: fullText }];
69
+ try {
70
+ for (const tc of pendingTools) {
71
+ const tool = tools.find(t => t.name === tc.name);
72
+ setCurrentTool(tc.name);
73
+ if (tool) {
74
+ try {
75
+ const result = await tool.execute(tc.args);
76
+ printer.toolMsg(tc.name, result);
77
+ next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
78
+ }
79
+ catch (e) {
80
+ const err = `Tool ${tc.name} error: ${e}`;
81
+ printer.errorMsg(err);
82
+ next.push({ role: 'user', content: err });
83
+ }
84
+ }
85
+ else {
86
+ printer.errorMsg(`unknown tool: ${tc.name}`);
87
+ next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
88
+ }
89
+ }
90
+ }
91
+ finally {
92
+ setCurrentTool(undefined);
93
+ }
94
+ // Auto-run tests after file edits
95
+ const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
96
+ if (didEditFiles) {
97
+ const testTool = tools.find(t => t.name === 'run_tests');
98
+ if (testTool) {
99
+ setCurrentTool('run_tests');
100
+ try {
101
+ const testResult = await testTool.execute({});
102
+ if (testResult && !testResult.startsWith('(no test script') && !testResult.startsWith('(no package.json')) {
103
+ printer.toolMsg('run_tests', testResult);
104
+ next.push({ role: 'user', content: `Test results after edits:\n${testResult}` });
105
+ }
106
+ }
107
+ catch (e) {
108
+ const err = `run_tests error: ${e}`;
109
+ printer.errorMsg(err);
110
+ next.push({ role: 'user', content: err });
111
+ }
112
+ finally {
113
+ setCurrentTool(undefined);
114
+ }
115
+ }
116
+ }
117
+ await runLoop(next, depth + 1, goal);
118
+ },
119
+ onError(err) {
120
+ if (err.name !== 'AbortError')
121
+ printer.errorMsg(err.message);
122
+ setStatus('idle');
123
+ },
124
+ });
125
+ }, [config]);
126
+ const handleAbort = useCallback(() => {
127
+ abortRef.current?.abort();
128
+ setStatus('idle');
129
+ }, []);
130
+ return {
131
+ status, setStatus, tick,
132
+ currentTool, setCurrentTool,
133
+ taskLabel, setTaskLabel,
134
+ thinkingStartRef, abortRef,
135
+ runLoop, handleAbort,
136
+ };
137
+ }
@@ -0,0 +1,50 @@
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { loadSession, saveSession } from '../../sessions.js';
3
+ import { getSystemPrompt } from '../../tools/index.js';
4
+ import { getTavilyKey, saveTavilyKey } from '../../tavily/client.js';
5
+ import * as printer from '../printer.js';
6
+ export function useSession(initialSession, cwd, config) {
7
+ const [sessionName, setSessionName] = useState(initialSession);
8
+ const sessionNameRef = useRef(initialSession);
9
+ const historyRef = useRef([]);
10
+ const saveTimerRef = useRef(null);
11
+ const systemPromptRef = useRef(getSystemPrompt(`\n- CWD: ${cwd}`));
12
+ useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
13
+ useEffect(() => {
14
+ const history = loadSession(initialSession);
15
+ historyRef.current = history;
16
+ if (history.length)
17
+ printer.systemMsg(`resumed "${initialSession}" — ${history.length} messages`);
18
+ if (config.tavilyApiKey && !getTavilyKey())
19
+ saveTavilyKey(config.tavilyApiKey);
20
+ if (!getTavilyKey()) {
21
+ printer.systemMsg('Tavily API key not set — web search disabled. Run /tavily-key <key> to enable. Get a free key at https://tavily.com');
22
+ }
23
+ }, []);
24
+ function scheduleSave() {
25
+ if (saveTimerRef.current)
26
+ clearTimeout(saveTimerRef.current);
27
+ saveTimerRef.current = setTimeout(() => {
28
+ saveSession(sessionNameRef.current, historyRef.current);
29
+ saveTimerRef.current = null;
30
+ }, 2000);
31
+ }
32
+ function pushHistory(msg) {
33
+ historyRef.current.push(msg);
34
+ if (historyRef.current.length > 100)
35
+ historyRef.current.splice(0, historyRef.current.length - 100);
36
+ scheduleSave();
37
+ }
38
+ function buildContext(extra) {
39
+ const ctx = [{ role: 'system', content: systemPromptRef.current }];
40
+ ctx.push(...historyRef.current);
41
+ if (extra)
42
+ ctx.push(extra);
43
+ return ctx;
44
+ }
45
+ return {
46
+ sessionName, setSessionName, sessionNameRef,
47
+ historyRef, saveTimerRef, systemPromptRef,
48
+ pushHistory, buildContext,
49
+ };
50
+ }
@@ -41,7 +41,7 @@ function formatContent(text) {
41
41
  }
42
42
  return out.join('\n');
43
43
  }
44
- export function welcome(provider, model, cwd) {
44
+ export function welcome(provider, model, cwd, version, updateAvailable, linked) {
45
45
  const cols = Math.min(process.stdout.columns ?? 80, 100);
46
46
  const innerW = cols - 2;
47
47
  const leftW = Math.floor(innerW * 0.44);
@@ -64,11 +64,17 @@ export function welcome(provider, model, cwd) {
64
64
  function rcmd(key, desc, keyW = 10) {
65
65
  return ' ' + cyan(key) + ' '.repeat(Math.max(1, keyW - key.length)) + gray(desc);
66
66
  }
67
- const titleStr = '─ MIII - CLI ';
67
+ const versionStr = version ? ` v${version}` : '';
68
+ const titleStr = `─ MIII - CLI${versionStr} `;
68
69
  const dashCount = Math.max(0, cols - 2 - titleStr.length);
69
- const top = gray('╭') + gray('─') + bold(cyan(' MIII - CLI ')) + gray('─'.repeat(dashCount) + '╮');
70
+ const top = gray('╭') + gray('─') + bold(cyan(` MIII - CLI${versionStr} `)) + gray('─'.repeat(dashCount) + '╮');
70
71
  const bottom = gray('╰' + '─'.repeat(innerW) + '╯');
71
72
  const shortCwd = cwd.replace(process.env.HOME ?? '', '~');
73
+ const upgradeCmd = linked ? 'cd <miii-dir> && npm run build' : 'npm install -g miii-cli';
74
+ const separator = gray('│') + bold(yellow(' ⬆ update available: v' + updateAvailable + ' — run: ' + upgradeCmd)).padEnd(innerW - 1) + gray('│');
75
+ const updateRow = updateAvailable
76
+ ? [gray('├' + '─'.repeat(innerW) + '┤'), separator, gray('├' + '─'.repeat(innerW) + '┤')]
77
+ : [];
72
78
  const lines = [
73
79
  top,
74
80
  blank(),
@@ -82,6 +88,7 @@ export function welcome(provider, model, cwd) {
82
88
  row(` ${gray(provider + '/' + model)}`, ` ${bold(yellow('Tips'))}`),
83
89
  row(` ${gray(shortCwd)}`, rcmd('ctrl+c', 'stop thinking')),
84
90
  row('', rcmd('ctrl+c x2', 'exit')),
91
+ ...updateRow,
85
92
  blank(),
86
93
  bottom,
87
94
  ];
@@ -0,0 +1,28 @@
1
+ export const THINKING_PHRASES = [
2
+ 'oh wow, a question. let me pretend to care…',
3
+ 'consulting the void…',
4
+ 'making something up, just a sec…',
5
+ 'definitely not hallucinating right now…',
6
+ 'running 47 mental tabs…',
7
+ 'staring into the abyss (it blinked)…',
8
+ 'calculating your fate, no pressure…',
9
+ 'doing the thinking you pay me for…',
10
+ 'processing your questionable life choices…',
11
+ 'summoning coherent thoughts, rarely works…',
12
+ 'asking my imaginary friend for help…',
13
+ 'pretending this is a hard problem…',
14
+ 'yes, yes, very interesting. anyway…',
15
+ 'googling it (not really, I can\'t)…',
16
+ 'simulating intelligence… please wait…',
17
+ 'having a brief existential crisis…',
18
+ 'cross-referencing vibes…',
19
+ 'totally not making this up…',
20
+ 'the answer is 42. now finding the question…',
21
+ 'my other tab is loading…',
22
+ 'channelling the spirit of stack overflow…',
23
+ 'trying not to confidently be wrong…',
24
+ 'applying artificial to the intelligence…',
25
+ 'phoning a friend who also doesn\'t know…',
26
+ 'checking if this is even my problem to solve…',
27
+ ];
28
+ export const SPARKLE = ['✦', '✧', '✶', '✷', '✸', '✹'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "Claude Code-level terminal workflows powered by your local models.",
6
6
  "license": "MIT",
@@ -36,7 +36,8 @@
36
36
  "dev": "tsx src/index.ts",
37
37
  "build": "tsc",
38
38
  "start": "node dist/index.js",
39
- "link": "npm run build && npm link"
39
+ "link": "npm run build && npm link",
40
+ "test": "vitest run"
40
41
  },
41
42
  "dependencies": {
42
43
  "ink": "^5.2.0",
@@ -49,6 +50,7 @@
49
50
  "@types/node": "^22.10.0",
50
51
  "@types/react": "^18.3.1",
51
52
  "tsx": "^4.19.1",
52
- "typescript": "^5.7.3"
53
+ "typescript": "^5.7.3",
54
+ "vitest": "^4.1.6"
53
55
  }
54
56
  }
package/dist/tui/App.js DELETED
@@ -1,285 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback, useRef, useEffect } from 'react';
3
- import { Box, Text, useStdout, useInput } from 'ink';
4
- import { StatusBar, Divider } from './components/StatusBar.js';
5
- import { MessageList } from './components/MessageList.js';
6
- import { InputArea } from './components/InputArea.js';
7
- import { ModelPicker } from './components/ModelPicker.js';
8
- import { chat } from '../llm/stream.js';
9
- import { listModels, pullModel } from '../llm/ollama.js';
10
- import { StreamParser, extractBareToolCall } from '../parser/stream-parser.js';
11
- import { tools, getSystemPrompt } from '../tools/index.js';
12
- import { readFile, guardPath } from '../files/ops.js';
13
- import { generateId } from '../types.js';
14
- const MAX_TOOL_DEPTH = 6;
15
- function expandAtRefs(text, cwd) {
16
- const refs = [...text.matchAll(/@([\w./\-]+)/g)];
17
- if (!refs.length)
18
- return { displayText: text, contextPrefix: '' };
19
- const parts = [];
20
- for (const m of refs) {
21
- try {
22
- const safePath = guardPath(m[1], cwd);
23
- const content = readFile(safePath);
24
- parts.push(`<file path="${m[1]}">\n${content}\n</file>`);
25
- }
26
- catch { }
27
- }
28
- return { displayText: text, contextPrefix: parts.length ? parts.join('\n\n') + '\n\n' : '' };
29
- }
30
- export function App({ config, skills, cwd }) {
31
- const { stdout } = useStdout();
32
- const [messages, setMessages] = useState([{
33
- id: 'welcome',
34
- role: 'system',
35
- content: `local AI coding assistant · ${config.provider}/${config.model} · cwd: ${cwd}`,
36
- timestamp: Date.now(),
37
- }]);
38
- const [status, setStatus] = useState('idle');
39
- const [tick, setTick] = useState(0);
40
- const [currentModel, setCurrentModel] = useState(config.model);
41
- const [scrollOffset, setScrollOffset] = useState(0);
42
- // model picker
43
- const [pickerOpen, setPickerOpen] = useState(false);
44
- const [pickerModels, setPickerModels] = useState([]);
45
- const [pickerLoading, setPickerLoading] = useState(false);
46
- const [pickerError, setPickerError] = useState();
47
- const [pullState, setPullState] = useState();
48
- const [systemPrompt, setSystemPrompt] = useState(() => getSystemPrompt(`\n- CWD: ${cwd}`));
49
- const systemPromptRef = useRef(systemPrompt);
50
- const currentModelRef = useRef(currentModel);
51
- const abortRef = useRef(null);
52
- const pullAbortRef = useRef(null);
53
- const messagesRef = useRef(messages);
54
- const approvalResolveRef = useRef(null);
55
- const [pendingApproval, setPendingApproval] = useState(null);
56
- const pendingApprovalRef = useRef(pendingApproval);
57
- useEffect(() => { systemPromptRef.current = systemPrompt; }, [systemPrompt]);
58
- useEffect(() => { currentModelRef.current = currentModel; }, [currentModel]);
59
- useEffect(() => { messagesRef.current = messages; }, [messages]);
60
- useEffect(() => { pendingApprovalRef.current = pendingApproval; }, [pendingApproval]);
61
- useEffect(() => {
62
- if (status === 'idle')
63
- return;
64
- const t = setInterval(() => setTick(n => n + 1), 80);
65
- return () => clearInterval(t);
66
- }, [status]);
67
- // Scroll keybindings — PageUp/PageDn scroll message history
68
- const SCROLL_STEP = 5;
69
- useInput((_input, key) => {
70
- // approvalResolveRef is set synchronously in requestApproval — no useEffect needed
71
- if (approvalResolveRef.current) {
72
- const resolve = approvalResolveRef.current;
73
- if (_input === 'y' || _input === 'Y') {
74
- approvalResolveRef.current = null;
75
- setPendingApproval(null);
76
- resolve(true);
77
- }
78
- else if (_input === 'n' || _input === 'N' || key.escape) {
79
- approvalResolveRef.current = null;
80
- setPendingApproval(null);
81
- resolve(false);
82
- }
83
- return;
84
- }
85
- if (pickerOpen)
86
- return;
87
- if (key.pageUp) {
88
- setScrollOffset(n => Math.min(n + SCROLL_STEP, Math.max(0, messages.length - 1)));
89
- }
90
- if (key.pageDown) {
91
- setScrollOffset(n => Math.max(0, n - SCROLL_STEP));
92
- }
93
- });
94
- const cols = stdout.columns ?? 80;
95
- const rows = stdout.rows ?? 24;
96
- const APPROVAL_TOOLS = new Set(['delete_file']);
97
- const requestApproval = useCallback((toolName, args) => {
98
- return new Promise((resolve) => {
99
- approvalResolveRef.current = resolve;
100
- setPendingApproval({
101
- toolName,
102
- path: (args.path ?? args.from) ?? '',
103
- content: args.content,
104
- });
105
- });
106
- }, []);
107
- function addMsg(role, content, id) {
108
- const mid = id ?? generateId();
109
- setMessages(prev => [...prev, { id: mid, role, content, timestamp: Date.now() }]);
110
- return mid;
111
- }
112
- function buildContext(extra) {
113
- const ctx = [{ role: 'system', content: systemPromptRef.current }];
114
- for (const m of messagesRef.current) {
115
- if (m.role === 'tool')
116
- ctx.push({ role: 'user', content: `[tool result]\n${m.content}` });
117
- else if (m.role === 'user' || m.role === 'assistant')
118
- ctx.push({ role: m.role, content: m.content });
119
- }
120
- if (extra)
121
- ctx.push(extra);
122
- return ctx;
123
- }
124
- const runLoop = useCallback(async (contextMsgs, depth = 0) => {
125
- if (depth >= MAX_TOOL_DEPTH) {
126
- setStatus('idle');
127
- return;
128
- }
129
- setStatus('thinking');
130
- const assistantId = generateId();
131
- setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
132
- abortRef.current = new AbortController();
133
- await chat({
134
- provider: config.provider,
135
- model: currentModelRef.current,
136
- baseUrl: config.baseUrl,
137
- apiKey: config.apiKey,
138
- messages: contextMsgs,
139
- signal: abortRef.current.signal,
140
- async onDone(fullText) {
141
- setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: fullText } : m));
142
- const pendingTools = [];
143
- const parser = new StreamParser();
144
- for (const item of [...parser.feed(fullText), ...parser.flush()]) {
145
- if (item.type === 'tool_call')
146
- pendingTools.push({ name: item.toolName, args: item.toolArgs });
147
- }
148
- if (!pendingTools.length) {
149
- const bare = extractBareToolCall(fullText);
150
- if (bare) {
151
- pendingTools.push(bare);
152
- }
153
- else {
154
- if (fullText.includes('{"name"')) {
155
- addMsg('tool', 'tool_call parse failed — could not extract tool call from model output');
156
- }
157
- setStatus('idle');
158
- return;
159
- }
160
- }
161
- setStatus('tool');
162
- const next = [...contextMsgs, { role: 'assistant', content: fullText }];
163
- for (const tc of pendingTools) {
164
- const tool = tools.find(t => t.name === tc.name);
165
- const toolId = generateId();
166
- if (tool) {
167
- if (APPROVAL_TOOLS.has(tc.name)) {
168
- const approved = await requestApproval(tc.name, tc.args);
169
- if (!approved) {
170
- const cancelled = `[${tc.name}] cancelled by user`;
171
- setMessages(prev => [...prev, { id: toolId, role: 'tool', content: cancelled, timestamp: Date.now() }]);
172
- next.push({ role: 'user', content: `Tool ${tc.name} was cancelled by user.` });
173
- continue;
174
- }
175
- }
176
- try {
177
- const result = await tool.execute(tc.args);
178
- setMessages(prev => [...prev, { id: toolId, role: 'tool', content: `[${tc.name}]\n${result}`, timestamp: Date.now() }]);
179
- next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
180
- }
181
- catch (e) {
182
- const err = `Tool ${tc.name} error: ${e}`;
183
- setMessages(prev => [...prev, { id: toolId, role: 'tool', content: err, timestamp: Date.now() }]);
184
- next.push({ role: 'user', content: err });
185
- }
186
- }
187
- else {
188
- const unk = `Unknown tool: ${tc.name}`;
189
- setMessages(prev => [...prev, { id: toolId, role: 'tool', content: unk, timestamp: Date.now() }]);
190
- next.push({ role: 'user', content: unk });
191
- }
192
- }
193
- await runLoop(next, depth + 1);
194
- },
195
- onError(err) {
196
- setMessages(prev => prev.filter(m => m.id !== assistantId));
197
- addMsg('system', `error: ${err.message}`);
198
- setStatus('idle');
199
- },
200
- });
201
- }, [config]);
202
- // Model picker
203
- const openPicker = useCallback(async () => {
204
- setPickerOpen(true);
205
- setPickerLoading(true);
206
- setPickerError(undefined);
207
- try {
208
- setPickerModels(await listModels(config.baseUrl));
209
- }
210
- catch (e) {
211
- setPickerError(String(e));
212
- }
213
- finally {
214
- setPickerLoading(false);
215
- }
216
- }, [config.baseUrl]);
217
- const handleModelSelect = useCallback((name) => {
218
- setCurrentModel(name);
219
- setPickerOpen(false);
220
- addMsg('system', `model → ${name}`);
221
- }, []);
222
- const handleModelPull = useCallback(async (name) => {
223
- setPullState({ name, status: 'starting...', pct: undefined });
224
- pullAbortRef.current = new AbortController();
225
- try {
226
- await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal);
227
- setPickerModels(await listModels(config.baseUrl));
228
- setPullState(undefined);
229
- setCurrentModel(name);
230
- setPickerOpen(false);
231
- addMsg('system', `pulled ${name} → active`);
232
- }
233
- catch (e) {
234
- setPullState(undefined);
235
- setPickerError(`pull failed: ${e}`);
236
- }
237
- }, [config.baseUrl]);
238
- const handleSubmit = useCallback(async (text) => {
239
- setScrollOffset(0); // snap to bottom on new message
240
- if (text.trim() === '/models') {
241
- await openPicker();
242
- return;
243
- }
244
- if (text.startsWith('/')) {
245
- const [cmd, ...rest] = text.slice(1).split(' ');
246
- const skill = skills.get(cmd);
247
- if (skill) {
248
- if (skill.name === 'list') {
249
- addMsg('system', skills.list().map(s => `/${s.ns === 'default' ? '' : s.ns + ':'}${s.name} — ${s.description}`).join('\n'));
250
- return;
251
- }
252
- if (skill.execute) {
253
- const ctx = {
254
- messages: messagesRef.current.map(m => ({ role: m.role, content: m.content })),
255
- appendMessage: (role, content) => addMsg(role, content),
256
- setSystemPrompt: (p) => setSystemPrompt(p),
257
- getSystemPrompt: () => systemPromptRef.current,
258
- };
259
- const result = await skill.execute(rest.join(' '), ctx);
260
- if (result)
261
- addMsg('system', result);
262
- return;
263
- }
264
- if (skill.prompt) {
265
- addMsg('user', skill.prompt);
266
- await runLoop(buildContext({ role: 'user', content: skill.prompt }));
267
- return;
268
- }
269
- }
270
- addMsg('system', `unknown skill: /${cmd}. Try /list`);
271
- return;
272
- }
273
- // Expand @file references
274
- const { displayText, contextPrefix } = expandAtRefs(text, cwd);
275
- addMsg('user', displayText);
276
- const llmContent = contextPrefix + text;
277
- await runLoop(buildContext({ role: 'user', content: llmContent }));
278
- }, [skills, runLoop, openPicker]);
279
- const handleAbort = useCallback(() => {
280
- abortRef.current?.abort();
281
- setStatus('idle');
282
- }, []);
283
- const skillList = skills.list();
284
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(StatusBar, { model: currentModel, provider: config.provider, status: status, tick: tick }), _jsx(Divider, { cols: cols }), pickerOpen ? (_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false); setPullState(undefined); } })) : (_jsx(MessageList, { messages: messages, rows: rows - 8, cols: cols, scrollOffset: scrollOffset, streaming: false, thinkingTick: status === 'thinking' ? tick : undefined })), _jsx(Divider, { cols: cols }), pendingApproval && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginBottom: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Allow ", pendingApproval.toolName, "?"] }), _jsxs(Text, { children: [" path: ", _jsx(Text, { color: "cyan", children: pendingApproval.path })] }), pendingApproval.content && (_jsx(Text, { color: "gray", dimColor: true, children: pendingApproval.content.split('\n').slice(0, 12).join('\n') })), _jsxs(Text, { color: "green", children: ["[y] approve ", _jsx(Text, { color: "red", children: "[n] cancel" })] })] })), _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, onSubmit: handleSubmit, onAbort: handleAbort })] }));
285
- }