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 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
@@ -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 = 90;
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 fragments = cmdRaw
67
- .split(/\n|&&|;/g)
67
+ const parts = cmdRaw
68
+ .split(/\n|&&|;|\|/g)
68
69
  .map((s) => s.trim())
69
70
  .filter(Boolean);
70
- const useful = fragments.filter((f) => !/^echo\s+["']?[=\-#]/i.test(f) &&
71
- !/^cat\s+<<\s*['"]?EOF/i.test(f) &&
72
- f.toUpperCase() !== "EOF");
73
- const source = useful.length > 0 ? useful : fragments;
74
- const shown = source.slice(0, 3);
75
- const suffix = source.length > 3 ? ` ; … +${source.length - 3}` : "";
76
- const joined = shown.join(" ; ") + suffix;
77
- return joined.slice(0, 140);
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 shimmerLabel(label, frame) {
200
- if (!label)
201
- return "";
202
- const width = Math.max(4, Math.min(10, Math.floor(label.length / 3)));
203
- const travel = Math.max(1, label.length - width);
204
- const period = travel * 2;
205
- const phase = frame % period;
206
- const head = phase <= travel ? phase : period - phase;
207
- let out = "";
208
- for (let i = 0; i < label.length; i++) {
209
- const inWindow = i >= head && i < head + width;
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 [startedAt, setStartedAt] = useState(0);
237
+ const startedAtRef = useRef(null);
217
238
  useEffect(() => {
218
- if (!active)
239
+ if (!active) {
240
+ startedAtRef.current = null;
219
241
  return;
220
- setStartedAt(Date.now());
221
- setFrame(0);
222
- const t = setInterval(() => setFrame((n) => n + 1), LOADING_TICK_MS);
223
- return () => clearInterval(t);
224
- }, [active, label]);
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 elapsedSec = Math.max(0, (Date.now() - startedAt) / 1000);
228
- return (_jsxs(Text, { color: inkColors.textSecondary, children: [" ", shimmerLabel(label, frame), " ", colors.gray(`${elapsedSec.toFixed(1)}s`)] }));
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
- 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);
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: 6,
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 + inputLineCount + 2;
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 + inputLineCount;
1047
- 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: _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) => {
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(_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")));
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
- 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
+ }
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(_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")));
1159
1285
  }
1160
1286
  }
1161
1287
  const isFirstRow = lineIdx === 0 && v === 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ideacode",
3
- "version": "1.2.2",
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": {