miii-cli 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/init.js +1 -1
- package/dist/llm/stream.js +67 -12
- package/dist/mcp/client.js +6 -2
- package/dist/memory/store.js +9 -11
- package/dist/sessions.js +30 -20
- package/dist/tasks/compactor.js +14 -4
- package/dist/tools/index.js +5 -3
- package/dist/tui/InputBar.js +73 -10
- package/dist/tui/components/InputArea.js +8 -3
- package/dist/tui/git-context.js +5 -40
- package/dist/tui/hooks/useRunLoop.js +13 -4
- package/dist/tui/hooks/useSession.js +29 -16
- package/dist/tui/hooks/useSubmit.js +55 -20
- package/dist/tui/hooks/useWatch.js +119 -0
- package/dist/tui/printer.js +2 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,6 +164,7 @@ No API keys. No account. No sign-up form. First run walks you through setup inte
|
|
|
164
164
|
| `/plan <topic>` | Structured planning mode before you write a line |
|
|
165
165
|
| `/model <name>` | Hot-swap your LLM mid-conversation |
|
|
166
166
|
| `/session <name>` | Switch between named project sessions |
|
|
167
|
+
| `/watch <path>` | Monitor files for changes and trigger agent reactions |
|
|
167
168
|
| `@filename` | Inject any file directly into context |
|
|
168
169
|
|
|
169
170
|
---
|
package/dist/init.js
CHANGED
|
@@ -15,7 +15,7 @@ import { loadMCPTools } from './mcp/client.js';
|
|
|
15
15
|
import { needsSetup, runSetup } from './setup.js';
|
|
16
16
|
const require = createRequire(import.meta.url);
|
|
17
17
|
const UPDATE_CACHE = join(homedir(), '.config', 'miii', 'update-check.json');
|
|
18
|
-
const CHECK_INTERVAL_MS =
|
|
18
|
+
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1h
|
|
19
19
|
function semverGt(a, b) {
|
|
20
20
|
const pa = a.split('.').map(Number);
|
|
21
21
|
const pb = b.split('.').map(Number);
|
package/dist/llm/stream.js
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
+
// Transient errors worth retrying: rate limits + server-side faults
|
|
2
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 529]);
|
|
3
|
+
const MAX_RETRIES = 4;
|
|
4
|
+
const MAX_DELAY_MS = 30_000;
|
|
5
|
+
function retryDelay(attempt) {
|
|
6
|
+
// Exponential backoff: 1s → 2s → 4s → 8s, capped at 30s, ±20% jitter
|
|
7
|
+
const base = 1_000 * Math.pow(2, attempt);
|
|
8
|
+
const capped = Math.min(base, MAX_DELAY_MS);
|
|
9
|
+
return Math.round(capped * (0.8 + Math.random() * 0.4));
|
|
10
|
+
}
|
|
11
|
+
function sleep(ms, signal) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
if (signal?.aborted) {
|
|
14
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const t = setTimeout(resolve, ms);
|
|
18
|
+
signal?.addEventListener('abort', () => { clearTimeout(t); reject(new DOMException('Aborted', 'AbortError')); }, { once: true });
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async function fetchWithRetry(url, init, signal, onRetry) {
|
|
22
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await fetch(url, { ...init, signal });
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err?.name === 'AbortError')
|
|
29
|
+
throw err;
|
|
30
|
+
if (attempt === MAX_RETRIES)
|
|
31
|
+
throw err;
|
|
32
|
+
const delayMs = retryDelay(attempt);
|
|
33
|
+
onRetry?.(attempt + 1, MAX_RETRIES, delayMs);
|
|
34
|
+
await sleep(delayMs, signal);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (res.ok || !RETRYABLE_STATUS.has(res.status) || attempt === MAX_RETRIES)
|
|
38
|
+
return res;
|
|
39
|
+
const retryAfterSec = Number(res.headers.get('retry-after') ?? 0);
|
|
40
|
+
const delayMs = retryAfterSec > 0 ? retryAfterSec * 1000 : retryDelay(attempt);
|
|
41
|
+
onRetry?.(attempt + 1, MAX_RETRIES, delayMs);
|
|
42
|
+
await sleep(delayMs, signal);
|
|
43
|
+
}
|
|
44
|
+
throw new Error('fetchWithRetry: exhausted retries without returning');
|
|
45
|
+
}
|
|
46
|
+
export async function warmup(provider, baseUrl, model) {
|
|
47
|
+
if (provider !== 'ollama')
|
|
48
|
+
return;
|
|
49
|
+
try {
|
|
50
|
+
await fetch(`${baseUrl}/api/generate`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ model, keep_alive: '10m' }),
|
|
54
|
+
signal: AbortSignal.timeout(30_000),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch { }
|
|
58
|
+
}
|
|
1
59
|
export async function chat(cfg) {
|
|
2
60
|
if (cfg.provider === 'anthropic')
|
|
3
61
|
return chatAnthropic(cfg);
|
|
@@ -6,14 +64,13 @@ export async function chat(cfg) {
|
|
|
6
64
|
return chatOllama(cfg);
|
|
7
65
|
}
|
|
8
66
|
async function chatOllama(cfg) {
|
|
9
|
-
const { model, messages, baseUrl, signal, onDone, onError, onUsage, onChunk } = cfg;
|
|
67
|
+
const { model, messages, baseUrl, signal, onDone, onError, onUsage, onChunk, onRetry } = cfg;
|
|
10
68
|
try {
|
|
11
|
-
const res = await
|
|
69
|
+
const res = await fetchWithRetry(`${baseUrl}/api/chat`, {
|
|
12
70
|
method: 'POST',
|
|
13
71
|
headers: { 'Content-Type': 'application/json' },
|
|
14
72
|
body: JSON.stringify({ model, messages, stream: !!onChunk }),
|
|
15
|
-
|
|
16
|
-
});
|
|
73
|
+
}, signal, onRetry);
|
|
17
74
|
if (!res.ok) {
|
|
18
75
|
onError(new Error(`Ollama ${res.status}: ${await res.text()}`));
|
|
19
76
|
return;
|
|
@@ -64,14 +121,13 @@ async function chatOllama(cfg) {
|
|
|
64
121
|
}
|
|
65
122
|
}
|
|
66
123
|
async function chatOpenAI(cfg) {
|
|
67
|
-
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk } = cfg;
|
|
124
|
+
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk, onRetry } = cfg;
|
|
68
125
|
try {
|
|
69
|
-
const res = await
|
|
126
|
+
const res = await fetchWithRetry(`${baseUrl}/v1/chat/completions`, {
|
|
70
127
|
method: 'POST',
|
|
71
128
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey ?? 'local'}` },
|
|
72
129
|
body: JSON.stringify({ model, messages, stream: !!onChunk }),
|
|
73
|
-
|
|
74
|
-
});
|
|
130
|
+
}, signal, onRetry);
|
|
75
131
|
if (!res.ok) {
|
|
76
132
|
onError(new Error(`LLM ${res.status}: ${await res.text()}`));
|
|
77
133
|
return;
|
|
@@ -118,7 +174,7 @@ async function chatOpenAI(cfg) {
|
|
|
118
174
|
}
|
|
119
175
|
}
|
|
120
176
|
async function chatAnthropic(cfg) {
|
|
121
|
-
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage } = cfg;
|
|
177
|
+
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onRetry } = cfg;
|
|
122
178
|
const url = baseUrl && baseUrl !== 'http://localhost:11434'
|
|
123
179
|
? `${baseUrl}/v1/messages`
|
|
124
180
|
: 'https://api.anthropic.com/v1/messages';
|
|
@@ -132,7 +188,7 @@ async function chatAnthropic(cfg) {
|
|
|
132
188
|
};
|
|
133
189
|
if (systemParts.length)
|
|
134
190
|
body.system = systemParts.join('\n\n');
|
|
135
|
-
const res = await
|
|
191
|
+
const res = await fetchWithRetry(url, {
|
|
136
192
|
method: 'POST',
|
|
137
193
|
headers: {
|
|
138
194
|
'content-type': 'application/json',
|
|
@@ -140,8 +196,7 @@ async function chatAnthropic(cfg) {
|
|
|
140
196
|
'anthropic-version': '2023-06-01',
|
|
141
197
|
},
|
|
142
198
|
body: JSON.stringify(body),
|
|
143
|
-
|
|
144
|
-
});
|
|
199
|
+
}, signal, onRetry);
|
|
145
200
|
if (!res.ok) {
|
|
146
201
|
onError(new Error(`Anthropic ${res.status}: ${await res.text()}`));
|
|
147
202
|
return;
|
package/dist/mcp/client.js
CHANGED
|
@@ -68,9 +68,13 @@ export class MCPClient {
|
|
|
68
68
|
send(method, params) {
|
|
69
69
|
return new Promise((resolve, reject) => {
|
|
70
70
|
const id = this.nextId++;
|
|
71
|
-
|
|
71
|
+
let timer;
|
|
72
|
+
this.pending.set(id, {
|
|
73
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
74
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
75
|
+
});
|
|
72
76
|
this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
73
|
-
setTimeout(() => {
|
|
77
|
+
timer = setTimeout(() => {
|
|
74
78
|
if (this.pending.has(id)) {
|
|
75
79
|
this.pending.delete(id);
|
|
76
80
|
reject(new Error(`MCP timeout: ${method}`));
|
package/dist/memory/store.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import { homedir } from 'os';
|
|
4
|
-
const MEMORY_DIR = join(homedir(), '.config', 'miii', 'memory');
|
|
5
3
|
const MAX_FACTS = 200;
|
|
6
|
-
function
|
|
7
|
-
|
|
4
|
+
function memoryPath(projectDir) {
|
|
5
|
+
return join(projectDir, 'memory.json');
|
|
8
6
|
}
|
|
9
|
-
export function loadLongMemory(
|
|
10
|
-
|
|
11
|
-
const p =
|
|
7
|
+
export function loadLongMemory(projectDir) {
|
|
8
|
+
mkdirSync(projectDir, { recursive: true });
|
|
9
|
+
const p = memoryPath(projectDir);
|
|
12
10
|
if (!existsSync(p))
|
|
13
11
|
return [];
|
|
14
12
|
try {
|
|
@@ -19,9 +17,9 @@ export function loadLongMemory(sessionName) {
|
|
|
19
17
|
return [];
|
|
20
18
|
}
|
|
21
19
|
}
|
|
22
|
-
export function saveLongMemory(
|
|
23
|
-
|
|
24
|
-
writeFileSync(
|
|
20
|
+
export function saveLongMemory(projectDir, facts) {
|
|
21
|
+
mkdirSync(projectDir, { recursive: true });
|
|
22
|
+
writeFileSync(memoryPath(projectDir), JSON.stringify(facts));
|
|
25
23
|
}
|
|
26
24
|
export function mergeFacts(existing, newTexts) {
|
|
27
25
|
const existingSet = new Set(existing.map(f => f.text.toLowerCase()));
|
|
@@ -37,5 +35,5 @@ export function mergeFacts(existing, newTexts) {
|
|
|
37
35
|
export function formatMemoryBlock(facts) {
|
|
38
36
|
if (!facts.length)
|
|
39
37
|
return '';
|
|
40
|
-
return `\n\n[Long-term memory — recalled from prior
|
|
38
|
+
return `\n\n[Long-term memory — recalled from prior sessions in this project]\n${facts.map(f => `- ${f.text}`).join('\n')}`;
|
|
41
39
|
}
|
package/dist/sessions.js
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, statSync, existsSync, unlinkSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
|
-
const
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
const PROJECTS_DIR = join(homedir(), '.config', 'miii', 'projects');
|
|
5
|
+
export function getProjectDir(cwd) {
|
|
6
|
+
const slug = cwd.replace(/\//g, '-').replace(/^-/, '').replace(/[^a-zA-Z0-9_.-]/g, '-') || 'default';
|
|
7
|
+
return join(PROJECTS_DIR, slug);
|
|
8
|
+
}
|
|
9
|
+
function sessionsDir(projectDir) {
|
|
10
|
+
return join(projectDir, 'sessions');
|
|
11
|
+
}
|
|
12
|
+
function ensureProjectDir(projectDir) {
|
|
13
|
+
mkdirSync(sessionsDir(projectDir), { recursive: true, mode: 0o700 });
|
|
14
|
+
chmodSync(projectDir, 0o700);
|
|
8
15
|
}
|
|
9
16
|
function sanitizeName(name) {
|
|
10
17
|
if (!/^[\w-]+$/.test(name))
|
|
11
18
|
throw new Error(`invalid session name: ${name}`);
|
|
12
19
|
return name;
|
|
13
20
|
}
|
|
14
|
-
export function listSessions() {
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
export function listSessions(projectDir) {
|
|
22
|
+
ensureProjectDir(projectDir);
|
|
23
|
+
const dir = sessionsDir(projectDir);
|
|
24
|
+
return readdirSync(dir)
|
|
17
25
|
.filter(f => f.endsWith('.json'))
|
|
18
26
|
.map(f => {
|
|
19
27
|
const name = f.replace('.json', '');
|
|
20
|
-
const p = join(
|
|
28
|
+
const p = join(dir, f);
|
|
21
29
|
let messageCount = 0;
|
|
22
30
|
let updatedAt = 0;
|
|
23
31
|
try {
|
|
@@ -30,9 +38,9 @@ export function listSessions() {
|
|
|
30
38
|
})
|
|
31
39
|
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
32
40
|
}
|
|
33
|
-
export function loadSession(name) {
|
|
34
|
-
|
|
35
|
-
const p = join(
|
|
41
|
+
export function loadSession(projectDir, name) {
|
|
42
|
+
ensureProjectDir(projectDir);
|
|
43
|
+
const p = join(sessionsDir(projectDir), `${sanitizeName(name)}.json`);
|
|
36
44
|
if (!existsSync(p))
|
|
37
45
|
return [];
|
|
38
46
|
try {
|
|
@@ -43,28 +51,30 @@ export function loadSession(name) {
|
|
|
43
51
|
return [];
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
|
-
export function saveSession(name, messages) {
|
|
47
|
-
|
|
54
|
+
export function saveSession(projectDir, name, messages) {
|
|
55
|
+
const safeName = sanitizeName(name);
|
|
56
|
+
ensureProjectDir(projectDir);
|
|
48
57
|
try {
|
|
49
|
-
writeFileSync(join(
|
|
58
|
+
writeFileSync(join(sessionsDir(projectDir), `${safeName}.json`), JSON.stringify(messages), { mode: 0o600 });
|
|
50
59
|
}
|
|
51
60
|
catch { }
|
|
52
61
|
}
|
|
53
|
-
export function deleteSession(name) {
|
|
54
|
-
const p = join(
|
|
62
|
+
export function deleteSession(projectDir, name) {
|
|
63
|
+
const p = join(sessionsDir(projectDir), `${sanitizeName(name)}.json`);
|
|
55
64
|
if (existsSync(p))
|
|
56
65
|
unlinkSync(p);
|
|
57
66
|
}
|
|
58
|
-
export function deleteAllSessions(exceptName) {
|
|
59
|
-
|
|
60
|
-
const
|
|
67
|
+
export function deleteAllSessions(projectDir, exceptName) {
|
|
68
|
+
ensureProjectDir(projectDir);
|
|
69
|
+
const dir = sessionsDir(projectDir);
|
|
70
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
61
71
|
let count = 0;
|
|
62
72
|
for (const f of files) {
|
|
63
73
|
const name = f.replace('.json', '');
|
|
64
74
|
if (exceptName && name === exceptName)
|
|
65
75
|
continue;
|
|
66
76
|
try {
|
|
67
|
-
unlinkSync(join(
|
|
77
|
+
unlinkSync(join(dir, f));
|
|
68
78
|
count++;
|
|
69
79
|
}
|
|
70
80
|
catch { }
|
package/dist/tasks/compactor.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { chat } from '../llm/stream.js';
|
|
2
|
-
|
|
2
|
+
// ~4 chars per token heuristic. 40K chars ≈ 10K tokens — safe floor for 7B local models.
|
|
3
|
+
// Cloud providers can handle more but compacting early keeps responses fast regardless.
|
|
4
|
+
const COMPACT_CHAR_THRESHOLD = 40_000;
|
|
3
5
|
const KEEP_RECENT = 6;
|
|
6
|
+
export function contextSize(messages) {
|
|
7
|
+
return messages.reduce((sum, m) => sum + m.content.length, 0);
|
|
8
|
+
}
|
|
4
9
|
export function shouldCompact(messages) {
|
|
5
|
-
return messages
|
|
10
|
+
return contextSize(messages) > COMPACT_CHAR_THRESHOLD;
|
|
6
11
|
}
|
|
7
12
|
const COMPACT_SYSTEM = `You are a context summarizer for an AI coding agent session.
|
|
8
13
|
Your job: produce a dense, structured summary of the conversation so the agent can continue the task without losing context.
|
|
@@ -26,7 +31,7 @@ Any constraints, errors encountered, important facts the agent must remember to
|
|
|
26
31
|
|
|
27
32
|
Be factual. No padding. Include file paths, error messages, and command outputs verbatim when relevant.`;
|
|
28
33
|
export async function compactContext(messages, cfg, goal) {
|
|
29
|
-
if (messages
|
|
34
|
+
if (contextSize(messages) <= COMPACT_CHAR_THRESHOLD)
|
|
30
35
|
return messages;
|
|
31
36
|
const system = messages[0]?.role === 'system' ? messages[0] : null;
|
|
32
37
|
const recent = messages.slice(messages.length - KEEP_RECENT);
|
|
@@ -42,6 +47,7 @@ export async function compactContext(messages, cfg, goal) {
|
|
|
42
47
|
`Conversation to summarize:\n\n${transcript}`,
|
|
43
48
|
].join('');
|
|
44
49
|
let summary = '';
|
|
50
|
+
let compactErr = '';
|
|
45
51
|
await chat({
|
|
46
52
|
...cfg,
|
|
47
53
|
messages: [
|
|
@@ -49,8 +55,10 @@ export async function compactContext(messages, cfg, goal) {
|
|
|
49
55
|
{ role: 'user', content: userPrompt },
|
|
50
56
|
],
|
|
51
57
|
onDone: (text) => { summary = text.trim(); },
|
|
52
|
-
onError: () => { },
|
|
58
|
+
onError: (err) => { compactErr = err.message; },
|
|
53
59
|
});
|
|
60
|
+
if (compactErr)
|
|
61
|
+
console.error(`[compactor] LLM error: ${compactErr}`);
|
|
54
62
|
// Fallback to dumb compaction if LLM fails
|
|
55
63
|
if (!summary)
|
|
56
64
|
return dumbCompact(messages, goal);
|
|
@@ -65,6 +73,8 @@ export async function compactContext(messages, cfg, goal) {
|
|
|
65
73
|
];
|
|
66
74
|
}
|
|
67
75
|
function dumbCompact(messages, goal) {
|
|
76
|
+
if (contextSize(messages) <= COMPACT_CHAR_THRESHOLD)
|
|
77
|
+
return messages;
|
|
68
78
|
const system = messages[0]?.role === 'system' ? messages[0] : null;
|
|
69
79
|
const userGoal = messages.find(m => m.role === 'user' && !m.content.startsWith('['));
|
|
70
80
|
const recent = messages.slice(messages.length - KEEP_RECENT);
|
package/dist/tools/index.js
CHANGED
|
@@ -68,8 +68,10 @@ export const tools = [
|
|
|
68
68
|
execute: async ({ path, old: oldStr, new: newStr }) => {
|
|
69
69
|
const safe = guardPath(path);
|
|
70
70
|
const current = readFile(safe);
|
|
71
|
-
if (
|
|
72
|
-
throw new Error(`file not found
|
|
71
|
+
if (current === null)
|
|
72
|
+
throw new Error(`file not found: ${path}`);
|
|
73
|
+
if (current === '')
|
|
74
|
+
throw new Error(`file empty: ${path}`);
|
|
73
75
|
const old = oldStr;
|
|
74
76
|
const count = current.split(old).length - 1;
|
|
75
77
|
if (count === 0) {
|
|
@@ -79,7 +81,7 @@ export const tools = [
|
|
|
79
81
|
if (count > 1) {
|
|
80
82
|
throw new Error(`ambiguous: ${count} matches found in ${path} — extend <old> block with more surrounding lines to make it unique`);
|
|
81
83
|
}
|
|
82
|
-
const updated = current.replace(old, newStr);
|
|
84
|
+
const updated = current.replace(old, String(newStr));
|
|
83
85
|
writeFile(safe, updated);
|
|
84
86
|
// Compute affected line range for the snippet
|
|
85
87
|
const startLine = current.slice(0, current.indexOf(old)).split('\n').length;
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useState, useRef, useMemo, useEffect
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useRef, useMemo, useEffect } 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
6
|
import { ConfigPicker } from './components/ConfigPicker.js';
|
|
7
7
|
import { Divider } from './components/StatusBar.js';
|
|
8
|
-
import { tools
|
|
8
|
+
import { tools } from '../tools/index.js';
|
|
9
9
|
import { toolArgSummary } from './printer.js';
|
|
10
10
|
import { MacroQueue } from '../tasks/queue.js';
|
|
11
11
|
import { TaskExecutor } from '../tasks/executor.js';
|
|
@@ -16,11 +16,13 @@ import { useRunLoop } from './hooks/useRunLoop.js';
|
|
|
16
16
|
import { useRefactor } from './hooks/useRefactor.js';
|
|
17
17
|
import { useGit } from './hooks/useGit.js';
|
|
18
18
|
import { useSubmit } from './hooks/useSubmit.js';
|
|
19
|
+
import { useWatch } from './hooks/useWatch.js';
|
|
19
20
|
import { runDeepThink } from './deepThink.js';
|
|
20
21
|
import { setInkInstance } from './printer.js';
|
|
21
22
|
import { createSearchCodebaseTool } from '../index/tool.js';
|
|
22
23
|
import { saveConfig } from '../config.js';
|
|
23
24
|
import { getTavilyKey, saveTavilyKey } from '../tavily/client.js';
|
|
25
|
+
import { warmup } from '../llm/stream.js';
|
|
24
26
|
function formatElapsed(ms) {
|
|
25
27
|
const s = Math.floor(ms / 1000);
|
|
26
28
|
if (s < 60)
|
|
@@ -29,11 +31,71 @@ function formatElapsed(ms) {
|
|
|
29
31
|
const rem = s % 60;
|
|
30
32
|
return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
|
|
31
33
|
}
|
|
34
|
+
const MAX_DIFF_LINES = 40;
|
|
35
|
+
const DIFF_CTX = 2;
|
|
36
|
+
function lineDiff(oldText, newText) {
|
|
37
|
+
const a = oldText.split('\n');
|
|
38
|
+
const b = newText.split('\n');
|
|
39
|
+
const m = a.length, n = b.length;
|
|
40
|
+
if (m * n > 10000) {
|
|
41
|
+
return [...a.map(line => ({ type: 'del', line })), ...b.map(line => ({ type: 'add', line }))];
|
|
42
|
+
}
|
|
43
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
44
|
+
for (let i = m - 1; i >= 0; i--)
|
|
45
|
+
for (let j = n - 1; j >= 0; j--)
|
|
46
|
+
dp[i][j] = a[i] === b[j] ? 1 + dp[i + 1][j + 1] : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
47
|
+
const result = [];
|
|
48
|
+
let i = 0, j = 0;
|
|
49
|
+
while (i < m || j < n) {
|
|
50
|
+
if (i < m && j < n && a[i] === b[j]) {
|
|
51
|
+
result.push({ type: 'eq', line: a[i++] });
|
|
52
|
+
j++;
|
|
53
|
+
}
|
|
54
|
+
else if (j < n && (i >= m || dp[i + 1][j] <= dp[i][j + 1])) {
|
|
55
|
+
result.push({ type: 'add', line: b[j++] });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
result.push({ type: 'del', line: a[i++] });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
function diffHunks(diff) {
|
|
64
|
+
const changedIdxs = diff.reduce((acc, d, i) => { if (d.type !== 'eq')
|
|
65
|
+
acc.push(i); return acc; }, []);
|
|
66
|
+
if (!changedIdxs.length)
|
|
67
|
+
return [];
|
|
68
|
+
const inHunk = new Set();
|
|
69
|
+
for (const ci of changedIdxs)
|
|
70
|
+
for (let k = Math.max(0, ci - DIFF_CTX); k <= Math.min(diff.length - 1, ci + DIFF_CTX); k++)
|
|
71
|
+
inHunk.add(k);
|
|
72
|
+
return diff.filter((_, i) => inHunk.has(i));
|
|
73
|
+
}
|
|
74
|
+
function DiffPreview({ toolName, args }) {
|
|
75
|
+
if (toolName === 'patch_file' && (args.old != null || args.new != null)) {
|
|
76
|
+
const path = String(args.path ?? '');
|
|
77
|
+
const diff = diffHunks(lineDiff(String(args.old ?? ''), String(args.new ?? '')));
|
|
78
|
+
const visible = diff.slice(0, MAX_DIFF_LINES);
|
|
79
|
+
const hidden = diff.length - visible.length;
|
|
80
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [" ", path] }), visible.map((d, i) => (_jsxs(Text, { color: d.type === 'del' ? 'red' : d.type === 'add' ? 'green' : 'gray', dimColor: d.type === 'eq', children: [d.type === 'del' ? '- ' : d.type === 'add' ? '+ ' : ' ', d.line.slice(0, 76)] }, i))), hidden > 0 && _jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", hidden, " more line", hidden === 1 ? '' : 's'] })] }));
|
|
81
|
+
}
|
|
82
|
+
if ((toolName === 'edit_file' || toolName === 'create_file') && args.content) {
|
|
83
|
+
const path = String(args.path ?? '');
|
|
84
|
+
const lines = String(args.content).split('\n');
|
|
85
|
+
const visible = lines.slice(0, MAX_DIFF_LINES);
|
|
86
|
+
const hidden = lines.length - visible.length;
|
|
87
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [" ", path] }), visible.map((line, i) => (_jsxs(Text, { color: "green", children: ["+ ", line.slice(0, 76)] }, i))), hidden > 0 && _jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", hidden, " more line", hidden === 1 ? '' : 's'] })] }));
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
32
91
|
export function InputBar({ config: initialConfig, skills, cwd, session, version, mcpTools = [] }) {
|
|
33
92
|
const [config, setConfig] = useState(initialConfig);
|
|
34
93
|
const { stdout, write: stdoutWrite } = useStdout();
|
|
35
94
|
const cols = stdout.columns ?? 80;
|
|
36
|
-
useEffect(() => {
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
setInkInstance(stdoutWrite);
|
|
97
|
+
warmup(initialConfig.provider, initialConfig.baseUrl, initialConfig.model);
|
|
98
|
+
}, []);
|
|
37
99
|
const phraseSeq = useMemo(() => Array.from({ length: 100 }, () => Math.floor(Math.random() * THINKING_PHRASES.length)), []);
|
|
38
100
|
const [planningMode, setPlanningMode] = useState(false);
|
|
39
101
|
const [configOpen, setConfigOpen] = useState(false);
|
|
@@ -42,8 +104,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
42
104
|
const executorRef = useRef(new TaskExecutor(tools));
|
|
43
105
|
const lastGitStatusRef = useRef('');
|
|
44
106
|
const abortRef = useRef(null);
|
|
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]);
|
|
107
|
+
const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools);
|
|
47
108
|
const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
|
|
48
109
|
const deepThinkTool = useMemo(() => ({
|
|
49
110
|
name: 'deep_think',
|
|
@@ -63,14 +124,16 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
63
124
|
setStatus, setTaskLabel, setCurrentTool, pushHistory,
|
|
64
125
|
});
|
|
65
126
|
const { handleGit } = useGit({ pushHistory, buildContext, runLoop });
|
|
127
|
+
const { watchActive, startWatch, stopWatch } = useWatch(cwd, { runLoop, buildContext, pushHistory });
|
|
66
128
|
const { handleSubmit } = useSubmit({
|
|
67
|
-
config, skills, cwd, version, currentModelRef, setCurrentModel,
|
|
129
|
+
config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel,
|
|
68
130
|
historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef,
|
|
69
131
|
setPlanningMode, runLoop, buildContext, pushHistory,
|
|
70
132
|
setSessionName, renameFromMessage,
|
|
71
133
|
setStatus, setTaskLabel, setCurrentTool,
|
|
72
134
|
runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig,
|
|
73
|
-
setConfigOpen,
|
|
135
|
+
setConfigOpen, updateMemory,
|
|
136
|
+
startWatch, stopWatch, watchActive,
|
|
74
137
|
});
|
|
75
138
|
const skillList = skills.list();
|
|
76
139
|
return (_jsxs(Box, { flexDirection: "column", children: [configOpen ? (_jsxs(_Fragment, { children: [_jsx(ConfigPicker, { config: config, currentModel: currentModel, tavilyKey: tavilyKey, onUpdate: ({ model, ...configPatch }) => {
|
|
@@ -80,7 +143,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
80
143
|
setConfig(c => ({ ...c, ...configPatch }));
|
|
81
144
|
saveConfig(configPatch);
|
|
82
145
|
}
|
|
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, "
|
|
146
|
+
}, 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, "k chars)"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "compact to keep responses fast, or keep full history" })] })) : permissionRequest ? (_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: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] }), _jsx(DiffPreview, { toolName: permissionRequest.toolName, args: permissionRequest.args })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { children: status === 'thinking'
|
|
84
147
|
? _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]] })] })
|
|
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) })] }));
|
|
148
|
+
: _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), watchActive: watchActive })] }));
|
|
86
149
|
}
|
|
@@ -23,6 +23,7 @@ const BUILTIN_COMMANDS = [
|
|
|
23
23
|
{ ns: 'builtin', name: 'plan', description: 'enter planning mode — AI helps think through a goal step-by-step' },
|
|
24
24
|
{ ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor — plans, reads, then edits — /refactor <goal>' },
|
|
25
25
|
{ ns: 'builtin', name: 'think', description: 'deep research before answering — reads files + optional web — /think <query>' },
|
|
26
|
+
{ ns: 'builtin', name: 'watch', description: 'watch for file changes, run tests, auto-fix failures — /watch stop to cancel' },
|
|
26
27
|
// ── Git ───────────────────────────────────────────────────────────────────
|
|
27
28
|
{ ns: 'git', name: 'status', description: 'show git working tree status (modified, staged, untracked)' },
|
|
28
29
|
{ ns: 'git', name: 'diff', description: 'show unstaged changes as a diff' },
|
|
@@ -55,7 +56,7 @@ function wordEndAfter(line, col) {
|
|
|
55
56
|
i++;
|
|
56
57
|
return i;
|
|
57
58
|
}
|
|
58
|
-
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [] }) {
|
|
59
|
+
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [], watchActive = false }) {
|
|
59
60
|
const [lines, setLines] = useState(['']);
|
|
60
61
|
const [cursor, setCursor] = useState({ row: 0, col: 0 });
|
|
61
62
|
const [overlay, setOverlay] = useState('none');
|
|
@@ -468,11 +469,15 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
468
469
|
? `history ${historyIdx + 1}/${history.length} ↑↓ navigate esc clear`
|
|
469
470
|
: planningMode
|
|
470
471
|
? 'planning mode /plan:done exit'
|
|
471
|
-
:
|
|
472
|
+
: watchActive
|
|
473
|
+
? 'watch active /watch stop to cancel'
|
|
474
|
+
: '? for shortcuts';
|
|
472
475
|
const pastePreview = pasteRef.current
|
|
473
476
|
? pasteRef.current.split('\n')[0].slice(0, cols - 6)
|
|
474
477
|
: '';
|
|
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] ? (
|
|
478
|
+
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] ? (watchActive && isActive
|
|
479
|
+
? _jsxs(Text, { children: [_jsx(Text, { color: "cyan", dimColor: true, children: "watching\u2026 " }), _jsx(Text, { children: "\u2588" })] })
|
|
480
|
+
: _jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
|
|
476
481
|
? viewportLine(line, cursor.col, availWidth, isActive)
|
|
477
482
|
: 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] })] }));
|
|
478
483
|
}
|
package/dist/tui/git-context.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { readFile } from '../files/ops.js';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
1
|
import { exec } from 'child_process';
|
|
4
2
|
import { promisify } from 'util';
|
|
5
3
|
const gitRun = promisify(exec);
|
|
@@ -14,44 +12,11 @@ export async function buildGitContext(cwd, lastStatusRef) {
|
|
|
14
12
|
if (!status || status === lastStatusRef.current)
|
|
15
13
|
return { prefix: '', label: '' };
|
|
16
14
|
lastStatusRef.current = status;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
for (const line of status.split('\n')) {
|
|
23
|
-
const code = line.slice(0, 2);
|
|
24
|
-
if (code.includes('D'))
|
|
25
|
-
continue;
|
|
26
|
-
const raw = line.slice(3).trim().replace(/^"|"$/g, '');
|
|
27
|
-
const arrowIdx = raw.lastIndexOf(' -> ');
|
|
28
|
-
const rel = arrowIdx !== -1 ? raw.slice(arrowIdx + 4) : raw;
|
|
29
|
-
if (!rel)
|
|
30
|
-
continue;
|
|
31
|
-
try {
|
|
32
|
-
const content = readFile(resolve(cwd, rel));
|
|
33
|
-
if (!content || content.length > MAX_FILE) {
|
|
34
|
-
skipped.push(rel);
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
total += content.length;
|
|
38
|
-
if (total > MAX_TOTAL) {
|
|
39
|
-
skipped.push(rel);
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
parts.push(`<file path="${rel}">\n${content}\n</file>`);
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
skipped.push(rel);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (!parts.length && !skipped.length)
|
|
49
|
-
return { prefix: '', label: '' };
|
|
50
|
-
let prefix = '[Auto-context: git-changed files]\n' + parts.join('\n') + '\n';
|
|
51
|
-
if (skipped.length)
|
|
52
|
-
prefix += `Files changed but too large to auto-load: ${skipped.join(', ')}\n`;
|
|
53
|
-
prefix += '\n';
|
|
54
|
-
const label = `auto-loaded ${parts.length} changed file(s)${skipped.length ? `, skipped ${skipped.length} (too large)` : ''}`;
|
|
15
|
+
const files = status.split('\n')
|
|
16
|
+
.map(l => l.slice(3).trim().replace(/^"|"$/g, ''))
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
const prefix = `[Git: ${files.length} changed file(s)]\n${status}\n\n`;
|
|
19
|
+
const label = `git: ${files.length} changed file(s) in context`;
|
|
55
20
|
return { prefix, label };
|
|
56
21
|
}
|
|
57
22
|
catch {
|
|
@@ -3,13 +3,18 @@ import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
|
3
3
|
import { chat } from '../../llm/stream.js';
|
|
4
4
|
import { tools as staticTools } from '../../tools/index.js';
|
|
5
5
|
import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
|
|
6
|
-
import { shouldCompact, compactContext } from '../../tasks/compactor.js';
|
|
6
|
+
import { shouldCompact, compactContext, contextSize } from '../../tasks/compactor.js';
|
|
7
7
|
import * as printer from '../printer.js';
|
|
8
|
-
const MAX_TOOL_DEPTH =
|
|
8
|
+
const MAX_TOOL_DEPTH = 10;
|
|
9
9
|
const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'patch_file', 'delete_file']);
|
|
10
10
|
const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
|
|
11
11
|
const PERMISSION_TOOLS = new Set(['edit_file', 'patch_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
|
|
12
12
|
const CHECKPOINT_TOOLS = new Set(['edit_file', 'patch_file', 'create_file', 'delete_file']);
|
|
13
|
+
// Tool result messages that are ephemeral — never worth storing in memory or compact summaries
|
|
14
|
+
const EPHEMERAL_PATTERN = /^Tool (read_file|list_files|run_tests) result:|^\[current state of|^\[Context compacted/;
|
|
15
|
+
export function stripEphemeral(messages) {
|
|
16
|
+
return messages.filter(m => m.role !== 'user' || !EPHEMERAL_PATTERN.test(m.content));
|
|
17
|
+
}
|
|
13
18
|
export function useRunLoop(config, currentModelRef, pushHistory, extraTools = [], abortRef) {
|
|
14
19
|
const [status, setStatus] = useState('idle');
|
|
15
20
|
const [tick, setTick] = useState(0);
|
|
@@ -57,11 +62,12 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
57
62
|
if (shouldCompact(contextMsgs)) {
|
|
58
63
|
const approved = await new Promise(resolve => {
|
|
59
64
|
compactResolveRef.current = resolve;
|
|
60
|
-
setCompactRequest({ messageCount: contextMsgs
|
|
65
|
+
setCompactRequest({ messageCount: Math.round(contextSize(contextMsgs) / 1000) });
|
|
61
66
|
});
|
|
62
67
|
if (approved) {
|
|
63
68
|
printer.systemMsg('compacting context…');
|
|
64
|
-
|
|
69
|
+
const toCompact = stripEphemeral(contextMsgs);
|
|
70
|
+
msgs = await compactContext(toCompact, {
|
|
65
71
|
provider: config.provider,
|
|
66
72
|
model: currentModelRef.current,
|
|
67
73
|
baseUrl: config.baseUrl,
|
|
@@ -80,6 +86,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
80
86
|
baseUrl: config.baseUrl,
|
|
81
87
|
messages: msgs,
|
|
82
88
|
signal: abortRef.current.signal,
|
|
89
|
+
onRetry(attempt, max, delayMs) {
|
|
90
|
+
printer.systemMsg(`retry ${attempt}/${max} — waiting ${Math.round(delayMs / 1000)}s`);
|
|
91
|
+
},
|
|
83
92
|
async onDone(fullText) {
|
|
84
93
|
const pendingTools = [];
|
|
85
94
|
const textParts = [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useEffect } from 'react';
|
|
2
|
-
import { loadSession, saveSession, deleteSession } from '../../sessions.js';
|
|
2
|
+
import { getProjectDir, loadSession, saveSession, deleteSession } from '../../sessions.js';
|
|
3
3
|
import { getSystemPrompt } from '../../tools/index.js';
|
|
4
4
|
import { getTavilyKey, saveTavilyKey } from '../../tavily/client.js';
|
|
5
5
|
import * as printer from '../printer.js';
|
|
@@ -10,6 +10,7 @@ function buildSystemPrompt(cwd, facts, extraTools = []) {
|
|
|
10
10
|
return getSystemPrompt(`\n- CWD: ${cwd}`, extraTools) + formatMemoryBlock(facts);
|
|
11
11
|
}
|
|
12
12
|
export function useSession(initialSession, cwd, config, extraTools = []) {
|
|
13
|
+
const projectDir = getProjectDir(cwd);
|
|
13
14
|
const [sessionName, setSessionName] = useState(initialSession);
|
|
14
15
|
const sessionNameRef = useRef(initialSession);
|
|
15
16
|
const historyRef = useRef([]);
|
|
@@ -17,43 +18,45 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
|
|
|
17
18
|
const firstMessageSentRef = useRef(false);
|
|
18
19
|
const longMemoryRef = useRef([]);
|
|
19
20
|
const systemPromptRef = useRef(buildSystemPrompt(cwd, [], extraTools));
|
|
21
|
+
const extractingRef = useRef(false);
|
|
20
22
|
useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
|
|
21
23
|
useEffect(() => {
|
|
22
|
-
const facts = loadLongMemory(
|
|
24
|
+
const facts = loadLongMemory(projectDir);
|
|
23
25
|
longMemoryRef.current = facts;
|
|
24
26
|
systemPromptRef.current = buildSystemPrompt(cwd, facts, extraTools);
|
|
25
27
|
if (facts.length)
|
|
26
|
-
printer.systemMsg(`
|
|
27
|
-
const history = loadSession(initialSession);
|
|
28
|
+
printer.systemMsg(`project memory: ${facts.length} facts`);
|
|
29
|
+
const history = loadSession(projectDir, initialSession);
|
|
28
30
|
historyRef.current = history;
|
|
29
31
|
if (history.length)
|
|
30
32
|
printer.systemMsg(`resumed "${initialSession}" — ${history.length} messages`);
|
|
31
33
|
if (config.tavilyApiKey && !getTavilyKey())
|
|
32
34
|
saveTavilyKey(config.tavilyApiKey);
|
|
33
35
|
if (!getTavilyKey()) {
|
|
34
|
-
printer.systemMsg('Tavily API key not set — web search disabled. Run /
|
|
36
|
+
printer.systemMsg('Tavily API key not set — web search disabled. Run /config to enable.');
|
|
35
37
|
}
|
|
36
38
|
}, []);
|
|
37
39
|
function scheduleSave() {
|
|
38
40
|
if (saveTimerRef.current)
|
|
39
41
|
clearTimeout(saveTimerRef.current);
|
|
40
42
|
saveTimerRef.current = setTimeout(() => {
|
|
41
|
-
saveSession(sessionNameRef.current, historyRef.current);
|
|
43
|
+
saveSession(projectDir, sessionNameRef.current, historyRef.current);
|
|
42
44
|
saveTimerRef.current = null;
|
|
43
45
|
}, 2000);
|
|
44
46
|
}
|
|
45
47
|
function pushHistory(msg) {
|
|
46
48
|
historyRef.current.push(msg);
|
|
47
|
-
if (historyRef.current.length > SHORT_MEMORY_SIZE) {
|
|
49
|
+
if (historyRef.current.length > SHORT_MEMORY_SIZE && !extractingRef.current) {
|
|
48
50
|
const dropped = historyRef.current.splice(0, historyRef.current.length - SHORT_MEMORY_SIZE);
|
|
51
|
+
extractingRef.current = true;
|
|
49
52
|
extractFacts(dropped, config, config.model).then(newFacts => {
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
});
|
|
53
|
+
if (newFacts.length) {
|
|
54
|
+
const updated = mergeFacts(longMemoryRef.current, newFacts);
|
|
55
|
+
longMemoryRef.current = updated;
|
|
56
|
+
systemPromptRef.current = buildSystemPrompt(cwd, updated, extraTools);
|
|
57
|
+
saveLongMemory(projectDir, updated);
|
|
58
|
+
}
|
|
59
|
+
}).finally(() => { extractingRef.current = false; });
|
|
57
60
|
}
|
|
58
61
|
scheduleSave();
|
|
59
62
|
}
|
|
@@ -74,7 +77,7 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
|
|
|
74
77
|
sessionNameRef.current = slug;
|
|
75
78
|
setSessionName(slug);
|
|
76
79
|
try {
|
|
77
|
-
deleteSession(oldName);
|
|
80
|
+
deleteSession(projectDir, oldName);
|
|
78
81
|
}
|
|
79
82
|
catch { }
|
|
80
83
|
}
|
|
@@ -85,9 +88,19 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
|
|
|
85
88
|
ctx.push(extra);
|
|
86
89
|
return ctx;
|
|
87
90
|
}
|
|
91
|
+
function updateMemory(newFacts) {
|
|
92
|
+
if (!newFacts.length)
|
|
93
|
+
return;
|
|
94
|
+
const updated = mergeFacts(longMemoryRef.current, newFacts);
|
|
95
|
+
longMemoryRef.current = updated;
|
|
96
|
+
systemPromptRef.current = buildSystemPrompt(cwd, updated, extraTools);
|
|
97
|
+
saveLongMemory(projectDir, updated);
|
|
98
|
+
}
|
|
88
99
|
return {
|
|
100
|
+
projectDir,
|
|
89
101
|
sessionName, setSessionName, sessionNameRef,
|
|
90
102
|
historyRef, saveTimerRef, systemPromptRef,
|
|
91
|
-
|
|
103
|
+
longMemoryRef,
|
|
104
|
+
pushHistory, buildContext, renameFromMessage, updateMemory,
|
|
92
105
|
};
|
|
93
106
|
}
|
|
@@ -4,6 +4,8 @@ import { getSystemPrompt } from '../../tools/index.js';
|
|
|
4
4
|
import { saveConfig } from '../../config.js';
|
|
5
5
|
import { loadSession, saveSession, listSessions, deleteSession, deleteAllSessions } from '../../sessions.js';
|
|
6
6
|
import { compactContext } from '../../tasks/compactor.js';
|
|
7
|
+
import { extractFacts } from '../../memory/extractor.js';
|
|
8
|
+
import { stripEphemeral } from './useRunLoop.js';
|
|
7
9
|
import { runDeepThink } from '../deepThink.js';
|
|
8
10
|
import { buildGitContext, looksCodeRelated } from '../git-context.js';
|
|
9
11
|
import { buildIndex } from '../../index/indexer.js';
|
|
@@ -35,7 +37,7 @@ export function useSubmit(deps) {
|
|
|
35
37
|
const depsRef = useRef(deps);
|
|
36
38
|
depsRef.current = deps;
|
|
37
39
|
const handleSubmit = useCallback(async (text) => {
|
|
38
|
-
const { config, skills, cwd, version, currentModelRef, setCurrentModel, historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef, setPlanningMode, runLoop, buildContext, pushHistory, setSessionName, renameFromMessage, setStatus, setTaskLabel, setCurrentTool, runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig, setConfigOpen, } = depsRef.current;
|
|
40
|
+
const { config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel, historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef, setPlanningMode, runLoop, buildContext, pushHistory, setSessionName, renameFromMessage, setStatus, setTaskLabel, setCurrentTool, runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig, setConfigOpen, updateMemory, startWatch, stopWatch, } = depsRef.current;
|
|
39
41
|
const cmd = text.trim();
|
|
40
42
|
if (cmd === '?') {
|
|
41
43
|
printer.systemMsg('shortcuts:\n' +
|
|
@@ -164,23 +166,37 @@ export function useSubmit(deps) {
|
|
|
164
166
|
return;
|
|
165
167
|
}
|
|
166
168
|
if (cmd === '/compact') {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
// Strip ephemeral tool noise (read_file, list_files, run_tests results, injected state)
|
|
170
|
+
const meaningful = stripEphemeral(historyRef.current);
|
|
171
|
+
if (!meaningful.length) {
|
|
169
172
|
printer.systemMsg('nothing to compact');
|
|
170
173
|
return;
|
|
171
174
|
}
|
|
172
|
-
|
|
175
|
+
const before = historyRef.current.length;
|
|
176
|
+
printer.systemMsg(`compacting ${before} messages (${before - meaningful.length} ephemeral dropped)…`);
|
|
173
177
|
setStatus('thinking');
|
|
174
178
|
try {
|
|
175
|
-
const
|
|
179
|
+
const cfg = {
|
|
176
180
|
provider: config.provider,
|
|
177
181
|
model: currentModelRef.current,
|
|
178
182
|
baseUrl: config.baseUrl,
|
|
179
183
|
apiKey: config.apiKey,
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
+
};
|
|
185
|
+
// Run both in parallel: LLM summary + fact extraction
|
|
186
|
+
const [compacted, facts] = await Promise.all([
|
|
187
|
+
compactContext([{ role: 'system', content: '' }, ...meaningful], cfg),
|
|
188
|
+
extractFacts(meaningful, config, currentModelRef.current),
|
|
189
|
+
]);
|
|
190
|
+
// Update long-term memory with extracted facts
|
|
191
|
+
if (facts.length) {
|
|
192
|
+
updateMemory(facts);
|
|
193
|
+
printer.systemMsg(`memory: +${facts.length} fact${facts.length === 1 ? '' : 's'} saved`);
|
|
194
|
+
}
|
|
195
|
+
// Replace history with just the compact summary (no system msg)
|
|
196
|
+
const summaryOnly = compacted.filter(m => m.role !== 'system');
|
|
197
|
+
historyRef.current = summaryOnly;
|
|
198
|
+
saveSession(projectDir, sessionNameRef.current, summaryOnly);
|
|
199
|
+
printer.systemMsg(`compacted: ${before} → ${summaryOnly.length} message${summaryOnly.length === 1 ? '' : 's'}`);
|
|
184
200
|
}
|
|
185
201
|
catch (e) {
|
|
186
202
|
printer.errorMsg(`compact failed: ${e}`);
|
|
@@ -195,7 +211,7 @@ export function useSubmit(deps) {
|
|
|
195
211
|
clearTimeout(saveTimerRef.current);
|
|
196
212
|
saveTimerRef.current = null;
|
|
197
213
|
}
|
|
198
|
-
saveSession(sessionNameRef.current, historyRef.current);
|
|
214
|
+
saveSession(projectDir, sessionNameRef.current, historyRef.current);
|
|
199
215
|
const newName = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
200
216
|
historyRef.current = [];
|
|
201
217
|
setSessionName(newName);
|
|
@@ -206,7 +222,7 @@ export function useSubmit(deps) {
|
|
|
206
222
|
}
|
|
207
223
|
if (cmd === '/clear') {
|
|
208
224
|
historyRef.current = [];
|
|
209
|
-
saveSession(sessionNameRef.current, []);
|
|
225
|
+
saveSession(projectDir, sessionNameRef.current, []);
|
|
210
226
|
setPlanningMode(false);
|
|
211
227
|
systemPromptRef.current = getSystemPrompt(`\n- CWD: ${cwd}`, mcpTools);
|
|
212
228
|
printer.systemMsg('chat cleared');
|
|
@@ -295,12 +311,22 @@ export function useSubmit(deps) {
|
|
|
295
311
|
}
|
|
296
312
|
}
|
|
297
313
|
if (cmd === '/sessions') {
|
|
298
|
-
const sessions = listSessions();
|
|
314
|
+
const sessions = listSessions(projectDir);
|
|
315
|
+
const shortCwd = cwd.replace(process.env.HOME ?? '', '~');
|
|
299
316
|
if (!sessions.length) {
|
|
300
|
-
printer.systemMsg(
|
|
317
|
+
printer.systemMsg(`project: ${shortCwd}\nno saved sessions\n\n` +
|
|
318
|
+
` /new start fresh session\n` +
|
|
319
|
+
` /session <name> switch to session\n` +
|
|
320
|
+
` /session delete <name> delete session\n` +
|
|
321
|
+
` /session delete all delete all sessions in this project`);
|
|
301
322
|
return;
|
|
302
323
|
}
|
|
303
|
-
|
|
324
|
+
const rows = sessions.map(s => ` ${s.name === sessionNameRef.current ? '▶' : ' '} ${s.name.padEnd(32)} ${s.messageCount} msg${s.messageCount === 1 ? '' : 's'}`).join('\n');
|
|
325
|
+
printer.systemMsg(`project: ${shortCwd} (${sessions.length} session${sessions.length === 1 ? '' : 's'})\n${rows}\n\n` +
|
|
326
|
+
` /session <name> switch session\n` +
|
|
327
|
+
` /session delete <name> delete session\n` +
|
|
328
|
+
` /session delete all delete all sessions in this project\n` +
|
|
329
|
+
` /new start fresh session`);
|
|
304
330
|
return;
|
|
305
331
|
}
|
|
306
332
|
if (cmd.startsWith('/session')) {
|
|
@@ -316,8 +342,8 @@ export function useSubmit(deps) {
|
|
|
316
342
|
return;
|
|
317
343
|
}
|
|
318
344
|
if (target === 'all') {
|
|
319
|
-
const count = deleteAllSessions(sessionNameRef.current);
|
|
320
|
-
printer.systemMsg(`deleted ${count} session
|
|
345
|
+
const count = deleteAllSessions(projectDir, sessionNameRef.current);
|
|
346
|
+
printer.systemMsg(`deleted ${count} session${count === 1 ? '' : 's'} — kept active: ${sessionNameRef.current}`);
|
|
321
347
|
return;
|
|
322
348
|
}
|
|
323
349
|
if (target === sessionNameRef.current) {
|
|
@@ -325,7 +351,7 @@ export function useSubmit(deps) {
|
|
|
325
351
|
return;
|
|
326
352
|
}
|
|
327
353
|
try {
|
|
328
|
-
deleteSession(target);
|
|
354
|
+
deleteSession(projectDir, target);
|
|
329
355
|
printer.systemMsg(`deleted: ${target}`);
|
|
330
356
|
}
|
|
331
357
|
catch (e) {
|
|
@@ -337,10 +363,10 @@ export function useSubmit(deps) {
|
|
|
337
363
|
clearTimeout(saveTimerRef.current);
|
|
338
364
|
saveTimerRef.current = null;
|
|
339
365
|
}
|
|
340
|
-
saveSession(sessionNameRef.current, historyRef.current);
|
|
341
|
-
historyRef.current = loadSession(arg);
|
|
366
|
+
saveSession(projectDir, sessionNameRef.current, historyRef.current);
|
|
367
|
+
historyRef.current = loadSession(projectDir, arg);
|
|
342
368
|
setSessionName(arg);
|
|
343
|
-
printer.systemMsg(`session → ${arg} (${historyRef.current.length}
|
|
369
|
+
printer.systemMsg(`session → ${arg} (${historyRef.current.length} message${historyRef.current.length === 1 ? '' : 's'})`);
|
|
344
370
|
return;
|
|
345
371
|
}
|
|
346
372
|
if (cmd === '/index' || cmd.startsWith('/index ')) {
|
|
@@ -405,6 +431,15 @@ export function useSubmit(deps) {
|
|
|
405
431
|
printer.systemMsg('usage: /index build | /index status | /index search <query> | /index clear');
|
|
406
432
|
return;
|
|
407
433
|
}
|
|
434
|
+
if (cmd === '/watch' || cmd.startsWith('/watch ')) {
|
|
435
|
+
const sub = cmd.slice(6).trim();
|
|
436
|
+
if (sub === 'stop') {
|
|
437
|
+
stopWatch();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
startWatch();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
408
443
|
if (text.startsWith('/')) {
|
|
409
444
|
const [slashCmd, ...rest] = text.slice(1).split(' ');
|
|
410
445
|
const skill = skills.get(slashCmd);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { watch } from 'fs';
|
|
3
|
+
import { tools as staticTools } from '../../tools/index.js';
|
|
4
|
+
import * as printer from '../printer.js';
|
|
5
|
+
const WATCH_DEBOUNCE_MS = 600;
|
|
6
|
+
const IGNORE_DIRS = new Set([
|
|
7
|
+
'node_modules', '.git', 'dist', '.next', 'build', 'coverage',
|
|
8
|
+
'__pycache__', '.turbo', '.cache', '.parcel-cache', 'out',
|
|
9
|
+
]);
|
|
10
|
+
const WATCH_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|c|cpp|h|hpp|css|scss)$/;
|
|
11
|
+
function testsFailed(output) {
|
|
12
|
+
// Explicit pass with no failures → passing
|
|
13
|
+
if (/\b0 fail/i.test(output))
|
|
14
|
+
return false;
|
|
15
|
+
if (/\d+ pass/i.test(output) && !/\d+ fail/i.test(output))
|
|
16
|
+
return false;
|
|
17
|
+
return /\d+ fail|FAIL\b|✕|✗|\bfailing\b|AssertionError/i.test(output);
|
|
18
|
+
}
|
|
19
|
+
export function useWatch(cwd, deps) {
|
|
20
|
+
const [watchActive, setWatchActive] = useState(false);
|
|
21
|
+
const watcherRef = useRef(null);
|
|
22
|
+
const debounceRef = useRef(null);
|
|
23
|
+
const changedRef = useRef(new Set());
|
|
24
|
+
const fixRunningRef = useRef(false);
|
|
25
|
+
// Always-fresh deps via ref — watcher callback is set up once
|
|
26
|
+
const depsRef = useRef(deps);
|
|
27
|
+
useEffect(() => { depsRef.current = deps; });
|
|
28
|
+
const stopWatch = useCallback(() => {
|
|
29
|
+
if (debounceRef.current) {
|
|
30
|
+
clearTimeout(debounceRef.current);
|
|
31
|
+
debounceRef.current = null;
|
|
32
|
+
}
|
|
33
|
+
watcherRef.current?.close();
|
|
34
|
+
watcherRef.current = null;
|
|
35
|
+
changedRef.current.clear();
|
|
36
|
+
fixRunningRef.current = false;
|
|
37
|
+
setWatchActive(false);
|
|
38
|
+
printer.systemMsg('watch: stopped');
|
|
39
|
+
}, []);
|
|
40
|
+
const startWatch = useCallback(() => {
|
|
41
|
+
if (watcherRef.current) {
|
|
42
|
+
printer.systemMsg('watch: already active — /watch stop to cancel');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let watcher;
|
|
46
|
+
try {
|
|
47
|
+
watcher = watch(cwd, { recursive: true }, (_event, filename) => {
|
|
48
|
+
if (!filename)
|
|
49
|
+
return;
|
|
50
|
+
const parts = filename.split('/');
|
|
51
|
+
if (parts.some(p => IGNORE_DIRS.has(p) || p.startsWith('.')))
|
|
52
|
+
return;
|
|
53
|
+
if (!WATCH_EXT.test(filename))
|
|
54
|
+
return;
|
|
55
|
+
changedRef.current.add(filename);
|
|
56
|
+
if (debounceRef.current)
|
|
57
|
+
clearTimeout(debounceRef.current);
|
|
58
|
+
debounceRef.current = setTimeout(async () => {
|
|
59
|
+
debounceRef.current = null;
|
|
60
|
+
const changed = [...changedRef.current];
|
|
61
|
+
changedRef.current.clear();
|
|
62
|
+
const testTool = staticTools.find(t => t.name === 'run_tests');
|
|
63
|
+
if (!testTool)
|
|
64
|
+
return;
|
|
65
|
+
const label = changed.length > 3
|
|
66
|
+
? `${changed.slice(0, 3).join(', ')} +${changed.length - 3} more`
|
|
67
|
+
: changed.join(', ');
|
|
68
|
+
printer.systemMsg(`watch: ${label} — running tests`);
|
|
69
|
+
if (fixRunningRef.current) {
|
|
70
|
+
printer.systemMsg('watch: fix in progress — skipping this cycle');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
fixRunningRef.current = true;
|
|
74
|
+
try {
|
|
75
|
+
const result = await testTool.execute({});
|
|
76
|
+
if (!result || result.startsWith('(no '))
|
|
77
|
+
return;
|
|
78
|
+
if (testsFailed(result)) {
|
|
79
|
+
printer.systemMsg('watch: tests failing — triggering fix');
|
|
80
|
+
const { pushHistory, buildContext, runLoop } = depsRef.current;
|
|
81
|
+
const fixMsg = `Tests are failing after changes to: ${changed.join(', ')}\n\n` +
|
|
82
|
+
`Test output:\n${result}\n\n` +
|
|
83
|
+
`Read the failing files and fix the issues.`;
|
|
84
|
+
pushHistory({ role: 'user', content: fixMsg });
|
|
85
|
+
await runLoop(buildContext(), 0, 'fix failing tests');
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
printer.systemMsg('watch: tests passing');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
printer.errorMsg(`watch: ${e}`);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
fixRunningRef.current = false;
|
|
96
|
+
}
|
|
97
|
+
}, WATCH_DEBOUNCE_MS);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
printer.errorMsg(`watch: failed to start: ${e}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
watcher.on('error', (err) => {
|
|
105
|
+
printer.errorMsg(`watch: ${err.message}`);
|
|
106
|
+
stopWatch();
|
|
107
|
+
});
|
|
108
|
+
watcherRef.current = watcher;
|
|
109
|
+
setWatchActive(true);
|
|
110
|
+
printer.systemMsg(`watch: active — monitoring ${cwd.replace(process.env.HOME ?? '', '~')}`);
|
|
111
|
+
}, [cwd, stopWatch]);
|
|
112
|
+
// Cleanup on unmount
|
|
113
|
+
useEffect(() => () => {
|
|
114
|
+
watcherRef.current?.close();
|
|
115
|
+
if (debounceRef.current)
|
|
116
|
+
clearTimeout(debounceRef.current);
|
|
117
|
+
}, []);
|
|
118
|
+
return { watchActive, startWatch, stopWatch };
|
|
119
|
+
}
|
package/dist/tui/printer.js
CHANGED
|
@@ -24,9 +24,6 @@ const gray = (s) => col(90, s);
|
|
|
24
24
|
const yellow = (s) => col(93, s);
|
|
25
25
|
const purple = (s) => col(95, s);
|
|
26
26
|
const red = (s) => col(91, s);
|
|
27
|
-
function indent(text, pad = ' ') {
|
|
28
|
-
return text.split('\n').map(l => pad + l).join('\n');
|
|
29
|
-
}
|
|
30
27
|
function stripMarkdown(s) {
|
|
31
28
|
return s
|
|
32
29
|
.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
|
|
@@ -104,7 +101,6 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
|
|
|
104
101
|
const top = gray('╭') + gray('─') + bold(cyan(` MIII - CLI${versionStr} `)) + gray('─'.repeat(dashCount) + '╮');
|
|
105
102
|
const bottom = gray('╰' + '─'.repeat(innerW) + '╯');
|
|
106
103
|
const shortCwd = cwd.replace(process.env.HOME ?? '', '~');
|
|
107
|
-
const username = process.env.USER ?? 'there';
|
|
108
104
|
const miniArt = [
|
|
109
105
|
` ${purple(' ● ● ')}`,
|
|
110
106
|
` ${purple(' ╱ ╲ ╱ ╲ ')}`,
|
|
@@ -124,7 +120,7 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
|
|
|
124
120
|
` ${bold(yellow('Tips for getting started'))}`,
|
|
125
121
|
` Type ${cyan('@filename')} to inject file into context`,
|
|
126
122
|
` Use ${cyan('/skill')} to run a skill or command`,
|
|
127
|
-
` Use ${cyan('/
|
|
123
|
+
` Use ${cyan('/config')} to switch provider, model, or API key`,
|
|
128
124
|
'',
|
|
129
125
|
];
|
|
130
126
|
const maxLen = Math.max(leftLines.length, rightLines.length);
|
|
@@ -266,7 +262,7 @@ export function toolResultSummary(name, args, result) {
|
|
|
266
262
|
if (summary)
|
|
267
263
|
write(gray(` ${summary}`) + '\n');
|
|
268
264
|
}
|
|
269
|
-
export function toolMsg(
|
|
265
|
+
export function toolMsg(_name, result) {
|
|
270
266
|
const preview = result.length > 600 ? result.slice(0, 600) + '…' : result;
|
|
271
267
|
const body = preview.trim()
|
|
272
268
|
? preview.split('\n').map(l => gray(' ' + l)).join('\n')
|