ideacode 1.2.1 → 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 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 = 3;
13
- const INITIAL_BACKOFF_MS = 1000;
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
- export async function callApi(apiKey, messages, systemPrompt, model) {
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 === 429 || res.status === 503) && attempt < MAX_RETRIES) {
52
+ if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
40
53
  const retryAfter = res.headers.get("retry-after");
41
- const waitMs = retryAfter
42
- ? Math.max(1000, parseInt(retryAfter, 10) * 1000)
43
- : INITIAL_BACKOFF_MS * Math.pow(2, attempt);
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. Summarize the following conversation between user and assistant, including any tool use and results. Preserve the user's goal, key decisions, and important facts. Output only the summary, no preamble.";
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 === 429 || res.status === 503) && attempt < MAX_RETRIES) {
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: "user",
26
- content: `Previous context:\n${summary}`,
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
- if (state.length <= keepLast) {
35
- let trimmed = state;
36
- while (trimmed.length > 1 && estimateTokens(trimmed, systemPrompt) > maxTokens) {
37
- trimmed = trimmed.slice(1);
38
- }
39
- return trimmed;
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 compressState(apiKey, state, systemPrompt, model, { keepLast });
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
- const { waitUntilExit } = render(_jsx(Repl, { apiKey: apiKey, cwd: process.cwd(), onQuit: () => process.exit(0) }));
24
- await waitUntilExit();
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
@@ -2,10 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  /**
3
3
  * Main REPL UI: input, log viewport, slash/@ suggestions, modals (model picker, palette), API loop and tool dispatch.
4
4
  */
5
- import { useState, useCallback, useRef, useMemo, useEffect } from "react";
5
+ 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 = Math.min(300, Math.max(80, Number.parseInt(process.env.IDEACODE_SPINNER_MS ?? "110", 10) || 110));
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)
@@ -62,6 +63,65 @@ function listFilesWithFilter(cwd, filter) {
62
63
  return [];
63
64
  }
64
65
  }
66
+ function summarizeBashCommand(cmdRaw) {
67
+ const parts = cmdRaw
68
+ .split(/\n|&&|;|\|/g)
69
+ .map((s) => s.trim())
70
+ .filter(Boolean);
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);
102
+ }
103
+ function toolArgPreview(toolName, toolArgs) {
104
+ if (toolName === "bash") {
105
+ const cmd = String(toolArgs.cmd ?? "").trim();
106
+ return cmd ? summarizeBashCommand(cmd) : "—";
107
+ }
108
+ if ("path" in toolArgs && typeof toolArgs.path === "string") {
109
+ return toolArgs.path.slice(0, 140) || "—";
110
+ }
111
+ const firstVal = Object.values(toolArgs)[0];
112
+ return String(firstVal ?? "").slice(0, 140) || "—";
113
+ }
114
+ function parseEditDelta(result) {
115
+ const both = result.match(/ok\s*\(\+(\d+)\s*-(\d+)\)/i);
116
+ if (both) {
117
+ return { added: Number.parseInt(both[1] ?? "0", 10), removed: Number.parseInt(both[2] ?? "0", 10) };
118
+ }
119
+ const addOnly = result.match(/ok\s*\(\+(\d+)\)/i);
120
+ if (addOnly) {
121
+ return { added: Number.parseInt(addOnly[1] ?? "0", 10), removed: 0 };
122
+ }
123
+ return undefined;
124
+ }
65
125
  function parseAtSegments(value) {
66
126
  const segments = [];
67
127
  let pos = 0;
@@ -115,11 +175,13 @@ function replayMessagesToLogLines(messages) {
115
175
  const block = toolUses.find((b) => b.id === tr.tool_use_id);
116
176
  if (block?.name) {
117
177
  const name = block.name.trim().toLowerCase();
118
- const firstVal = block.input && typeof block.input === "object" ? Object.values(block.input)[0] : undefined;
119
- const argPreview = String(firstVal ?? "").slice(0, 50) || "—";
178
+ const args = block.input && typeof block.input === "object"
179
+ ? block.input
180
+ : {};
181
+ const argPreview = toolArgPreview(name, args).slice(0, 60);
120
182
  const content = tr.content ?? "";
121
183
  const ok = !content.startsWith("error:");
122
- lines.push(toolCallBox(name, argPreview, ok));
184
+ lines.push(toolCallBox(name, argPreview, ok, 0, name === "edit" || name === "write" ? parseEditDelta(content) : undefined));
123
185
  const tokens = estimateTokensForString(content);
124
186
  lines.push(toolResultTokenLine(tokens, ok));
125
187
  }
@@ -158,6 +220,42 @@ function useTerminalSize() {
158
220
  }, [stdout]);
159
221
  return size;
160
222
  }
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("");
234
+ }
235
+ const LoadingStatus = React.memo(function LoadingStatus({ active, label, }) {
236
+ const [frame, setFrame] = useState(0);
237
+ const startedAtRef = useRef(null);
238
+ useEffect(() => {
239
+ if (!active) {
240
+ startedAtRef.current = null;
241
+ return;
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]);
252
+ if (!active)
253
+ return _jsx(Text, { color: inkColors.textSecondary, children: "\u00A0" });
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)] }));
258
+ });
161
259
  export function Repl({ apiKey, cwd, onQuit }) {
162
260
  const { rows: termRows, columns: termColumns } = useTerminalSize();
163
261
  // Big ASCII art logo for ideacode
@@ -189,6 +287,10 @@ export function Repl({ apiKey, cwd, onQuit }) {
189
287
  }
190
288
  return banner;
191
289
  });
290
+ const logLinesRef = useRef(logLines);
291
+ useEffect(() => {
292
+ logLinesRef.current = logLines;
293
+ }, [logLines]);
192
294
  const [inputValue, setInputValue] = useState("");
193
295
  const [currentModel, setCurrentModel] = useState(getModel);
194
296
  const [messages, setMessages] = useState(() => loadConversation(cwd));
@@ -236,10 +338,23 @@ export function Repl({ apiKey, cwd, onQuit }) {
236
338
  if (saveDebounceRef.current)
237
339
  clearTimeout(saveDebounceRef.current);
238
340
  saveConversation(cwd, messagesRef.current);
239
- onQuit();
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);
240
354
  }, [cwd, onQuit]);
241
355
  const [loading, setLoading] = useState(false);
242
356
  const [loadingLabel, setLoadingLabel] = useState("Thinking…");
357
+ const [cursorBlinkOn, setCursorBlinkOn] = useState(true);
243
358
  const [showPalette, setShowPalette] = useState(false);
244
359
  const [paletteIndex, setPaletteIndex] = useState(0);
245
360
  const [showModelSelector, setShowModelSelector] = useState(false);
@@ -258,14 +373,21 @@ export function Repl({ apiKey, cwd, onQuit }) {
258
373
  const scrollBoundsRef = useRef({ maxLogScrollOffset: 0, logViewportHeight: 1 });
259
374
  const prevEscRef = useRef(false);
260
375
  useEffect(() => {
376
+ // Enable SGR mouse + basic tracking so trackpad wheel scrolling works.
261
377
  process.stdout.write("\x1b[?1006h\x1b[?1000h");
262
378
  return () => {
263
379
  process.stdout.write("\x1b[?1006l\x1b[?1000l");
264
380
  };
265
381
  }, []);
266
- const [spinnerTick, setSpinnerTick] = useState(0);
267
- const loadingStartedAtRef = useRef(0);
268
- const SPINNER = ["●○○", "○●○", "○○●", "○●○"];
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]);
269
391
  const estimatedTokens = useMemo(() => estimateTokens(messages, undefined), [messages]);
270
392
  const contextWindowK = useMemo(() => {
271
393
  const ctx = modelList.find((m) => m.id === currentModel)?.context_length;
@@ -279,6 +401,20 @@ export function Repl({ apiKey, cwd, onQuit }) {
279
401
  return modelList.filter((m) => m.id.toLowerCase().includes(q) ||
280
402
  (m.name ?? "").toLowerCase().includes(q));
281
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]);
282
418
  useEffect(() => {
283
419
  setInputCursor((c) => Math.min(c, Math.max(0, inputValue.length)));
284
420
  }, [inputValue.length]);
@@ -296,14 +432,6 @@ export function Repl({ apiKey, cwd, onQuit }) {
296
432
  if (showModelSelector && filteredModelList.length > 0)
297
433
  setModelIndex((i) => Math.min(i, filteredModelList.length - 1));
298
434
  }, [showModelSelector, filteredModelList.length]);
299
- useEffect(() => {
300
- if (!loading)
301
- return;
302
- loadingStartedAtRef.current = Date.now();
303
- setSpinnerTick(0);
304
- const t = setInterval(() => setSpinnerTick((n) => n + 1), LOADING_TICK_MS);
305
- return () => clearInterval(t);
306
- }, [loading]);
307
435
  const showSlashSuggestions = inputValue.startsWith("/");
308
436
  const filteredSlashCommands = useMemo(() => {
309
437
  const filter = inputValue.slice(1).trim();
@@ -446,7 +574,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
446
574
  appendLog(userPromptBox(userInput));
447
575
  appendLog("");
448
576
  let state = [...messages, { role: "user", content: userInput }];
449
- const systemPrompt = `Concise coding assistant. cwd: ${cwd}. PRIORITIZE grep to locate; then read with offset and limit to fetch only relevant sections. Do not read whole files unless the user explicitly asks. Use focused greps (specific patterns, narrow paths) and read in chunks when files are large; avoid one huge grep or read that floods context. When exploring a dependency, set path to that package (e.g. node_modules/<pkg>) and list/read only what you need. Prefer grep or keyword search for the most recent or specific occurrence; avoid tail/read of thousands of lines. If a tool result says it was truncated, call the tool again with offset, limit, or a narrower pattern to get what you need. Use as many parallel read/search/web tool calls as needed in one turn when they are independent (often more than 3 is appropriate for broad research), but keep each call high-signal, non-redundant, and minimal in output size.`;
577
+ const systemPrompt = `Concise coding assistant. cwd: ${cwd}. PRIORITIZE grep to locate; then read with offset and limit to fetch only relevant sections. Do not read whole files unless the user explicitly asks. Use focused greps (specific patterns, narrow paths) and read in chunks when files are large; avoid one huge grep or read that floods context. When exploring a dependency, set path to that package (e.g. node_modules/<pkg>) and list/read only what you need. Prefer grep or keyword search for the most recent or specific occurrence; avoid tail/read of thousands of lines. If a tool result says it was truncated, call the tool again with offset, limit, or a narrower pattern to get what you need. Use as many parallel read/search/web tool calls as needed in one turn when they are independent (often more than 3 is appropriate for broad research), but keep each call high-signal, non-redundant, and minimal in output size. For bash tool calls, avoid decorative echo headers; run direct commands and keep commands concise.`;
450
578
  const modelContext = modelList.find((m) => m.id === currentModel)?.context_length;
451
579
  const maxContextTokens = Math.floor((modelContext ?? CONTEXT_WINDOW_K * 1024) * 0.85);
452
580
  const stateBeforeCompress = state;
@@ -454,19 +582,25 @@ export function Repl({ apiKey, cwd, onQuit }) {
454
582
  setLoading(true);
455
583
  state = await ensureUnderBudget(apiKey, state, systemPrompt, currentModel, {
456
584
  maxTokens: maxContextTokens,
457
- keepLast: 6,
585
+ keepLast: 8,
458
586
  });
459
587
  if (state.length < stateBeforeCompress.length) {
460
588
  appendLog(colors.muted(" (context compressed to stay under limit)\n"));
461
589
  }
590
+ setLoadingLabel("Thinking…");
462
591
  for (;;) {
592
+ setLoading(true);
463
593
  setLoadingLabel("Thinking…");
464
- 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
+ });
465
599
  const contentBlocks = response.content ?? [];
466
600
  const toolResults = [];
467
601
  const renderToolOutcome = (planned, result, extraIndent = 0) => {
468
602
  const ok = !result.startsWith("error:");
469
- appendLog(toolCallBox(planned.toolName, planned.argPreview, ok, extraIndent));
603
+ appendLog(toolCallBox(planned.toolName, planned.argPreview, ok, extraIndent, planned.toolName === "edit" || planned.toolName === "write" ? parseEditDelta(result) : undefined));
470
604
  const contentForApi = truncateToolResult(result);
471
605
  const tokens = estimateTokensForString(contentForApi);
472
606
  appendLog(toolResultTokenLine(tokens, ok, extraIndent));
@@ -477,6 +611,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
477
611
  const runParallelBatch = async (batch) => {
478
612
  if (batch.length === 0)
479
613
  return;
614
+ setLoadingLabel(`Running ${batch.length} tools in parallel…`);
480
615
  const started = Date.now();
481
616
  const groupedTools = Array.from(batch.reduce((acc, planned) => {
482
617
  acc.set(planned.toolName, (acc.get(planned.toolName) ?? 0) + 1);
@@ -504,12 +639,11 @@ export function Repl({ apiKey, cwd, onQuit }) {
504
639
  continue;
505
640
  const toolName = block.name.trim().toLowerCase();
506
641
  const toolArgs = block.input;
507
- const firstVal = Object.values(toolArgs)[0];
508
642
  const planned = {
509
643
  block,
510
644
  toolName,
511
645
  toolArgs,
512
- argPreview: String(firstVal ?? "").slice(0, 100) || "—",
646
+ argPreview: toolArgPreview(toolName, toolArgs),
513
647
  };
514
648
  if (ENABLE_PARALLEL_TOOL_CALLS && PARALLEL_SAFE_TOOLS.has(toolName)) {
515
649
  parallelBatch.push(planned);
@@ -517,11 +651,11 @@ export function Repl({ apiKey, cwd, onQuit }) {
517
651
  }
518
652
  await runParallelBatch(parallelBatch);
519
653
  parallelBatch = [];
654
+ setLoadingLabel(`Running ${planned.toolName}…`);
520
655
  const result = await runTool(planned.toolName, planned.toolArgs);
521
656
  renderToolOutcome(planned, result);
522
657
  }
523
658
  await runParallelBatch(parallelBatch);
524
- setLoading(false);
525
659
  state = [...state, { role: "assistant", content: contentBlocks }];
526
660
  if (toolResults.length === 0) {
527
661
  setMessages(state);
@@ -530,6 +664,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
530
664
  state = [...state, { role: "user", content: toolResults }];
531
665
  setMessages(state);
532
666
  }
667
+ setLoading(false);
533
668
  return true;
534
669
  }, [apiKey, cwd, currentModel, messages, modelList, appendLog, openModelSelector, openBraveKeyModal, openHelpModal]);
535
670
  const handleSubmit = useCallback(async (value) => {
@@ -947,13 +1082,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
947
1082
  ? 4 + Math.max(1, filteredFilePaths.length)
948
1083
  : 0;
949
1084
  const suggestionBoxLines = slashSuggestionBoxLines || atSuggestionBoxLines;
950
- const wrapWidth = Math.max(10, termColumns - PROMPT_INDENT_LEN - 2);
951
- const inputLineCount = (() => {
952
- const lines = inputValue.split("\n");
953
- return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / wrapWidth)), 0);
954
- })();
955
1085
  // Keep a fixed loading row reserved to avoid viewport jumps/flicker when loading starts/stops.
956
- const reservedLines = 1 + inputLineCount + 2;
1086
+ const reservedLines = 1 + stableInputLineCount + 2;
957
1087
  const logViewportHeight = Math.max(1, termRows - reservedLines - suggestionBoxLines);
958
1088
  const effectiveLogLines = logLines;
959
1089
  const maxLogScrollOffset = Math.max(0, effectiveLogLines.length - logViewportHeight);
@@ -963,7 +1093,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
963
1093
  logStartIndex = 0;
964
1094
  }
965
1095
  const sliceEnd = logStartIndex + logViewportHeight;
966
- const visibleLogLines = effectiveLogLines.slice(logStartIndex, sliceEnd);
1096
+ const visibleLogLines = useMemo(() => effectiveLogLines.slice(logStartIndex, sliceEnd), [effectiveLogLines, logStartIndex, sliceEnd]);
1097
+ const useSimpleInputRenderer = inputLineCount > 1;
967
1098
  if (showHelpModal) {
968
1099
  const helpModalWidth = Math.min(88, Math.max(80, termColumns - 4));
969
1100
  const helpContentRows = 20;
@@ -986,17 +1117,14 @@ export function Repl({ apiKey, cwd, onQuit }) {
986
1117
  const leftPad = Math.max(0, Math.floor((termColumns - paletteModalWidth) / 2));
987
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 })] }));
988
1119
  }
989
- const footerLines = suggestionBoxLines + 1 + inputLineCount;
990
- return (_jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", height: logViewportHeight, overflow: "hidden", children: visibleLogLines.map((line, i) => (_jsx(Text, { children: line === "" ? "\u00A0" : line }, effectiveLogLines.length - visibleLogLines.length + i))) }), _jsx(Box, { flexDirection: "row", marginTop: 1, marginBottom: 0, children: _jsxs(Text, { color: inkColors.textSecondary, children: [" ", loading
991
- ? `${SPINNER[spinnerTick % SPINNER.length]} ${loadingLabel} ${((Date.now() - loadingStartedAtRef.current) /
992
- 1000).toFixed(1)}s`
993
- : "\u00A0"] }) })] }), _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) => {
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) => {
994
1122
  const i = filteredSlashCommands.length - 1 - rev;
995
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));
996
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) => {
997
1125
  const i = filteredFilePaths.length - 1 - rev;
998
1126
  return (_jsxs(Text, { color: i === clampedAtFileIndex ? inkColors.primary : undefined, children: [i === clampedAtFileIndex ? "› " : " ", p] }, p));
999
- })), _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..." })] })) : ((() => {
1000
1128
  const lines = inputValue.split("\n");
1001
1129
  let lineStart = 0;
1002
1130
  return (_jsx(_Fragment, { children: lines.flatMap((lineText, lineIdx) => {
@@ -1005,6 +1133,49 @@ export function Repl({ apiKey, cwd, onQuit }) {
1005
1133
  const cursorOffsetInLine = cursorOnThisLine ? inputCursor - lineStart : -1;
1006
1134
  const currentLineStart = lineStart;
1007
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
+ }
1008
1179
  const segments = parseAtSegments(lineText);
1009
1180
  let runIdx = 0;
1010
1181
  const segmentsWithStyle = [];
@@ -1068,7 +1239,9 @@ export function Repl({ apiKey, cwd, onQuit }) {
1068
1239
  : -1;
1069
1240
  const lineNodes = [];
1070
1241
  if (lineText === "" && v === 0 && cursorOnThisLine) {
1071
- lineNodes.push(_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor"));
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")));
1072
1245
  }
1073
1246
  else {
1074
1247
  let cursorRendered = false;
@@ -1088,7 +1261,12 @@ export function Repl({ apiKey, cwd, onQuit }) {
1088
1261
  const after = text.slice(segRel + 1);
1089
1262
  const usePath = "color" in seg.style && !!seg.style.color;
1090
1263
  lineNodes.push(_jsx(Text, { ...seg.style, children: before }, `${segIdx}-a`));
1091
- lineNodes.push(_jsx(Text, { inverse: true, color: usePath ? inkColors.path : inkColors.primary, bold: "bold" in seg.style && !!seg.style.bold, children: curChar }, `${segIdx}-b`));
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
+ }
1092
1270
  lineNodes.push(_jsx(Text, { ...seg.style, children: after }, `${segIdx}-c`));
1093
1271
  cursorRendered = true;
1094
1272
  }
@@ -1101,7 +1279,9 @@ export function Repl({ apiKey, cwd, onQuit }) {
1101
1279
  }
1102
1280
  });
1103
1281
  if (cursorPosInVisual >= 0 && !cursorRendered) {
1104
- lineNodes.push(_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor-end"));
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")));
1105
1285
  }
1106
1286
  }
1107
1287
  const isFirstRow = lineIdx === 0 && v === 0;
@@ -19,8 +19,10 @@ export function readFile(args) {
19
19
  suffix);
20
20
  }
21
21
  export function writeFile(args) {
22
- fs.writeFileSync(args.path, args.content, "utf-8");
23
- return "ok";
22
+ const content = args.content;
23
+ fs.writeFileSync(args.path, content, "utf-8");
24
+ const added = content === "" ? 0 : content.split("\n").length;
25
+ return `ok (+${added})`;
24
26
  }
25
27
  export function editFile(args) {
26
28
  const text = fs.readFileSync(args.path, "utf-8");
@@ -39,5 +41,9 @@ export function editFile(args) {
39
41
  }
40
42
  const replacement = args.all ? text.split(oldStr).join(newStr) : text.replace(oldStr, newStr);
41
43
  fs.writeFileSync(args.path, replacement, "utf-8");
42
- return "ok";
44
+ const oldLines = oldStr === "" ? 0 : oldStr.split("\n").length;
45
+ const newLines = newStr === "" ? 0 : newStr.split("\n").length;
46
+ const removed = oldLines * count;
47
+ const added = newLines * count;
48
+ return `ok (+${added} -${removed})`;
43
49
  }
package/dist/ui/format.js CHANGED
@@ -308,14 +308,17 @@ export function userPromptBox(prompt) {
308
308
  }
309
309
  const TOOL_INDENT = " ";
310
310
  const toolSubdued = chalk.gray;
311
- export function toolCallBox(toolName, argPreview, success = true, extraIndent = 0) {
311
+ export function toolCallBox(toolName, argPreview, success = true, extraIndent = 0, editDelta) {
312
312
  const indent = TOOL_INDENT + " ".repeat(Math.max(0, extraIndent));
313
313
  const diamondColor = success ? colors.toolSuccess : colors.toolFail;
314
314
  const nameColor = diamondColor;
315
315
  const argColor = success ? toolSubdued : colors.toolFail;
316
316
  const parenColor = colors.mutedDark;
317
317
  const name = " " + toolName.trim().toLowerCase();
318
- return `${indent}${diamondColor(icons.tool)}${nameColor(name)}${parenColor("(")}${argColor(argPreview)}${parenColor(")")}`;
318
+ const delta = editDelta != null
319
+ ? ` ${colors.toolSuccess(`+${editDelta.added}`)} ${colors.toolFail(`-${editDelta.removed}`)}`
320
+ : "";
321
+ return `${indent}${diamondColor(icons.tool)}${nameColor(name)}${parenColor("(")}${argColor(argPreview)}${parenColor(")")}${delta}`;
319
322
  }
320
323
  export function toolResultLine(preview, success = true) {
321
324
  const pipeColor = success ? toolSubdued : colors.toolFail;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ideacode",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "CLI TUI for AI agents via OpenRouter — agentic loop, tools, markdown",
5
5
  "type": "module",
6
6
  "repository": {