ideacode 1.2.2 → 1.2.4

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,8 @@ 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;
44
+ const MAX_EMPTY_ASSISTANT_RETRIES = 3;
43
45
  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
46
  function truncateToolResult(content) {
45
47
  if (content.length <= MAX_TOOL_RESULT_CHARS)
@@ -62,19 +64,90 @@ function listFilesWithFilter(cwd, filter) {
62
64
  return [];
63
65
  }
64
66
  }
67
+ function stripHeredocBodies(cmdRaw) {
68
+ const lines = cmdRaw.replace(/\r\n/g, "\n").split("\n");
69
+ const out = [];
70
+ let i = 0;
71
+ while (i < lines.length) {
72
+ const line = lines[i] ?? "";
73
+ out.push(line);
74
+ const markerMatch = line.match(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/);
75
+ if (!markerMatch) {
76
+ i += 1;
77
+ continue;
78
+ }
79
+ const marker = markerMatch[2] ?? "";
80
+ i += 1;
81
+ while (i < lines.length) {
82
+ const bodyLine = lines[i] ?? "";
83
+ if (bodyLine.trim() === marker) {
84
+ out.push(bodyLine);
85
+ break;
86
+ }
87
+ i += 1;
88
+ }
89
+ i += 1;
90
+ }
91
+ return out.join("\n");
92
+ }
65
93
  function summarizeBashCommand(cmdRaw) {
66
- const fragments = cmdRaw
67
- .split(/\n|&&|;/g)
94
+ const sanitized = stripHeredocBodies(cmdRaw);
95
+ const parts = sanitized
96
+ .split(/\n|&&|\|\||;|\|/g)
68
97
  .map((s) => s.trim())
69
98
  .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);
99
+ const skipTokens = new Set([
100
+ "if",
101
+ "then",
102
+ "else",
103
+ "elif",
104
+ "fi",
105
+ "for",
106
+ "while",
107
+ "do",
108
+ "done",
109
+ "case",
110
+ "esac",
111
+ "in",
112
+ "function",
113
+ "{",
114
+ "}",
115
+ "(",
116
+ ")",
117
+ ]);
118
+ const commands = [];
119
+ for (const part of parts) {
120
+ let s = part.replace(/^\(+/, "").trim();
121
+ if (!s || s.toUpperCase() === "EOF")
122
+ continue;
123
+ // Strip simple environment assignments at the front: FOO=bar CMD
124
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(s)) {
125
+ const idx = s.indexOf(" ");
126
+ if (idx === -1) {
127
+ s = "";
128
+ break;
129
+ }
130
+ s = s.slice(idx + 1).trim();
131
+ }
132
+ if (!s)
133
+ continue;
134
+ const token = (s.split(/\s+/)[0] ?? "").replace(/^['"]|['"]$/g, "").toLowerCase();
135
+ if (!/^[A-Za-z0-9_./-]+$/.test(token))
136
+ continue;
137
+ if (skipTokens.has(token))
138
+ continue;
139
+ if (token === "echo")
140
+ continue;
141
+ if (token === "cat" && /<<\s*['"]?EOF/i.test(s))
142
+ continue;
143
+ if (!commands.includes(token))
144
+ commands.push(token);
145
+ }
146
+ if (commands.length === 0)
147
+ return "bash";
148
+ const shown = commands.slice(0, 5);
149
+ const suffix = commands.length > 5 ? `, +${commands.length - 5}` : "";
150
+ return (shown.join(", ") + suffix).slice(0, 140);
78
151
  }
79
152
  function toolArgPreview(toolName, toolArgs) {
80
153
  if (toolName === "bash") {
@@ -196,36 +269,41 @@ function useTerminalSize() {
196
269
  }, [stdout]);
197
270
  return size;
198
271
  }
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;
272
+ const LogViewport = React.memo(function LogViewport({ lines, startIndex, height, }) {
273
+ return (_jsx(Box, { flexDirection: "column", height: height, overflow: "hidden", children: lines.map((line, i) => (_jsx(Text, { children: line === "" ? "\u00A0" : line }, startIndex + i))) }));
274
+ });
275
+ function orbitDots(frame) {
276
+ const phase = frame % 6;
277
+ const activeIndex = phase <= 3 ? phase : 6 - phase;
278
+ const slots = ["·", "·", "·", "·"];
279
+ slots[activeIndex] = "●";
280
+ return slots
281
+ .map((ch, i) => (i === activeIndex ? colors.gray(ch) : colors.mutedDark(ch)))
282
+ .join("");
213
283
  }
214
284
  const LoadingStatus = React.memo(function LoadingStatus({ active, label, }) {
215
285
  const [frame, setFrame] = useState(0);
216
- const [startedAt, setStartedAt] = useState(0);
286
+ const startedAtRef = useRef(null);
217
287
  useEffect(() => {
218
- if (!active)
288
+ if (!active) {
289
+ startedAtRef.current = null;
219
290
  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]);
291
+ }
292
+ if (startedAtRef.current == null) {
293
+ startedAtRef.current = Date.now();
294
+ setFrame(0);
295
+ }
296
+ const anim = setInterval(() => setFrame((n) => n + 1), LOADING_TICK_MS);
297
+ return () => {
298
+ clearInterval(anim);
299
+ };
300
+ }, [active]);
225
301
  if (!active)
226
302
  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`)] }));
303
+ const startedAt = startedAtRef.current ?? Date.now();
304
+ const elapsedSeconds = Math.max(0, (Date.now() - startedAt) / 1000);
305
+ const elapsedText = elapsedSeconds < 10 ? `${elapsedSeconds.toFixed(1)}s` : `${Math.floor(elapsedSeconds)}s`;
306
+ return (_jsxs(Text, { color: inkColors.textSecondary, children: [" ", orbitDots(frame), " ", colors.gray(label), " ", colors.gray(elapsedText)] }));
229
307
  });
230
308
  export function Repl({ apiKey, cwd, onQuit }) {
231
309
  const { rows: termRows, columns: termColumns } = useTerminalSize();
@@ -258,6 +336,10 @@ export function Repl({ apiKey, cwd, onQuit }) {
258
336
  }
259
337
  return banner;
260
338
  });
339
+ const logLinesRef = useRef(logLines);
340
+ useEffect(() => {
341
+ logLinesRef.current = logLines;
342
+ }, [logLines]);
261
343
  const [inputValue, setInputValue] = useState("");
262
344
  const [currentModel, setCurrentModel] = useState(getModel);
263
345
  const [messages, setMessages] = useState(() => loadConversation(cwd));
@@ -305,10 +387,23 @@ export function Repl({ apiKey, cwd, onQuit }) {
305
387
  if (saveDebounceRef.current)
306
388
  clearTimeout(saveDebounceRef.current);
307
389
  saveConversation(cwd, messagesRef.current);
308
- onQuit();
390
+ // Best-effort terminal mode reset in case process exits before React cleanup runs.
391
+ try {
392
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
393
+ process.stdin.setRawMode(false);
394
+ }
395
+ if (process.stdout.isTTY) {
396
+ writeSync(process.stdout.fd, "\x1b[?2004l\x1b[?1004l\x1b[?1007l\x1b[?1015l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?25h\x1b[0m");
397
+ }
398
+ }
399
+ catch {
400
+ // Ignore restore failures during teardown.
401
+ }
402
+ onQuit(logLinesRef.current);
309
403
  }, [cwd, onQuit]);
310
404
  const [loading, setLoading] = useState(false);
311
405
  const [loadingLabel, setLoadingLabel] = useState("Thinking…");
406
+ const cursorBlinkOn = true;
312
407
  const [showPalette, setShowPalette] = useState(false);
313
408
  const [paletteIndex, setPaletteIndex] = useState(0);
314
409
  const [showModelSelector, setShowModelSelector] = useState(false);
@@ -327,6 +422,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
327
422
  const scrollBoundsRef = useRef({ maxLogScrollOffset: 0, logViewportHeight: 1 });
328
423
  const prevEscRef = useRef(false);
329
424
  useEffect(() => {
425
+ // Enable SGR mouse + basic tracking so trackpad wheel scrolling works.
330
426
  process.stdout.write("\x1b[?1006h\x1b[?1000h");
331
427
  return () => {
332
428
  process.stdout.write("\x1b[?1006l\x1b[?1000l");
@@ -345,6 +441,20 @@ export function Repl({ apiKey, cwd, onQuit }) {
345
441
  return modelList.filter((m) => m.id.toLowerCase().includes(q) ||
346
442
  (m.name ?? "").toLowerCase().includes(q));
347
443
  }, [modelList, modelSearchFilter]);
444
+ const wrapWidth = Math.max(10, termColumns - PROMPT_INDENT_LEN - 2);
445
+ const inputLineCount = useMemo(() => {
446
+ const lines = inputValue.split("\n");
447
+ return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / wrapWidth)), 0);
448
+ }, [inputValue, wrapWidth]);
449
+ const [stableInputLineCount, setStableInputLineCount] = useState(inputLineCount);
450
+ useEffect(() => {
451
+ if (inputLineCount <= 1) {
452
+ setStableInputLineCount(1);
453
+ return;
454
+ }
455
+ const t = setTimeout(() => setStableInputLineCount(inputLineCount), 90);
456
+ return () => clearTimeout(t);
457
+ }, [inputLineCount]);
348
458
  useEffect(() => {
349
459
  setInputCursor((c) => Math.min(c, Math.max(0, inputValue.length)));
350
460
  }, [inputValue.length]);
@@ -512,15 +622,36 @@ export function Repl({ apiKey, cwd, onQuit }) {
512
622
  setLoading(true);
513
623
  state = await ensureUnderBudget(apiKey, state, systemPrompt, currentModel, {
514
624
  maxTokens: maxContextTokens,
515
- keepLast: 6,
625
+ keepLast: 8,
516
626
  });
517
627
  if (state.length < stateBeforeCompress.length) {
518
628
  appendLog(colors.muted(" (context compressed to stay under limit)\n"));
519
629
  }
630
+ setLoadingLabel("Thinking…");
631
+ let emptyAssistantRetries = 0;
520
632
  for (;;) {
633
+ setLoading(true);
521
634
  setLoadingLabel("Thinking…");
522
- const response = await callApi(apiKey, state, systemPrompt, currentModel);
635
+ const response = await callApi(apiKey, state, systemPrompt, currentModel, {
636
+ onRetry: ({ attempt, maxAttempts, waitMs, status }) => {
637
+ setLoadingLabel(`Rate limited (${status}), retry ${attempt}/${maxAttempts} in ${(waitMs / 1000).toFixed(1)}s…`);
638
+ },
639
+ });
523
640
  const contentBlocks = response.content ?? [];
641
+ const hasMeaningfulAssistantOutput = contentBlocks.some((block) => block.type === "tool_use" || (block.type === "text" && !!block.text?.trim()));
642
+ if (!hasMeaningfulAssistantOutput) {
643
+ emptyAssistantRetries += 1;
644
+ if (emptyAssistantRetries <= MAX_EMPTY_ASSISTANT_RETRIES) {
645
+ setLoadingLabel(`No output yet, retrying ${emptyAssistantRetries}/${MAX_EMPTY_ASSISTANT_RETRIES}…`);
646
+ appendLog(colors.muted(` ${icons.tool} model returned an empty turn, retrying (${emptyAssistantRetries}/${MAX_EMPTY_ASSISTANT_RETRIES})…`));
647
+ continue;
648
+ }
649
+ appendLog(colors.error(`${icons.error} model returned empty output repeatedly. Stopping this turn; you can submit "continue" to resume.`));
650
+ appendLog("");
651
+ setMessages(state);
652
+ break;
653
+ }
654
+ emptyAssistantRetries = 0;
524
655
  const toolResults = [];
525
656
  const renderToolOutcome = (planned, result, extraIndent = 0) => {
526
657
  const ok = !result.startsWith("error:");
@@ -535,6 +666,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
535
666
  const runParallelBatch = async (batch) => {
536
667
  if (batch.length === 0)
537
668
  return;
669
+ setLoadingLabel(`Running ${batch.length} tools in parallel…`);
538
670
  const started = Date.now();
539
671
  const groupedTools = Array.from(batch.reduce((acc, planned) => {
540
672
  acc.set(planned.toolName, (acc.get(planned.toolName) ?? 0) + 1);
@@ -574,11 +706,11 @@ export function Repl({ apiKey, cwd, onQuit }) {
574
706
  }
575
707
  await runParallelBatch(parallelBatch);
576
708
  parallelBatch = [];
709
+ setLoadingLabel(`Running ${planned.toolName}…`);
577
710
  const result = await runTool(planned.toolName, planned.toolArgs);
578
711
  renderToolOutcome(planned, result);
579
712
  }
580
713
  await runParallelBatch(parallelBatch);
581
- setLoading(false);
582
714
  state = [...state, { role: "assistant", content: contentBlocks }];
583
715
  if (toolResults.length === 0) {
584
716
  setMessages(state);
@@ -587,6 +719,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
587
719
  state = [...state, { role: "user", content: toolResults }];
588
720
  setMessages(state);
589
721
  }
722
+ setLoading(false);
590
723
  return true;
591
724
  }, [apiKey, cwd, currentModel, messages, modelList, appendLog, openModelSelector, openBraveKeyModal, openHelpModal]);
592
725
  const handleSubmit = useCallback(async (value) => {
@@ -1004,13 +1137,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
1004
1137
  ? 4 + Math.max(1, filteredFilePaths.length)
1005
1138
  : 0;
1006
1139
  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
1140
  // Keep a fixed loading row reserved to avoid viewport jumps/flicker when loading starts/stops.
1013
- const reservedLines = 1 + inputLineCount + 2;
1141
+ const reservedLines = 1 + stableInputLineCount + 2;
1014
1142
  const logViewportHeight = Math.max(1, termRows - reservedLines - suggestionBoxLines);
1015
1143
  const effectiveLogLines = logLines;
1016
1144
  const maxLogScrollOffset = Math.max(0, effectiveLogLines.length - logViewportHeight);
@@ -1020,7 +1148,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
1020
1148
  logStartIndex = 0;
1021
1149
  }
1022
1150
  const sliceEnd = logStartIndex + logViewportHeight;
1023
- const visibleLogLines = effectiveLogLines.slice(logStartIndex, sliceEnd);
1151
+ const visibleLogLines = useMemo(() => effectiveLogLines.slice(logStartIndex, sliceEnd), [effectiveLogLines, logStartIndex, sliceEnd]);
1152
+ const useSimpleInputRenderer = inputLineCount > 1;
1024
1153
  if (showHelpModal) {
1025
1154
  const helpModalWidth = Math.min(88, Math.max(80, termColumns - 4));
1026
1155
  const helpContentRows = 20;
@@ -1043,14 +1172,14 @@ export function Repl({ apiKey, cwd, onQuit }) {
1043
1172
  const leftPad = Math.max(0, Math.floor((termColumns - paletteModalWidth) / 2));
1044
1173
  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
1174
  }
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) => {
1175
+ const footerLines = suggestionBoxLines + 1 + stableInputLineCount;
1176
+ 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
1177
  const i = filteredSlashCommands.length - 1 - rev;
1049
1178
  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
1179
  })), _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
1180
  const i = filteredFilePaths.length - 1 - rev;
1052
1181
  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..." })] })) : ((() => {
1182
+ })), _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
1183
  const lines = inputValue.split("\n");
1055
1184
  let lineStart = 0;
1056
1185
  return (_jsx(_Fragment, { children: lines.flatMap((lineText, lineIdx) => {
@@ -1059,6 +1188,49 @@ export function Repl({ apiKey, cwd, onQuit }) {
1059
1188
  const cursorOffsetInLine = cursorOnThisLine ? inputCursor - lineStart : -1;
1060
1189
  const currentLineStart = lineStart;
1061
1190
  lineStart = lineEnd + 1;
1191
+ if (useSimpleInputRenderer) {
1192
+ const visualLines = wrapLine(lineText, wrapWidth);
1193
+ return visualLines.map((visualChunk, v) => {
1194
+ const visualStart = v * wrapWidth;
1195
+ const visualEnd = Math.min((v + 1) * wrapWidth, lineText.length);
1196
+ const isLastVisualOfThisLine = v === visualLines.length - 1;
1197
+ const cursorAtEndOfVisual = isLastVisualOfThisLine && cursorOffsetInLine === visualEnd;
1198
+ const cursorPosInVisual = cursorOnThisLine &&
1199
+ cursorOffsetInLine >= visualStart &&
1200
+ (cursorOffsetInLine < visualEnd || cursorAtEndOfVisual)
1201
+ ? cursorOffsetInLine < visualEnd
1202
+ ? cursorOffsetInLine - visualStart
1203
+ : visualEnd - visualStart
1204
+ : -1;
1205
+ const isFirstRow = lineIdx === 0 && v === 0;
1206
+ const isLastLogicalLine = lineIdx === lines.length - 1;
1207
+ const isLastVisualOfLine = v === visualLines.length - 1;
1208
+ const rowNodes = [];
1209
+ if (lineText === "" && v === 0 && cursorOnThisLine) {
1210
+ rowNodes.push(cursorBlinkOn
1211
+ ? (_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor-empty-on"))
1212
+ : (_jsx(Text, { color: inkColors.primary, children: "\u00A0" }, "cursor-empty-off")));
1213
+ }
1214
+ else if (cursorPosInVisual >= 0) {
1215
+ const before = visualChunk.slice(0, cursorPosInVisual);
1216
+ const curChar = cursorPosInVisual < visualChunk.length
1217
+ ? visualChunk[cursorPosInVisual]
1218
+ : "\u00A0";
1219
+ const after = cursorPosInVisual < visualChunk.length
1220
+ ? visualChunk.slice(cursorPosInVisual + 1)
1221
+ : "";
1222
+ rowNodes.push(_jsx(Text, { children: before }, "plain-before"));
1223
+ rowNodes.push(cursorBlinkOn
1224
+ ? (_jsx(Text, { inverse: true, color: inkColors.primary, children: curChar }, "plain-caret-on"))
1225
+ : (_jsx(Text, { children: curChar }, "plain-caret-off")));
1226
+ rowNodes.push(_jsx(Text, { children: after }, "plain-after"));
1227
+ }
1228
+ else {
1229
+ rowNodes.push(_jsx(Text, { children: visualChunk }, "plain"));
1230
+ }
1231
+ 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}`));
1232
+ });
1233
+ }
1062
1234
  const segments = parseAtSegments(lineText);
1063
1235
  let runIdx = 0;
1064
1236
  const segmentsWithStyle = [];
@@ -1122,7 +1294,9 @@ export function Repl({ apiKey, cwd, onQuit }) {
1122
1294
  : -1;
1123
1295
  const lineNodes = [];
1124
1296
  if (lineText === "" && v === 0 && cursorOnThisLine) {
1125
- lineNodes.push(_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor"));
1297
+ lineNodes.push(cursorBlinkOn
1298
+ ? (_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor-on"))
1299
+ : (_jsx(Text, { color: inkColors.primary, children: "\u00A0" }, "cursor-off")));
1126
1300
  }
1127
1301
  else {
1128
1302
  let cursorRendered = false;
@@ -1142,7 +1316,12 @@ export function Repl({ apiKey, cwd, onQuit }) {
1142
1316
  const after = text.slice(segRel + 1);
1143
1317
  const usePath = "color" in seg.style && !!seg.style.color;
1144
1318
  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`));
1319
+ if (cursorBlinkOn) {
1320
+ 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`));
1321
+ }
1322
+ else {
1323
+ lineNodes.push(_jsx(Text, { ...seg.style, children: curChar }, `${segIdx}-b-off`));
1324
+ }
1146
1325
  lineNodes.push(_jsx(Text, { ...seg.style, children: after }, `${segIdx}-c`));
1147
1326
  cursorRendered = true;
1148
1327
  }
@@ -1155,7 +1334,9 @@ export function Repl({ apiKey, cwd, onQuit }) {
1155
1334
  }
1156
1335
  });
1157
1336
  if (cursorPosInVisual >= 0 && !cursorRendered) {
1158
- lineNodes.push(_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor-end"));
1337
+ lineNodes.push(cursorBlinkOn
1338
+ ? (_jsx(Text, { inverse: true, color: inkColors.primary, children: "\u00A0" }, "cursor-end-on"))
1339
+ : (_jsx(Text, { color: inkColors.primary, children: "\u00A0" }, "cursor-end-off")));
1159
1340
  }
1160
1341
  }
1161
1342
  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.4",
4
4
  "description": "CLI TUI for AI agents via OpenRouter — agentic loop, tools, markdown",
5
5
  "type": "module",
6
6
  "repository": {