ideacode 1.2.2 → 1.2.3
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/dist/api.js +27 -12
- package/dist/context.js +59 -9
- package/dist/index.js +40 -2
- package/dist/repl.js +177 -51
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -9,12 +9,25 @@ export async function fetchModels(apiKey) {
|
|
|
9
9
|
const json = (await res.json());
|
|
10
10
|
return json.data ?? [];
|
|
11
11
|
}
|
|
12
|
-
const MAX_RETRIES =
|
|
13
|
-
const INITIAL_BACKOFF_MS =
|
|
12
|
+
const MAX_RETRIES = 8;
|
|
13
|
+
const INITIAL_BACKOFF_MS = 1200;
|
|
14
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
15
|
+
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
14
16
|
function sleep(ms) {
|
|
15
17
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
18
|
}
|
|
17
|
-
|
|
19
|
+
function computeRetryDelayMs(attempt, retryAfterHeader) {
|
|
20
|
+
if (retryAfterHeader) {
|
|
21
|
+
const retrySeconds = Number.parseFloat(retryAfterHeader);
|
|
22
|
+
if (Number.isFinite(retrySeconds) && retrySeconds > 0) {
|
|
23
|
+
return Math.max(1000, Math.min(MAX_BACKOFF_MS, Math.round(retrySeconds * 1000)));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const base = Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * Math.pow(2, attempt));
|
|
27
|
+
const jitter = 0.75 + Math.random() * 0.5; // 0.75x .. 1.25x
|
|
28
|
+
return Math.max(1000, Math.round(base * jitter));
|
|
29
|
+
}
|
|
30
|
+
export async function callApi(apiKey, messages, systemPrompt, model, callbacks) {
|
|
18
31
|
const body = {
|
|
19
32
|
model,
|
|
20
33
|
max_tokens: 8192,
|
|
@@ -36,11 +49,15 @@ export async function callApi(apiKey, messages, systemPrompt, model) {
|
|
|
36
49
|
return res.json();
|
|
37
50
|
const text = await res.text();
|
|
38
51
|
lastError = new Error(`API ${res.status}: ${text}`);
|
|
39
|
-
if ((res.status
|
|
52
|
+
if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
|
|
40
53
|
const retryAfter = res.headers.get("retry-after");
|
|
41
|
-
const waitMs = retryAfter
|
|
42
|
-
|
|
43
|
-
:
|
|
54
|
+
const waitMs = computeRetryDelayMs(attempt, retryAfter);
|
|
55
|
+
callbacks?.onRetry?.({
|
|
56
|
+
attempt: attempt + 1,
|
|
57
|
+
maxAttempts: MAX_RETRIES + 1,
|
|
58
|
+
waitMs,
|
|
59
|
+
status: res.status,
|
|
60
|
+
});
|
|
44
61
|
await sleep(waitMs);
|
|
45
62
|
continue;
|
|
46
63
|
}
|
|
@@ -187,7 +204,7 @@ export async function callApiStream(apiKey, messages, systemPrompt, model, callb
|
|
|
187
204
|
await flushRemainingToolCalls();
|
|
188
205
|
return finish();
|
|
189
206
|
}
|
|
190
|
-
const SUMMARIZE_SYSTEM = "You are a summarizer.
|
|
207
|
+
const SUMMARIZE_SYSTEM = "You are a summarizer. Compress the conversation while preserving fidelity. Output plain text with these exact sections: Goal, Constraints, Decisions, Open Questions, Next Actions, Critical Facts. Keep literals (paths, model ids, command names, env vars, URLs, numbers, error messages) whenever available. Include key tool results in concise form. Do not add preamble or commentary.";
|
|
191
208
|
export async function callSummarize(apiKey, messages, model) {
|
|
192
209
|
const body = {
|
|
193
210
|
model,
|
|
@@ -213,11 +230,9 @@ export async function callSummarize(apiKey, messages, model) {
|
|
|
213
230
|
}
|
|
214
231
|
const text = await res.text();
|
|
215
232
|
lastError = new Error(`Summarize API ${res.status}: ${text}`);
|
|
216
|
-
if ((res.status
|
|
233
|
+
if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
|
|
217
234
|
const retryAfter = res.headers.get("retry-after");
|
|
218
|
-
const waitMs = retryAfter
|
|
219
|
-
? Math.max(1000, parseInt(retryAfter, 10) * 1000)
|
|
220
|
-
: INITIAL_BACKOFF_MS * Math.pow(2, attempt);
|
|
235
|
+
const waitMs = computeRetryDelayMs(attempt, retryAfter);
|
|
221
236
|
await sleep(waitMs);
|
|
222
237
|
continue;
|
|
223
238
|
}
|
package/dist/context.js
CHANGED
|
@@ -1,4 +1,52 @@
|
|
|
1
1
|
import { callSummarize } from "./api.js";
|
|
2
|
+
const MAX_PINNED_FACTS = 28;
|
|
3
|
+
const MAX_PINNED_FACT_CHARS = 200;
|
|
4
|
+
function messageContentToText(content) {
|
|
5
|
+
if (typeof content === "string")
|
|
6
|
+
return content;
|
|
7
|
+
if (Array.isArray(content)) {
|
|
8
|
+
const parts = [];
|
|
9
|
+
for (const item of content) {
|
|
10
|
+
if (item && typeof item === "object") {
|
|
11
|
+
const maybe = item;
|
|
12
|
+
if (typeof maybe.content === "string")
|
|
13
|
+
parts.push(maybe.content);
|
|
14
|
+
if (maybe.type === "tool_use" && typeof maybe.name === "string") {
|
|
15
|
+
parts.push(`tool_use ${maybe.name} ${JSON.stringify(maybe.input ?? {})}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return parts.join("\n");
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(content);
|
|
22
|
+
}
|
|
23
|
+
function extractPinnedFacts(messages) {
|
|
24
|
+
const allLines = messages
|
|
25
|
+
.flatMap((m) => messageContentToText(m.content).split("\n"))
|
|
26
|
+
.map((l) => l.trim())
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
const facts = [];
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const importantPattern = /(\/[A-Za-z0-9._/-]{2,}|https?:\/\/\S+|\b[A-Z][A-Z0-9_]{2,}\b|\b(error|failed|must|never|always|todo|fixme|constraint)\b)/i;
|
|
31
|
+
for (const line of allLines) {
|
|
32
|
+
if (!importantPattern.test(line))
|
|
33
|
+
continue;
|
|
34
|
+
const cleaned = line.replace(/\s+/g, " ").slice(0, MAX_PINNED_FACT_CHARS);
|
|
35
|
+
if (!cleaned || seen.has(cleaned))
|
|
36
|
+
continue;
|
|
37
|
+
seen.add(cleaned);
|
|
38
|
+
facts.push(cleaned);
|
|
39
|
+
if (facts.length >= MAX_PINNED_FACTS)
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
return facts;
|
|
43
|
+
}
|
|
44
|
+
function buildSummaryEnvelope(summary, pinnedFacts) {
|
|
45
|
+
const factBlock = pinnedFacts.length > 0
|
|
46
|
+
? `Pinned facts (verbatim, highest priority):\n${pinnedFacts.map((f) => `- ${f}`).join("\n")}\n\n`
|
|
47
|
+
: "";
|
|
48
|
+
return `${factBlock}Conversation summary:\n${summary}`.trim();
|
|
49
|
+
}
|
|
2
50
|
export function estimateTokens(messages, systemPrompt) {
|
|
3
51
|
let chars = 0;
|
|
4
52
|
for (const m of messages) {
|
|
@@ -21,9 +69,10 @@ export async function compressState(apiKey, state, systemPrompt, model, options)
|
|
|
21
69
|
const toSummarize = state.slice(0, state.length - keepLast);
|
|
22
70
|
const recent = state.slice(-keepLast);
|
|
23
71
|
const summary = await callSummarize(apiKey, toSummarize, model);
|
|
72
|
+
const pinnedFacts = extractPinnedFacts(toSummarize);
|
|
24
73
|
const summaryMessage = {
|
|
25
|
-
role: "
|
|
26
|
-
content:
|
|
74
|
+
role: "assistant",
|
|
75
|
+
content: buildSummaryEnvelope(summary, pinnedFacts),
|
|
27
76
|
};
|
|
28
77
|
return [summaryMessage, ...recent];
|
|
29
78
|
}
|
|
@@ -31,12 +80,13 @@ export async function ensureUnderBudget(apiKey, state, systemPrompt, model, opti
|
|
|
31
80
|
const { maxTokens, keepLast } = options;
|
|
32
81
|
if (estimateTokens(state, systemPrompt) <= maxTokens)
|
|
33
82
|
return state;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
83
|
+
let working = state;
|
|
84
|
+
if (working.length > keepLast) {
|
|
85
|
+
working = await compressState(apiKey, working, systemPrompt, model, { keepLast });
|
|
86
|
+
}
|
|
87
|
+
// If still over budget, trim oldest messages as a hard fallback.
|
|
88
|
+
while (working.length > 1 && estimateTokens(working, systemPrompt) > maxTokens) {
|
|
89
|
+
working = working.slice(1);
|
|
40
90
|
}
|
|
41
|
-
return
|
|
91
|
+
return working;
|
|
42
92
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import "dotenv/config";
|
|
4
|
+
import { writeSync } from "node:fs";
|
|
4
5
|
import { render } from "ink";
|
|
5
6
|
import { getApiKey } from "./config.js";
|
|
6
7
|
import { getVersion } from "./version.js";
|
|
7
8
|
import { runOnboarding } from "./onboarding.js";
|
|
8
9
|
import { Repl } from "./repl.js";
|
|
10
|
+
const PRINT_TRANSCRIPT_ON_EXIT = process.env.IDEACODE_TRANSCRIPT_ON_EXIT === "1";
|
|
11
|
+
const CLEAR_SCREEN_ON_EXIT = process.env.IDEACODE_CLEAR_ON_EXIT !== "0";
|
|
12
|
+
const TERMINAL_RESTORE_SEQ = "\x1b[?2004l\x1b[?1004l\x1b[?1007l\x1b[?1015l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?25h\x1b[0m";
|
|
13
|
+
function restoreTerminalState() {
|
|
14
|
+
try {
|
|
15
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
|
|
16
|
+
process.stdin.setRawMode(false);
|
|
17
|
+
}
|
|
18
|
+
if (process.stdout.isTTY) {
|
|
19
|
+
writeSync(process.stdout.fd, TERMINAL_RESTORE_SEQ);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Ignore restore failures during process teardown.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
process.on("exit", restoreTerminalState);
|
|
27
|
+
process.on("SIGINT", () => {
|
|
28
|
+
restoreTerminalState();
|
|
29
|
+
process.exit(130);
|
|
30
|
+
});
|
|
31
|
+
process.on("SIGTERM", () => {
|
|
32
|
+
restoreTerminalState();
|
|
33
|
+
process.exit(143);
|
|
34
|
+
});
|
|
9
35
|
async function main() {
|
|
10
36
|
const args = process.argv.slice(2);
|
|
11
37
|
if (args.includes("-v") || args.includes("--version")) {
|
|
@@ -20,7 +46,19 @@ async function main() {
|
|
|
20
46
|
if (!apiKey) {
|
|
21
47
|
process.exit(1);
|
|
22
48
|
}
|
|
23
|
-
|
|
24
|
-
|
|
49
|
+
let app;
|
|
50
|
+
let transcriptLines = [];
|
|
51
|
+
app = render(_jsx(Repl, { apiKey: apiKey, cwd: process.cwd(), onQuit: (lines) => {
|
|
52
|
+
transcriptLines = lines;
|
|
53
|
+
app.unmount();
|
|
54
|
+
} }));
|
|
55
|
+
await app.waitUntilExit();
|
|
56
|
+
if (!PRINT_TRANSCRIPT_ON_EXIT && CLEAR_SCREEN_ON_EXIT && process.stdout.isTTY) {
|
|
57
|
+
// Clear lingering in-place TUI frame from primary screen buffer.
|
|
58
|
+
writeSync(process.stdout.fd, "\x1b[2J\x1b[H");
|
|
59
|
+
}
|
|
60
|
+
if (PRINT_TRANSCRIPT_ON_EXIT && transcriptLines.length > 0) {
|
|
61
|
+
process.stdout.write("\n" + transcriptLines.join("\n") + "\n");
|
|
62
|
+
}
|
|
25
63
|
}
|
|
26
64
|
main();
|
package/dist/repl.js
CHANGED
|
@@ -6,6 +6,7 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from "react"
|
|
|
6
6
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
7
7
|
import { globSync } from "glob";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
+
import { writeSync } from "node:fs";
|
|
9
10
|
import gradient from "gradient-string";
|
|
10
11
|
// Custom matcha-themed gradient: matcha green → dark sepia
|
|
11
12
|
const matchaGradient = gradient(["#7F9A65", "#5C4033"]);
|
|
@@ -39,7 +40,7 @@ const MAX_AT_SUGGESTIONS = 12;
|
|
|
39
40
|
const INITIAL_BANNER_LINES = 12;
|
|
40
41
|
const ENABLE_PARALLEL_TOOL_CALLS = process.env.IDEACODE_PARALLEL_TOOL_CALLS !== "0";
|
|
41
42
|
const PARALLEL_SAFE_TOOLS = new Set(["read", "glob", "grep", "web_fetch", "web_search"]);
|
|
42
|
-
const LOADING_TICK_MS =
|
|
43
|
+
const LOADING_TICK_MS = 80;
|
|
43
44
|
const TRUNCATE_NOTE = "\n\n(Output truncated to save context. Use read with offset/limit, grep with a specific pattern, or tail with fewer lines to get more.)";
|
|
44
45
|
function truncateToolResult(content) {
|
|
45
46
|
if (content.length <= MAX_TOOL_RESULT_CHARS)
|
|
@@ -63,18 +64,41 @@ function listFilesWithFilter(cwd, filter) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
function summarizeBashCommand(cmdRaw) {
|
|
66
|
-
const
|
|
67
|
-
.split(/\n
|
|
67
|
+
const parts = cmdRaw
|
|
68
|
+
.split(/\n|&&|;|\|/g)
|
|
68
69
|
.map((s) => s.trim())
|
|
69
70
|
.filter(Boolean);
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
const commands = [];
|
|
72
|
+
for (const part of parts) {
|
|
73
|
+
let s = part.replace(/^\(+/, "").trim();
|
|
74
|
+
if (!s || s.toUpperCase() === "EOF")
|
|
75
|
+
continue;
|
|
76
|
+
// Strip simple environment assignments at the front: FOO=bar CMD
|
|
77
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(s)) {
|
|
78
|
+
const idx = s.indexOf(" ");
|
|
79
|
+
if (idx === -1) {
|
|
80
|
+
s = "";
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
s = s.slice(idx + 1).trim();
|
|
84
|
+
}
|
|
85
|
+
if (!s)
|
|
86
|
+
continue;
|
|
87
|
+
const token = s.split(/\s+/)[0]?.replace(/^['"]|['"]$/g, "") ?? "";
|
|
88
|
+
if (!/^[A-Za-z0-9_./-]+$/.test(token))
|
|
89
|
+
continue;
|
|
90
|
+
if (token === "echo")
|
|
91
|
+
continue;
|
|
92
|
+
if (token === "cat" && /<<\s*['"]?EOF/i.test(s))
|
|
93
|
+
continue;
|
|
94
|
+
if (!commands.includes(token))
|
|
95
|
+
commands.push(token);
|
|
96
|
+
}
|
|
97
|
+
if (commands.length === 0)
|
|
98
|
+
return "bash";
|
|
99
|
+
const shown = commands.slice(0, 5);
|
|
100
|
+
const suffix = commands.length > 5 ? `, +${commands.length - 5}` : "";
|
|
101
|
+
return (shown.join(", ") + suffix).slice(0, 140);
|
|
78
102
|
}
|
|
79
103
|
function toolArgPreview(toolName, toolArgs) {
|
|
80
104
|
if (toolName === "bash") {
|
|
@@ -196,36 +220,41 @@ function useTerminalSize() {
|
|
|
196
220
|
}, [stdout]);
|
|
197
221
|
return size;
|
|
198
222
|
}
|
|
199
|
-
function
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
out += inWindow ? colors.gray(label[i] ?? "") : colors.mutedDark(label[i] ?? "");
|
|
211
|
-
}
|
|
212
|
-
return out;
|
|
223
|
+
const LogViewport = React.memo(function LogViewport({ lines, startIndex, height, }) {
|
|
224
|
+
return (_jsx(Box, { flexDirection: "column", height: height, overflow: "hidden", children: lines.map((line, i) => (_jsx(Text, { children: line === "" ? "\u00A0" : line }, startIndex + i))) }));
|
|
225
|
+
});
|
|
226
|
+
function orbitDots(frame) {
|
|
227
|
+
const phase = frame % 6;
|
|
228
|
+
const activeIndex = phase <= 3 ? phase : 6 - phase;
|
|
229
|
+
const slots = ["·", "·", "·", "·"];
|
|
230
|
+
slots[activeIndex] = "●";
|
|
231
|
+
return slots
|
|
232
|
+
.map((ch, i) => (i === activeIndex ? colors.gray(ch) : colors.mutedDark(ch)))
|
|
233
|
+
.join("");
|
|
213
234
|
}
|
|
214
235
|
const LoadingStatus = React.memo(function LoadingStatus({ active, label, }) {
|
|
215
236
|
const [frame, setFrame] = useState(0);
|
|
216
|
-
const
|
|
237
|
+
const startedAtRef = useRef(null);
|
|
217
238
|
useEffect(() => {
|
|
218
|
-
if (!active)
|
|
239
|
+
if (!active) {
|
|
240
|
+
startedAtRef.current = null;
|
|
219
241
|
return;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
242
|
+
}
|
|
243
|
+
if (startedAtRef.current == null) {
|
|
244
|
+
startedAtRef.current = Date.now();
|
|
245
|
+
setFrame(0);
|
|
246
|
+
}
|
|
247
|
+
const anim = setInterval(() => setFrame((n) => n + 1), LOADING_TICK_MS);
|
|
248
|
+
return () => {
|
|
249
|
+
clearInterval(anim);
|
|
250
|
+
};
|
|
251
|
+
}, [active]);
|
|
225
252
|
if (!active)
|
|
226
253
|
return _jsx(Text, { color: inkColors.textSecondary, children: "\u00A0" });
|
|
227
|
-
const
|
|
228
|
-
|
|
254
|
+
const startedAt = startedAtRef.current ?? Date.now();
|
|
255
|
+
const elapsedSeconds = Math.max(0, (Date.now() - startedAt) / 1000);
|
|
256
|
+
const elapsedText = elapsedSeconds < 10 ? `${elapsedSeconds.toFixed(1)}s` : `${Math.floor(elapsedSeconds)}s`;
|
|
257
|
+
return (_jsxs(Text, { color: inkColors.textSecondary, children: [" ", orbitDots(frame), " ", colors.gray(label), " ", colors.gray(elapsedText)] }));
|
|
229
258
|
});
|
|
230
259
|
export function Repl({ apiKey, cwd, onQuit }) {
|
|
231
260
|
const { rows: termRows, columns: termColumns } = useTerminalSize();
|
|
@@ -258,6 +287,10 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
258
287
|
}
|
|
259
288
|
return banner;
|
|
260
289
|
});
|
|
290
|
+
const logLinesRef = useRef(logLines);
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
logLinesRef.current = logLines;
|
|
293
|
+
}, [logLines]);
|
|
261
294
|
const [inputValue, setInputValue] = useState("");
|
|
262
295
|
const [currentModel, setCurrentModel] = useState(getModel);
|
|
263
296
|
const [messages, setMessages] = useState(() => loadConversation(cwd));
|
|
@@ -305,10 +338,23 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
305
338
|
if (saveDebounceRef.current)
|
|
306
339
|
clearTimeout(saveDebounceRef.current);
|
|
307
340
|
saveConversation(cwd, messagesRef.current);
|
|
308
|
-
|
|
341
|
+
// Best-effort terminal mode reset in case process exits before React cleanup runs.
|
|
342
|
+
try {
|
|
343
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
|
|
344
|
+
process.stdin.setRawMode(false);
|
|
345
|
+
}
|
|
346
|
+
if (process.stdout.isTTY) {
|
|
347
|
+
writeSync(process.stdout.fd, "\x1b[?2004l\x1b[?1004l\x1b[?1007l\x1b[?1015l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?25h\x1b[0m");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Ignore restore failures during teardown.
|
|
352
|
+
}
|
|
353
|
+
onQuit(logLinesRef.current);
|
|
309
354
|
}, [cwd, onQuit]);
|
|
310
355
|
const [loading, setLoading] = useState(false);
|
|
311
356
|
const [loadingLabel, setLoadingLabel] = useState("Thinking…");
|
|
357
|
+
const [cursorBlinkOn, setCursorBlinkOn] = useState(true);
|
|
312
358
|
const [showPalette, setShowPalette] = useState(false);
|
|
313
359
|
const [paletteIndex, setPaletteIndex] = useState(0);
|
|
314
360
|
const [showModelSelector, setShowModelSelector] = useState(false);
|
|
@@ -327,11 +373,21 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
327
373
|
const scrollBoundsRef = useRef({ maxLogScrollOffset: 0, logViewportHeight: 1 });
|
|
328
374
|
const prevEscRef = useRef(false);
|
|
329
375
|
useEffect(() => {
|
|
376
|
+
// Enable SGR mouse + basic tracking so trackpad wheel scrolling works.
|
|
330
377
|
process.stdout.write("\x1b[?1006h\x1b[?1000h");
|
|
331
378
|
return () => {
|
|
332
379
|
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
|
333
380
|
};
|
|
334
381
|
}, []);
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
setCursorBlinkOn(true);
|
|
384
|
+
if (loading)
|
|
385
|
+
return;
|
|
386
|
+
const timer = setInterval(() => {
|
|
387
|
+
setCursorBlinkOn((prev) => !prev);
|
|
388
|
+
}, 520);
|
|
389
|
+
return () => clearInterval(timer);
|
|
390
|
+
}, [loading, inputValue, inputCursor]);
|
|
335
391
|
const estimatedTokens = useMemo(() => estimateTokens(messages, undefined), [messages]);
|
|
336
392
|
const contextWindowK = useMemo(() => {
|
|
337
393
|
const ctx = modelList.find((m) => m.id === currentModel)?.context_length;
|
|
@@ -345,6 +401,20 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
345
401
|
return modelList.filter((m) => m.id.toLowerCase().includes(q) ||
|
|
346
402
|
(m.name ?? "").toLowerCase().includes(q));
|
|
347
403
|
}, [modelList, modelSearchFilter]);
|
|
404
|
+
const wrapWidth = Math.max(10, termColumns - PROMPT_INDENT_LEN - 2);
|
|
405
|
+
const inputLineCount = useMemo(() => {
|
|
406
|
+
const lines = inputValue.split("\n");
|
|
407
|
+
return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / wrapWidth)), 0);
|
|
408
|
+
}, [inputValue, wrapWidth]);
|
|
409
|
+
const [stableInputLineCount, setStableInputLineCount] = useState(inputLineCount);
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
if (inputLineCount <= 1) {
|
|
412
|
+
setStableInputLineCount(1);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const t = setTimeout(() => setStableInputLineCount(inputLineCount), 90);
|
|
416
|
+
return () => clearTimeout(t);
|
|
417
|
+
}, [inputLineCount]);
|
|
348
418
|
useEffect(() => {
|
|
349
419
|
setInputCursor((c) => Math.min(c, Math.max(0, inputValue.length)));
|
|
350
420
|
}, [inputValue.length]);
|
|
@@ -512,14 +582,20 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
512
582
|
setLoading(true);
|
|
513
583
|
state = await ensureUnderBudget(apiKey, state, systemPrompt, currentModel, {
|
|
514
584
|
maxTokens: maxContextTokens,
|
|
515
|
-
keepLast:
|
|
585
|
+
keepLast: 8,
|
|
516
586
|
});
|
|
517
587
|
if (state.length < stateBeforeCompress.length) {
|
|
518
588
|
appendLog(colors.muted(" (context compressed to stay under limit)\n"));
|
|
519
589
|
}
|
|
590
|
+
setLoadingLabel("Thinking…");
|
|
520
591
|
for (;;) {
|
|
592
|
+
setLoading(true);
|
|
521
593
|
setLoadingLabel("Thinking…");
|
|
522
|
-
const response = await callApi(apiKey, state, systemPrompt, currentModel
|
|
594
|
+
const response = await callApi(apiKey, state, systemPrompt, currentModel, {
|
|
595
|
+
onRetry: ({ attempt, maxAttempts, waitMs, status }) => {
|
|
596
|
+
setLoadingLabel(`Rate limited (${status}), retry ${attempt}/${maxAttempts} in ${(waitMs / 1000).toFixed(1)}s…`);
|
|
597
|
+
},
|
|
598
|
+
});
|
|
523
599
|
const contentBlocks = response.content ?? [];
|
|
524
600
|
const toolResults = [];
|
|
525
601
|
const renderToolOutcome = (planned, result, extraIndent = 0) => {
|
|
@@ -535,6 +611,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
535
611
|
const runParallelBatch = async (batch) => {
|
|
536
612
|
if (batch.length === 0)
|
|
537
613
|
return;
|
|
614
|
+
setLoadingLabel(`Running ${batch.length} tools in parallel…`);
|
|
538
615
|
const started = Date.now();
|
|
539
616
|
const groupedTools = Array.from(batch.reduce((acc, planned) => {
|
|
540
617
|
acc.set(planned.toolName, (acc.get(planned.toolName) ?? 0) + 1);
|
|
@@ -574,11 +651,11 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
574
651
|
}
|
|
575
652
|
await runParallelBatch(parallelBatch);
|
|
576
653
|
parallelBatch = [];
|
|
654
|
+
setLoadingLabel(`Running ${planned.toolName}…`);
|
|
577
655
|
const result = await runTool(planned.toolName, planned.toolArgs);
|
|
578
656
|
renderToolOutcome(planned, result);
|
|
579
657
|
}
|
|
580
658
|
await runParallelBatch(parallelBatch);
|
|
581
|
-
setLoading(false);
|
|
582
659
|
state = [...state, { role: "assistant", content: contentBlocks }];
|
|
583
660
|
if (toolResults.length === 0) {
|
|
584
661
|
setMessages(state);
|
|
@@ -587,6 +664,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
587
664
|
state = [...state, { role: "user", content: toolResults }];
|
|
588
665
|
setMessages(state);
|
|
589
666
|
}
|
|
667
|
+
setLoading(false);
|
|
590
668
|
return true;
|
|
591
669
|
}, [apiKey, cwd, currentModel, messages, modelList, appendLog, openModelSelector, openBraveKeyModal, openHelpModal]);
|
|
592
670
|
const handleSubmit = useCallback(async (value) => {
|
|
@@ -1004,13 +1082,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
1004
1082
|
? 4 + Math.max(1, filteredFilePaths.length)
|
|
1005
1083
|
: 0;
|
|
1006
1084
|
const suggestionBoxLines = slashSuggestionBoxLines || atSuggestionBoxLines;
|
|
1007
|
-
const wrapWidth = Math.max(10, termColumns - PROMPT_INDENT_LEN - 2);
|
|
1008
|
-
const inputLineCount = (() => {
|
|
1009
|
-
const lines = inputValue.split("\n");
|
|
1010
|
-
return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / wrapWidth)), 0);
|
|
1011
|
-
})();
|
|
1012
1085
|
// Keep a fixed loading row reserved to avoid viewport jumps/flicker when loading starts/stops.
|
|
1013
|
-
const reservedLines = 1 +
|
|
1086
|
+
const reservedLines = 1 + stableInputLineCount + 2;
|
|
1014
1087
|
const logViewportHeight = Math.max(1, termRows - reservedLines - suggestionBoxLines);
|
|
1015
1088
|
const effectiveLogLines = logLines;
|
|
1016
1089
|
const maxLogScrollOffset = Math.max(0, effectiveLogLines.length - logViewportHeight);
|
|
@@ -1020,7 +1093,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
1020
1093
|
logStartIndex = 0;
|
|
1021
1094
|
}
|
|
1022
1095
|
const sliceEnd = logStartIndex + logViewportHeight;
|
|
1023
|
-
const visibleLogLines = effectiveLogLines.slice(logStartIndex, sliceEnd);
|
|
1096
|
+
const visibleLogLines = useMemo(() => effectiveLogLines.slice(logStartIndex, sliceEnd), [effectiveLogLines, logStartIndex, sliceEnd]);
|
|
1097
|
+
const useSimpleInputRenderer = inputLineCount > 1;
|
|
1024
1098
|
if (showHelpModal) {
|
|
1025
1099
|
const helpModalWidth = Math.min(88, Math.max(80, termColumns - 4));
|
|
1026
1100
|
const helpContentRows = 20;
|
|
@@ -1043,14 +1117,14 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
1043
1117
|
const leftPad = Math.max(0, Math.floor((termColumns - paletteModalWidth) / 2));
|
|
1044
1118
|
return (_jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [_jsx(Box, { height: topPad }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: leftPad }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: inkColors.primary, paddingX: 2, paddingY: 1, width: paletteModalWidth, minHeight: paletteModalHeight, children: [_jsx(Text, { bold: true, children: " Command palette " }), COMMANDS.map((c, i) => (_jsxs(Text, { color: i === paletteIndex ? inkColors.primary : undefined, children: [i === paletteIndex ? "› " : " ", c.cmd, _jsxs(Text, { color: inkColors.textSecondary, children: [" \u2014 ", c.desc] })] }, c.cmd))), _jsxs(Text, { color: paletteIndex === COMMANDS.length ? inkColors.primary : undefined, children: [paletteIndex === COMMANDS.length ? "› " : " ", "Cancel (Esc)"] }), _jsx(Text, { color: inkColors.textSecondary, children: " \u2191/\u2193 select, Enter confirm, Esc close " })] })] }), _jsx(Box, { flexGrow: 1 })] }));
|
|
1045
1119
|
}
|
|
1046
|
-
const footerLines = suggestionBoxLines + 1 +
|
|
1047
|
-
return (_jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: [_jsx(
|
|
1120
|
+
const footerLines = suggestionBoxLines + 1 + stableInputLineCount;
|
|
1121
|
+
return (_jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: [_jsx(LogViewport, { lines: visibleLogLines, startIndex: logStartIndex, height: logViewportHeight }), _jsx(Box, { flexDirection: "row", marginTop: 1, marginBottom: 0, children: _jsx(LoadingStatus, { active: loading, label: loadingLabel }) })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, height: footerLines, children: [showSlashSuggestions && (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 2, borderStyle: "single", borderColor: inkColors.textDisabled, children: [filteredSlashCommands.length === 0 ? (_jsx(Text, { color: inkColors.textSecondary, children: " No match " })) : ([...filteredSlashCommands].reverse().map((c, rev) => {
|
|
1048
1122
|
const i = filteredSlashCommands.length - 1 - rev;
|
|
1049
1123
|
return (_jsxs(Text, { color: i === clampedSlashIndex ? inkColors.primary : undefined, children: [i === clampedSlashIndex ? "› " : " ", c.cmd, _jsxs(Text, { color: inkColors.textSecondary, children: [" \u2014 ", c.desc] })] }, c.cmd));
|
|
1050
1124
|
})), _jsx(Text, { color: inkColors.textSecondary, children: " Commands (\u2191/\u2193 select, Enter run, Esc clear) " })] })), cursorInAtSegment && !showSlashSuggestions && (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 2, borderStyle: "single", borderColor: inkColors.textDisabled, children: [filteredFilePaths.length === 0 ? (_jsxs(Text, { color: inkColors.textSecondary, children: [" ", hasCharsAfterAt ? "No match" : "Type to search files", " "] })) : ([...filteredFilePaths].reverse().map((p, rev) => {
|
|
1051
1125
|
const i = filteredFilePaths.length - 1 - rev;
|
|
1052
1126
|
return (_jsxs(Text, { color: i === clampedAtFileIndex ? inkColors.primary : undefined, children: [i === clampedAtFileIndex ? "› " : " ", p] }, p));
|
|
1053
|
-
})), _jsx(Box, { flexDirection: "row", marginTop: 1, children: _jsx(Text, { color: inkColors.textSecondary, children: " Files (\u2191/\u2193 select, Enter/Tab complete, Esc clear) " }) })] })), _jsxs(Box, { flexDirection: "row", marginTop: 0, children: [_jsxs(Text, { color: "gray", children: [" ", icons.tool, " ", tokenDisplay] }), _jsx(Text, { color: "gray", children: ` · / ! @ trackpad/↑/↓ scroll Opt/Fn+select Ctrl+J newline Tab queue Esc Esc edit ${pasteShortcut} paste Ctrl+C exit` })] }), _jsx(Box, { flexDirection: "column", marginTop: 0, children: inputValue.length === 0 ? (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: inkColors.primary, children: [icons.prompt, " "] }), _jsx(Text, { inverse: true, color: inkColors.primary, children: " " }), _jsx(Text, { color: inkColors.textSecondary, children: "Message or / for commands, @ for files, ! for shell, ? for help..." })] })) : ((() => {
|
|
1127
|
+
})), _jsx(Box, { flexDirection: "row", marginTop: 1, children: _jsx(Text, { color: inkColors.textSecondary, children: " Files (\u2191/\u2193 select, Enter/Tab complete, Esc clear) " }) })] })), _jsxs(Box, { flexDirection: "row", marginTop: 0, children: [_jsxs(Text, { color: "gray", children: [" ", icons.tool, " ", tokenDisplay] }), _jsx(Text, { color: "gray", children: ` · / ! @ trackpad/↑/↓ scroll Opt/Fn+select Ctrl+J newline Tab queue Esc Esc edit ${pasteShortcut} paste Ctrl+C exit` })] }), _jsx(Box, { flexDirection: "column", marginTop: 0, children: inputValue.length === 0 ? (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: inkColors.primary, children: [icons.prompt, " "] }), cursorBlinkOn ? (_jsx(Text, { inverse: true, color: inkColors.primary, children: " " })) : (_jsx(Text, { color: inkColors.primary, children: " " })), _jsx(Text, { color: inkColors.textSecondary, children: "Message or / for commands, @ for files, ! for shell, ? for help..." })] })) : ((() => {
|
|
1054
1128
|
const lines = inputValue.split("\n");
|
|
1055
1129
|
let lineStart = 0;
|
|
1056
1130
|
return (_jsx(_Fragment, { children: lines.flatMap((lineText, lineIdx) => {
|
|
@@ -1059,6 +1133,49 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
1059
1133
|
const cursorOffsetInLine = cursorOnThisLine ? inputCursor - lineStart : -1;
|
|
1060
1134
|
const currentLineStart = lineStart;
|
|
1061
1135
|
lineStart = lineEnd + 1;
|
|
1136
|
+
if (useSimpleInputRenderer) {
|
|
1137
|
+
const visualLines = wrapLine(lineText, wrapWidth);
|
|
1138
|
+
return visualLines.map((visualChunk, v) => {
|
|
1139
|
+
const visualStart = v * wrapWidth;
|
|
1140
|
+
const visualEnd = Math.min((v + 1) * wrapWidth, lineText.length);
|
|
1141
|
+
const isLastVisualOfThisLine = v === visualLines.length - 1;
|
|
1142
|
+
const cursorAtEndOfVisual = isLastVisualOfThisLine && cursorOffsetInLine === visualEnd;
|
|
1143
|
+
const cursorPosInVisual = cursorOnThisLine &&
|
|
1144
|
+
cursorOffsetInLine >= visualStart &&
|
|
1145
|
+
(cursorOffsetInLine < visualEnd || cursorAtEndOfVisual)
|
|
1146
|
+
? cursorOffsetInLine < visualEnd
|
|
1147
|
+
? cursorOffsetInLine - visualStart
|
|
1148
|
+
: visualEnd - visualStart
|
|
1149
|
+
: -1;
|
|
1150
|
+
const isFirstRow = lineIdx === 0 && v === 0;
|
|
1151
|
+
const isLastLogicalLine = lineIdx === lines.length - 1;
|
|
1152
|
+
const isLastVisualOfLine = v === visualLines.length - 1;
|
|
1153
|
+
const rowNodes = [];
|
|
1154
|
+
if (lineText === "" && v === 0 && cursorOnThisLine) {
|
|
1155
|
+
rowNodes.push(cursorBlinkOn
|
|
1156
|
+
? (_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor-empty-on"))
|
|
1157
|
+
: (_jsx(Text, { color: inkColors.primary, children: "\u00A0" }, "cursor-empty-off")));
|
|
1158
|
+
}
|
|
1159
|
+
else if (cursorPosInVisual >= 0) {
|
|
1160
|
+
const before = visualChunk.slice(0, cursorPosInVisual);
|
|
1161
|
+
const curChar = cursorPosInVisual < visualChunk.length
|
|
1162
|
+
? visualChunk[cursorPosInVisual]
|
|
1163
|
+
: "\u00A0";
|
|
1164
|
+
const after = cursorPosInVisual < visualChunk.length
|
|
1165
|
+
? visualChunk.slice(cursorPosInVisual + 1)
|
|
1166
|
+
: "";
|
|
1167
|
+
rowNodes.push(_jsx(Text, { children: before }, "plain-before"));
|
|
1168
|
+
rowNodes.push(cursorBlinkOn
|
|
1169
|
+
? (_jsx(Text, { inverse: true, color: inkColors.primary, children: curChar }, "plain-caret-on"))
|
|
1170
|
+
: (_jsx(Text, { children: curChar }, "plain-caret-off")));
|
|
1171
|
+
rowNodes.push(_jsx(Text, { children: after }, "plain-after"));
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
rowNodes.push(_jsx(Text, { children: visualChunk }, "plain"));
|
|
1175
|
+
}
|
|
1176
|
+
return (_jsxs(Box, { flexDirection: "row", children: [isFirstRow ? (_jsxs(Text, { color: inkColors.primary, children: [icons.prompt, " "] })) : (_jsx(Text, { children: " ".repeat(PROMPT_INDENT_LEN) })), rowNodes, isLastLogicalLine && isLastVisualOfLine && inputValue.startsWith("!") && (_jsxs(Text, { color: inkColors.textDisabled, children: [" — ", "type a shell command to run"] }))] }, `simple-${lineIdx}-${v}`));
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1062
1179
|
const segments = parseAtSegments(lineText);
|
|
1063
1180
|
let runIdx = 0;
|
|
1064
1181
|
const segmentsWithStyle = [];
|
|
@@ -1122,7 +1239,9 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
1122
1239
|
: -1;
|
|
1123
1240
|
const lineNodes = [];
|
|
1124
1241
|
if (lineText === "" && v === 0 && cursorOnThisLine) {
|
|
1125
|
-
lineNodes.push(
|
|
1242
|
+
lineNodes.push(cursorBlinkOn
|
|
1243
|
+
? (_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor-on"))
|
|
1244
|
+
: (_jsx(Text, { color: inkColors.primary, children: "\u00A0" }, "cursor-off")));
|
|
1126
1245
|
}
|
|
1127
1246
|
else {
|
|
1128
1247
|
let cursorRendered = false;
|
|
@@ -1142,7 +1261,12 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
1142
1261
|
const after = text.slice(segRel + 1);
|
|
1143
1262
|
const usePath = "color" in seg.style && !!seg.style.color;
|
|
1144
1263
|
lineNodes.push(_jsx(Text, { ...seg.style, children: before }, `${segIdx}-a`));
|
|
1145
|
-
|
|
1264
|
+
if (cursorBlinkOn) {
|
|
1265
|
+
lineNodes.push(_jsx(Text, { inverse: true, color: usePath ? inkColors.path : inkColors.primary, bold: "bold" in seg.style && !!seg.style.bold, children: curChar }, `${segIdx}-b-on`));
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
lineNodes.push(_jsx(Text, { ...seg.style, children: curChar }, `${segIdx}-b-off`));
|
|
1269
|
+
}
|
|
1146
1270
|
lineNodes.push(_jsx(Text, { ...seg.style, children: after }, `${segIdx}-c`));
|
|
1147
1271
|
cursorRendered = true;
|
|
1148
1272
|
}
|
|
@@ -1155,7 +1279,9 @@ export function Repl({ apiKey, cwd, onQuit }) {
|
|
|
1155
1279
|
}
|
|
1156
1280
|
});
|
|
1157
1281
|
if (cursorPosInVisual >= 0 && !cursorRendered) {
|
|
1158
|
-
lineNodes.push(
|
|
1282
|
+
lineNodes.push(cursorBlinkOn
|
|
1283
|
+
? (_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor-end-on"))
|
|
1284
|
+
: (_jsx(Text, { color: inkColors.primary, children: "\u00A0" }, "cursor-end-off")));
|
|
1159
1285
|
}
|
|
1160
1286
|
}
|
|
1161
1287
|
const isFirstRow = lineIdx === 0 && v === 0;
|