ideacode 1.1.9 → 1.2.1

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/repl.js CHANGED
@@ -37,6 +37,9 @@ const CONTEXT_WINDOW_K = 128;
37
37
  const MAX_TOOL_RESULT_CHARS = 3500;
38
38
  const MAX_AT_SUGGESTIONS = 12;
39
39
  const INITIAL_BANNER_LINES = 12;
40
+ const ENABLE_PARALLEL_TOOL_CALLS = process.env.IDEACODE_PARALLEL_TOOL_CALLS !== "0";
41
+ 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));
40
43
  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.)";
41
44
  function truncateToolResult(content) {
42
45
  if (content.length <= MAX_TOOL_RESULT_CHARS)
@@ -88,12 +91,19 @@ function wrapLine(line, width) {
88
91
  return out.length > 0 ? out : [""];
89
92
  }
90
93
  function replayMessagesToLogLines(messages) {
94
+ const sanitizePrompt = (value) => value
95
+ .replace(/\x1b\[[0-9;]*m/g, "")
96
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
97
+ .trim();
91
98
  const lines = [];
92
99
  for (let i = 0; i < messages.length; i++) {
93
100
  const msg = messages[i];
94
101
  if (msg.role === "user") {
95
102
  if (typeof msg.content === "string") {
96
- lines.push("", ...userPromptBox(msg.content).split("\n"), "");
103
+ const prompt = sanitizePrompt(msg.content);
104
+ if (!prompt)
105
+ continue;
106
+ lines.push("", ...userPromptBox(prompt).split("\n"), "");
97
107
  }
98
108
  else if (Array.isArray(msg.content)) {
99
109
  const prev = messages[i - 1];
@@ -110,8 +120,6 @@ function replayMessagesToLogLines(messages) {
110
120
  const content = tr.content ?? "";
111
121
  const ok = !content.startsWith("error:");
112
122
  lines.push(toolCallBox(name, argPreview, ok));
113
- const preview = content.split("\n")[0]?.slice(0, 60) ?? "";
114
- lines.push(toolResultLine(preview, ok));
115
123
  const tokens = estimateTokensForString(content);
116
124
  lines.push(toolResultTokenLine(tokens, ok));
117
125
  }
@@ -231,6 +239,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
231
239
  onQuit();
232
240
  }, [cwd, onQuit]);
233
241
  const [loading, setLoading] = useState(false);
242
+ const [loadingLabel, setLoadingLabel] = useState("Thinking…");
234
243
  const [showPalette, setShowPalette] = useState(false);
235
244
  const [paletteIndex, setPaletteIndex] = useState(0);
236
245
  const [showModelSelector, setShowModelSelector] = useState(false);
@@ -255,7 +264,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
255
264
  };
256
265
  }, []);
257
266
  const [spinnerTick, setSpinnerTick] = useState(0);
258
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
267
+ const loadingStartedAtRef = useRef(0);
268
+ const SPINNER = ["●○○", "○●○", "○○●", "○●○"];
259
269
  const estimatedTokens = useMemo(() => estimateTokens(messages, undefined), [messages]);
260
270
  const contextWindowK = useMemo(() => {
261
271
  const ctx = modelList.find((m) => m.id === currentModel)?.context_length;
@@ -289,7 +299,9 @@ export function Repl({ apiKey, cwd, onQuit }) {
289
299
  useEffect(() => {
290
300
  if (!loading)
291
301
  return;
292
- const t = setInterval(() => setSpinnerTick((n) => n + 1), 80);
302
+ loadingStartedAtRef.current = Date.now();
303
+ setSpinnerTick(0);
304
+ const t = setInterval(() => setSpinnerTick((n) => n + 1), LOADING_TICK_MS);
293
305
  return () => clearInterval(t);
294
306
  }, [loading]);
295
307
  const showSlashSuggestions = inputValue.startsWith("/");
@@ -434,10 +446,12 @@ export function Repl({ apiKey, cwd, onQuit }) {
434
446
  appendLog(userPromptBox(userInput));
435
447
  appendLog("");
436
448
  let state = [...messages, { role: "user", content: userInput }];
437
- 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.`;
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.`;
438
450
  const modelContext = modelList.find((m) => m.id === currentModel)?.context_length;
439
451
  const maxContextTokens = Math.floor((modelContext ?? CONTEXT_WINDOW_K * 1024) * 0.85);
440
452
  const stateBeforeCompress = state;
453
+ setLoadingLabel("Compressing context…");
454
+ setLoading(true);
441
455
  state = await ensureUnderBudget(apiKey, state, systemPrompt, currentModel, {
442
456
  maxTokens: maxContextTokens,
443
457
  keepLast: 6,
@@ -446,38 +460,67 @@ export function Repl({ apiKey, cwd, onQuit }) {
446
460
  appendLog(colors.muted(" (context compressed to stay under limit)\n"));
447
461
  }
448
462
  for (;;) {
449
- setLoading(true);
463
+ setLoadingLabel("Thinking…");
450
464
  const response = await callApi(apiKey, state, systemPrompt, currentModel);
451
465
  const contentBlocks = response.content ?? [];
452
466
  const toolResults = [];
467
+ const renderToolOutcome = (planned, result, extraIndent = 0) => {
468
+ const ok = !result.startsWith("error:");
469
+ appendLog(toolCallBox(planned.toolName, planned.argPreview, ok, extraIndent));
470
+ const contentForApi = truncateToolResult(result);
471
+ const tokens = estimateTokensForString(contentForApi);
472
+ appendLog(toolResultTokenLine(tokens, ok, extraIndent));
473
+ if (planned.block.id) {
474
+ toolResults.push({ type: "tool_result", tool_use_id: planned.block.id, content: contentForApi });
475
+ }
476
+ };
477
+ const runParallelBatch = async (batch) => {
478
+ if (batch.length === 0)
479
+ return;
480
+ const started = Date.now();
481
+ const groupedTools = Array.from(batch.reduce((acc, planned) => {
482
+ acc.set(planned.toolName, (acc.get(planned.toolName) ?? 0) + 1);
483
+ return acc;
484
+ }, new Map()))
485
+ .map(([name, count]) => (count > 1 ? `${name}×${count}` : name))
486
+ .join(", ");
487
+ appendLog(colors.gray(` ${icons.tool} parallel batch (${batch.length}): ${groupedTools}`));
488
+ const settled = await Promise.all(batch.map(async (planned) => ({ planned, result: await runTool(planned.toolName, planned.toolArgs) })));
489
+ const elapsed = Date.now() - started;
490
+ appendLog(colors.gray(` completed in ${elapsed}ms`));
491
+ for (const { planned, result } of settled) {
492
+ renderToolOutcome(planned, result, 1);
493
+ }
494
+ };
495
+ let parallelBatch = [];
453
496
  for (const block of contentBlocks) {
454
497
  if (block.type === "text" && block.text?.trim()) {
455
498
  if (lastLogLineRef.current !== "")
456
499
  appendLog("");
457
500
  appendLog(agentMessage(block.text).trimEnd());
501
+ continue;
458
502
  }
459
- if (block.type === "tool_use" && block.name && block.input) {
460
- const toolName = block.name.trim().toLowerCase();
461
- const toolArgs = block.input;
462
- const firstVal = Object.values(toolArgs)[0];
463
- const argPreview = String(firstVal ?? "").slice(0, 100) || "—";
464
- const result = await runTool(toolName, toolArgs);
465
- const ok = !result.startsWith("error:");
466
- appendLog(toolCallBox(toolName, argPreview, ok));
467
- const resultLines = result.split("\n");
468
- let preview = resultLines[0]?.slice(0, 80) ?? "";
469
- if (resultLines.length > 1)
470
- preview += ` ... +${resultLines.length - 1} lines`;
471
- else if (preview.length > 80)
472
- preview += "...";
473
- appendLog(toolResultLine(preview, ok));
474
- const contentForApi = truncateToolResult(result);
475
- const tokens = estimateTokensForString(contentForApi);
476
- appendLog(toolResultTokenLine(tokens, ok));
477
- if (block.id)
478
- toolResults.push({ type: "tool_result", tool_use_id: block.id, content: contentForApi });
503
+ if (block.type !== "tool_use" || !block.name || !block.input)
504
+ continue;
505
+ const toolName = block.name.trim().toLowerCase();
506
+ const toolArgs = block.input;
507
+ const firstVal = Object.values(toolArgs)[0];
508
+ const planned = {
509
+ block,
510
+ toolName,
511
+ toolArgs,
512
+ argPreview: String(firstVal ?? "").slice(0, 100) || "",
513
+ };
514
+ if (ENABLE_PARALLEL_TOOL_CALLS && PARALLEL_SAFE_TOOLS.has(toolName)) {
515
+ parallelBatch.push(planned);
516
+ continue;
479
517
  }
518
+ await runParallelBatch(parallelBatch);
519
+ parallelBatch = [];
520
+ const result = await runTool(planned.toolName, planned.toolArgs);
521
+ renderToolOutcome(planned, result);
480
522
  }
523
+ await runParallelBatch(parallelBatch);
481
524
  setLoading(false);
482
525
  state = [...state, { role: "assistant", content: contentBlocks }];
483
526
  if (toolResults.length === 0) {
@@ -892,10 +935,10 @@ export function Repl({ apiKey, cwd, onQuit }) {
892
935
  const visibleModelCount = Math.min(filteredModelList.length, modelModalHeight - 4);
893
936
  const modelScrollOffset = Math.max(0, Math.min(modelIndex - Math.floor(visibleModelCount / 2), filteredModelList.length - visibleModelCount));
894
937
  const visibleModels = filteredModelList.slice(modelScrollOffset, modelScrollOffset + visibleModelCount);
895
- 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: modelModalWidth, minHeight: modelModalHeight, children: [_jsx(Text, { bold: true, children: " Select model " }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "gray", children: " Filter: " }), _jsx(Text, { children: modelSearchFilter || " " }), modelSearchFilter.length > 0 && (_jsxs(Text, { dimColor: true, color: "gray", children: [" ", "(", filteredModelList.length, " match", filteredModelList.length !== 1 ? "es" : "", ")"] }))] }), visibleModels.length === 0 ? (_jsx(Text, { color: "gray", children: " No match \u2014 type to search by id or name " })) : (visibleModels.map((m, i) => {
938
+ 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: modelModalWidth, minHeight: modelModalHeight, children: [_jsx(Text, { bold: true, children: " Select model " }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: inkColors.textSecondary, children: " Filter: " }), _jsx(Text, { children: modelSearchFilter || " " }), modelSearchFilter.length > 0 && (_jsxs(Text, { color: inkColors.textDisabled, children: [" ", "(", filteredModelList.length, " match", filteredModelList.length !== 1 ? "es" : "", ")"] }))] }), visibleModels.length === 0 ? (_jsx(Text, { color: inkColors.textSecondary, children: " No match \u2014 type to search by id or name " })) : (visibleModels.map((m, i) => {
896
939
  const actualIndex = modelScrollOffset + i;
897
940
  return (_jsxs(Text, { color: actualIndex === modelIndex ? inkColors.primary : undefined, children: [actualIndex === modelIndex ? "› " : " ", m.name ? `${m.id} — ${m.name}` : m.id] }, m.id));
898
- })), _jsx(Text, { color: "gray", children: " \u2191/\u2193 select Enter confirm Esc cancel Type to filter " })] })] }), _jsx(Box, { flexGrow: 1 })] }));
941
+ })), _jsx(Text, { color: inkColors.textSecondary, children: " \u2191/\u2193 select Enter confirm Esc cancel Type to filter " })] })] }), _jsx(Box, { flexGrow: 1 })] }));
899
942
  }
900
943
  const slashSuggestionBoxLines = showSlashSuggestions
901
944
  ? 3 + Math.max(1, filteredSlashCommands.length)
@@ -909,7 +952,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
909
952
  const lines = inputValue.split("\n");
910
953
  return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / wrapWidth)), 0);
911
954
  })();
912
- const reservedLines = 1 + inputLineCount + (loading ? 2 : 1);
955
+ // Keep a fixed loading row reserved to avoid viewport jumps/flicker when loading starts/stops.
956
+ const reservedLines = 1 + inputLineCount + 2;
913
957
  const logViewportHeight = Math.max(1, termRows - reservedLines - suggestionBoxLines);
914
958
  const effectiveLogLines = logLines;
915
959
  const maxLogScrollOffset = Math.max(0, effectiveLogLines.length - logViewportHeight);
@@ -919,9 +963,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
919
963
  logStartIndex = 0;
920
964
  }
921
965
  const sliceEnd = logStartIndex + logViewportHeight;
922
- const visibleLogLines = logStartIndex === 0 && effectiveLogLines.length > 0
923
- ? ["", ...effectiveLogLines.slice(0, logViewportHeight - 1)]
924
- : effectiveLogLines.slice(logStartIndex, sliceEnd);
966
+ const visibleLogLines = effectiveLogLines.slice(logStartIndex, sliceEnd);
925
967
  if (showHelpModal) {
926
968
  const helpModalWidth = Math.min(88, Math.max(80, termColumns - 4));
927
969
  const helpContentRows = 20;
@@ -929,29 +971,32 @@ export function Repl({ apiKey, cwd, onQuit }) {
929
971
  const helpLeftPad = Math.max(0, Math.floor((termColumns - helpModalWidth) / 2));
930
972
  const labelWidth = 20;
931
973
  const descWidth = helpModalWidth - (2 * 2) - labelWidth - 2;
932
- return (_jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [_jsx(Box, { height: helpTopPad }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: helpLeftPad }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: inkColors.primary, paddingX: 2, paddingY: 1, width: helpModalWidth, children: [_jsx(Text, { bold: true, children: " Help " }), _jsx(Text, { color: "gray", children: " What you can do " }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " Message " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: "gray", children: " Type and Enter to send to the agent. " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " / " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: "gray", children: " Commands. Type / then pick: /models, /brave, /help, /clear, /status, /q. Ctrl+P palette. " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " @ " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: "gray", children: " Attach files. Type @ then path; Tab to complete. " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " ! " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: "gray", children: " Run a shell command. Type ! then the command. " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " Word / char nav " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: "gray", children: " Ctrl+\u2190/\u2192 or Meta+\u2190/\u2192 word; Ctrl+F/B char (Emacs). Opt+\u2190/\u2192 needs terminal to send Meta (e.g. iTerm2: Esc+). " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " Scroll " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: "gray", children: " Trackpad/\u2191/\u2193 scroll. To select text: hold Option (iTerm2) or Fn (Terminal.app) or Shift (Windows/Linux). " }) })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: " Press any key to close " }) })] })] }), _jsx(Box, { flexGrow: 1 })] }));
974
+ return (_jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [_jsx(Box, { height: helpTopPad }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: helpLeftPad }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: inkColors.primary, paddingX: 2, paddingY: 1, width: helpModalWidth, children: [_jsx(Text, { bold: true, children: " Help " }), _jsx(Text, { color: inkColors.textSecondary, children: " What you can do " }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " Message " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: inkColors.textSecondary, children: " Type and Enter to send to the agent. " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " / " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: inkColors.textSecondary, children: " Commands. Type / then pick: /models, /brave, /help, /clear, /status, /q. Ctrl+P palette. " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " @ " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: inkColors.textSecondary, children: " Attach files. Type @ then path; Tab to complete. " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " ! " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: inkColors.textSecondary, children: " Run a shell command. Type ! then the command. " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " Word / char nav " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: inkColors.textSecondary, children: " Ctrl+\u2190/\u2192 or Meta+\u2190/\u2192 word; Ctrl+F/B char (Emacs). Opt+\u2190/\u2192 needs terminal to send Meta (e.g. iTerm2: Esc+). " }) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: inkColors.primary, children: " Scroll " }) }), _jsx(Box, { width: descWidth, flexGrow: 0, children: _jsx(Text, { color: inkColors.textSecondary, children: " Trackpad/\u2191/\u2193 scroll. To select text: hold Option (iTerm2) or Fn (Terminal.app) or Shift (Windows/Linux). " }) })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: inkColors.textSecondary, children: " Press any key to close " }) })] })] }), _jsx(Box, { flexGrow: 1 })] }));
933
975
  }
934
976
  if (showBraveKeyModal) {
935
977
  const braveModalWidth = 52;
936
978
  const topPad = Math.max(0, Math.floor((termRows - 6) / 2));
937
979
  const leftPad = Math.max(0, Math.floor((termColumns - braveModalWidth) / 2));
938
- 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: braveModalWidth, children: [_jsx(Text, { bold: true, children: " Brave Search API key " }), _jsx(Text, { color: "gray", children: " Get one at https://brave.com/search/api " }), braveKeyInput === BRAVE_KEY_PLACEHOLDER && (_jsx(Text, { color: "gray", children: " Key already set. Type or paste to replace. " })), _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Text, { color: inkColors.primary, children: " Key: " }), _jsx(Text, { children: braveKeyInput || "\u00A0" })] }), _jsx(Text, { color: "gray", children: " Enter to save, Esc to cancel " })] })] }), _jsx(Box, { flexGrow: 1 })] }));
980
+ 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: braveModalWidth, children: [_jsx(Text, { bold: true, children: " Brave Search API key " }), _jsx(Text, { color: inkColors.textSecondary, children: " Get one at https://brave.com/search/api " }), braveKeyInput === BRAVE_KEY_PLACEHOLDER && (_jsx(Text, { color: inkColors.textSecondary, children: " Key already set. Type or paste to replace. " })), _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Text, { color: inkColors.primary, children: " Key: " }), _jsx(Text, { children: braveKeyInput || "\u00A0" })] }), _jsx(Text, { color: inkColors.textSecondary, children: " Enter to save, Esc to cancel " })] })] }), _jsx(Box, { flexGrow: 1 })] }));
939
981
  }
940
982
  if (showPalette) {
941
983
  const paletteModalHeight = COMMANDS.length + 4;
942
984
  const paletteModalWidth = 52;
943
985
  const topPad = Math.max(0, Math.floor((termRows - paletteModalHeight) / 2));
944
986
  const leftPad = Math.max(0, Math.floor((termColumns - paletteModalWidth) / 2));
945
- 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: "gray", children: [" \u2014 ", c.desc] })] }, c.cmd))), _jsxs(Text, { color: paletteIndex === COMMANDS.length ? inkColors.primary : undefined, children: [paletteIndex === COMMANDS.length ? "› " : " ", "Cancel (Esc)"] }), _jsx(Text, { color: "gray", children: " \u2191/\u2193 select, Enter confirm, Esc close " })] })] }), _jsx(Box, { flexGrow: 1 })] }));
987
+ 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 })] }));
946
988
  }
947
989
  const footerLines = suggestionBoxLines + 1 + inputLineCount;
948
- 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))) }), loading && (_jsx(Box, { flexDirection: "row", marginTop: 1, marginBottom: 0, children: _jsxs(Text, { color: "gray", children: [" ", SPINNER[spinnerTick % SPINNER.length], " Thinking\u2026"] }) }))] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, height: footerLines, children: [showSlashSuggestions && (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 2, borderStyle: "single", borderColor: "gray", children: [filteredSlashCommands.length === 0 ? (_jsx(Text, { color: "gray", children: " No match " })) : ([...filteredSlashCommands].reverse().map((c, rev) => {
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) => {
949
994
  const i = filteredSlashCommands.length - 1 - rev;
950
- return (_jsxs(Text, { color: i === clampedSlashIndex ? inkColors.primary : undefined, children: [i === clampedSlashIndex ? "› " : " ", c.cmd, _jsxs(Text, { color: "gray", children: [" \u2014 ", c.desc] })] }, c.cmd));
951
- })), _jsx(Text, { color: "gray", children: " Commands (\u2191/\u2193 select, Enter run, Esc clear) " })] })), cursorInAtSegment && !showSlashSuggestions && (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 2, borderStyle: "single", borderColor: "gray", children: [filteredFilePaths.length === 0 ? (_jsxs(Text, { color: "gray", children: [" ", hasCharsAfterAt ? "No match" : "Type to search files", " "] })) : ([...filteredFilePaths].reverse().map((p, rev) => {
995
+ 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
+ })), _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) => {
952
997
  const i = filteredFilePaths.length - 1 - rev;
953
998
  return (_jsxs(Text, { color: i === clampedAtFileIndex ? inkColors.primary : undefined, children: [i === clampedAtFileIndex ? "› " : " ", p] }, p));
954
- })), _jsx(Box, { flexDirection: "row", marginTop: 1, children: _jsx(Text, { color: "gray", children: " Files (\u2191/\u2193 select, Enter/Tab complete, Esc clear) " }) })] })), _jsxs(Box, { flexDirection: "row", marginTop: 0, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [" ", icons.tool, " ", tokenDisplay] }), _jsx(Text, { color: "gray", dimColor: true, 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: "gray", children: "Message or / for commands, @ for files, ! for shell, ? for help..." })] })) : ((() => {
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..." })] })) : ((() => {
955
1000
  const lines = inputValue.split("\n");
956
1001
  let lineStart = 0;
957
1002
  return (_jsx(_Fragment, { children: lines.flatMap((lineText, lineIdx) => {
@@ -1062,7 +1107,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
1062
1107
  const isFirstRow = lineIdx === 0 && v === 0;
1063
1108
  const isLastLogicalLine = lineIdx === lines.length - 1;
1064
1109
  const isLastVisualOfLine = v === visualLines.length - 1;
1065
- return (_jsxs(Box, { flexDirection: "row", children: [isFirstRow ? (_jsxs(Text, { color: inkColors.primary, children: [icons.prompt, " "] })) : (_jsx(Text, { children: " ".repeat(PROMPT_INDENT_LEN) })), lineNodes, isLastLogicalLine && isLastVisualOfLine && inputValue.startsWith("!") && (_jsxs(Text, { dimColor: true, color: "gray", children: [" — ", "type a shell command to run"] }))] }, `${lineIdx}-${v}`));
1110
+ return (_jsxs(Box, { flexDirection: "row", children: [isFirstRow ? (_jsxs(Text, { color: inkColors.primary, children: [icons.prompt, " "] })) : (_jsx(Text, { children: " ".repeat(PROMPT_INDENT_LEN) })), lineNodes, isLastLogicalLine && isLastVisualOfLine && inputValue.startsWith("!") && (_jsxs(Text, { color: inkColors.textDisabled, children: [" — ", "type a shell command to run"] }))] }, `${lineIdx}-${v}`));
1066
1111
  });
1067
1112
  }) }));
1068
1113
  })()) })] })] }));
package/dist/tools/web.js CHANGED
@@ -2,105 +2,226 @@ import { getBraveSearchApiKey } from "../config.js";
2
2
  const MAX_FETCH_CHARS = 40_000;
3
3
  const FETCH_TIMEOUT_MS = 20_000;
4
4
  const MAX_SEARCH_RESULTS = 8;
5
+ const FETCH_CACHE_TTL_MS = 5 * 60_000;
6
+ const fetchCache = new Map();
5
7
  function formatSearchResults(hits) {
6
8
  const slice = hits.slice(0, MAX_SEARCH_RESULTS);
7
9
  return slice
8
10
  .map((r, i) => `${i + 1}. ${(r.title ?? "Untitled").replace(/<[^>]+>/g, "")}\n ${r.url ?? ""}${r.snippet ? "\n " + String(r.snippet).replace(/<[^>]+>/g, "").slice(0, 300) : ""}`)
9
11
  .join("\n\n");
10
12
  }
11
- const PLAYWRIGHT_INSTALL_MSG = "Playwright Chromium not installed. Run: npx playwright install chromium (in the project directory). web_fetch uses it only when plain fetch fails (e.g. JS-rendered pages).";
12
- function stripHtmlToText(html) {
13
- let s = html
13
+ const PLAYWRIGHT_INSTALL_MSG = "Playwright Chromium not installed. Run: npx playwright install chromium (in the project directory). web_fetch uses it only when plain fetch cannot reliably extract content.";
14
+ function decodeHtmlEntities(text) {
15
+ return text
16
+ .replace(/&nbsp;/gi, " ")
17
+ .replace(/&amp;/gi, "&")
18
+ .replace(/&lt;/gi, "<")
19
+ .replace(/&gt;/gi, ">")
20
+ .replace(/&quot;/gi, '"')
21
+ .replace(/&#39;/gi, "'")
22
+ .replace(/&#x([0-9a-f]+);/gi, (_m, hex) => {
23
+ const n = Number.parseInt(hex, 16);
24
+ return Number.isFinite(n) ? String.fromCodePoint(n) : _m;
25
+ })
26
+ .replace(/&#(\d+);/g, (_m, dec) => {
27
+ const n = Number.parseInt(dec, 10);
28
+ return Number.isFinite(n) ? String.fromCodePoint(n) : _m;
29
+ });
30
+ }
31
+ function pickMainHtml(html) {
32
+ const article = html.match(/<article\b[^>]*>[\s\S]*?<\/article>/i)?.[0];
33
+ if (article)
34
+ return article;
35
+ const main = html.match(/<main\b[^>]*>[\s\S]*?<\/main>/i)?.[0];
36
+ if (main)
37
+ return main;
38
+ return html;
39
+ }
40
+ function htmlToText(html) {
41
+ const focused = pickMainHtml(html);
42
+ let s = focused
14
43
  .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
15
44
  .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
45
+ .replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, "")
46
+ .replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, "")
47
+ .replace(/<nav\b[^>]*>[\s\S]*?<\/nav>/gi, "")
48
+ .replace(/<header\b[^>]*>[\s\S]*?<\/header>/gi, "")
49
+ .replace(/<footer\b[^>]*>[\s\S]*?<\/footer>/gi, "")
50
+ .replace(/<form\b[^>]*>[\s\S]*?<\/form>/gi, "")
51
+ .replace(/<aside\b[^>]*>[\s\S]*?<\/aside>/gi, "")
52
+ .replace(/<br\s*\/?>/gi, "\n")
53
+ .replace(/<\/(p|div|section|article|h[1-6]|li|tr|ul|ol|table)>/gi, "\n")
54
+ .replace(/<li\b[^>]*>/gi, "- ")
16
55
  .replace(/<[^>]+>/g, " ");
17
- return s.replace(/\s+/g, " ").trim();
56
+ s = decodeHtmlEntities(s);
57
+ s = s
58
+ .replace(/[ \t]+/g, " ")
59
+ .replace(/ *\n */g, "\n")
60
+ .replace(/\n{3,}/g, "\n\n")
61
+ .trim();
62
+ return s;
63
+ }
64
+ function isLikelyBlockedOrThin(text) {
65
+ const lowered = text.toLowerCase();
66
+ const blockedSignals = [
67
+ "enable javascript",
68
+ "verify you are human",
69
+ "captcha",
70
+ "cloudflare",
71
+ "access denied",
72
+ "unusual traffic",
73
+ "robot",
74
+ "bot detection",
75
+ "request blocked",
76
+ "forbidden",
77
+ ];
78
+ if (blockedSignals.some((s) => lowered.includes(s)))
79
+ return true;
80
+ return text.length < 280;
81
+ }
82
+ function clipOutput(text) {
83
+ if (text.length <= MAX_FETCH_CHARS)
84
+ return text;
85
+ return text.slice(0, MAX_FETCH_CHARS) + `\n\n... (truncated, total ${text.length} chars)`;
86
+ }
87
+ function getCachedFetch(url) {
88
+ const hit = fetchCache.get(url);
89
+ if (!hit)
90
+ return null;
91
+ if (Date.now() - hit.cachedAt > FETCH_CACHE_TTL_MS) {
92
+ fetchCache.delete(url);
93
+ return null;
94
+ }
95
+ return hit.value;
96
+ }
97
+ function setCachedFetch(url, value) {
98
+ fetchCache.set(url, { cachedAt: Date.now(), value });
18
99
  }
19
100
  async function fetchWithTimeout(url) {
20
101
  const ac = new AbortController();
21
102
  const t = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
22
103
  try {
23
- const res = await fetch(url, {
104
+ return await fetch(url, {
24
105
  signal: ac.signal,
25
- headers: { "User-Agent": "ideacode-web-fetch/1" },
106
+ headers: {
107
+ "User-Agent": "ideacode-web-fetch/2",
108
+ Accept: "text/html,application/json,text/plain,*/*",
109
+ },
110
+ redirect: "follow",
26
111
  });
27
- return res;
28
112
  }
29
113
  finally {
30
114
  clearTimeout(t);
31
115
  }
32
116
  }
117
+ async function fetchWithPlaywright(url) {
118
+ const { chromium } = await import("playwright");
119
+ const browser = await chromium.launch({ headless: true });
120
+ try {
121
+ const page = await browser.newPage({
122
+ userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
123
+ });
124
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: FETCH_TIMEOUT_MS });
125
+ const text = await page.evaluate(() => {
126
+ const root = document.querySelector("article") ??
127
+ document.querySelector("main") ??
128
+ document.querySelector("[role='main']") ??
129
+ document.body;
130
+ if (!root)
131
+ return "";
132
+ const clone = root.cloneNode(true);
133
+ for (const el of clone.querySelectorAll("script, style, nav, header, footer, aside, form, noscript, svg")) {
134
+ el.remove();
135
+ }
136
+ return (clone.innerText ?? clone.textContent ?? "")
137
+ .replace(/[ \t]+/g, " ")
138
+ .replace(/ *\n */g, "\n")
139
+ .replace(/\n{3,}/g, "\n\n")
140
+ .trim();
141
+ });
142
+ return text || "(no text content)";
143
+ }
144
+ finally {
145
+ await browser.close().catch(() => { });
146
+ }
147
+ }
33
148
  export async function webFetch(args) {
34
149
  const url = args.url?.trim();
35
150
  if (!url)
36
151
  return "error: url is required";
37
- if (!url.startsWith("http://") && !url.startsWith("https://"))
152
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
38
153
  return "error: url must be http or https";
154
+ }
155
+ const cached = getCachedFetch(url);
156
+ if (cached)
157
+ return cached;
158
+ let needsBrowserFallback = false;
159
+ let fallbackReason = "";
39
160
  try {
40
161
  const res = await fetchWithTimeout(url);
162
+ const ct = (res.headers.get("content-type") ?? "").toLowerCase();
41
163
  if (res.ok) {
42
- const ct = (res.headers.get("content-type") ?? "").toLowerCase();
43
- if (ct.includes("text/plain") ||
44
- ct.includes("text/html") ||
45
- ct.includes("application/json") ||
46
- ct.includes("text/")) {
164
+ if (ct.includes("application/json")) {
165
+ const raw = await res.text();
166
+ const clipped = clipOutput(raw.trim() || "(no text content)");
167
+ setCachedFetch(url, clipped);
168
+ return clipped;
169
+ }
170
+ if (ct.includes("text/plain") || ct.includes("text/") || ct.includes("application/xml") || ct.includes("application/xhtml")) {
47
171
  const raw = await res.text();
48
- const text = ct.includes("text/html") && raw.includes("<")
49
- ? stripHtmlToText(raw)
50
- : raw.replace(/\s+/g, " ").trim();
51
- if (text.length > MAX_FETCH_CHARS) {
52
- return (text.slice(0, MAX_FETCH_CHARS) +
53
- "\n\n... (truncated, total " +
54
- text.length +
55
- " chars)");
172
+ const normalized = ct.includes("html") ? htmlToText(raw) : raw.replace(/\s+/g, " ").trim();
173
+ if (!normalized) {
174
+ needsBrowserFallback = true;
175
+ fallbackReason = "empty fetch result";
176
+ }
177
+ else if (ct.includes("html") && isLikelyBlockedOrThin(normalized)) {
178
+ needsBrowserFallback = true;
179
+ fallbackReason = "thin or blocked html content";
180
+ }
181
+ else {
182
+ const clipped = clipOutput(normalized);
183
+ setCachedFetch(url, clipped);
184
+ return clipped;
56
185
  }
57
- return text || "(no text content)";
186
+ }
187
+ else {
188
+ return `error: unsupported content-type: ${ct || "unknown"}`;
189
+ }
190
+ }
191
+ else {
192
+ const bodyPreview = (await res.text()).slice(0, 220);
193
+ if (res.status === 403 || res.status === 429 || res.status === 503) {
194
+ needsBrowserFallback = true;
195
+ fallbackReason = `http ${res.status}`;
196
+ }
197
+ else {
198
+ return `error: HTTP ${res.status}${bodyPreview ? `: ${bodyPreview}` : ""}`;
58
199
  }
59
200
  }
60
201
  }
61
202
  catch {
62
- // Fall through to Playwright for JS-rendered or when fetch fails
203
+ needsBrowserFallback = true;
204
+ fallbackReason = "fetch failed";
63
205
  }
206
+ if (!needsBrowserFallback)
207
+ return "error: unable to fetch content";
64
208
  try {
65
- const { chromium } = await import("playwright");
66
- const browser = await chromium.launch({ headless: true });
67
- try {
68
- const page = await browser.newPage();
69
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: FETCH_TIMEOUT_MS });
70
- const text = await page.evaluate(() => {
71
- const body = document.body;
72
- if (!body)
73
- return "";
74
- const clone = body.cloneNode(true);
75
- for (const el of clone.querySelectorAll("script, style, nav, header, footer, [role='navigation']")) {
76
- el.remove();
77
- }
78
- return (clone.innerText ?? clone.textContent ?? "").replace(/\s+/g, " ").trim();
79
- });
80
- if (text.length > MAX_FETCH_CHARS) {
81
- return (text.slice(0, MAX_FETCH_CHARS) +
82
- "\n\n... (truncated, total " +
83
- text.length +
84
- " chars)");
85
- }
86
- return text || "(no text content)";
87
- }
88
- finally {
89
- await browser.close().catch(() => { });
90
- }
209
+ const browserText = await fetchWithPlaywright(url);
210
+ const clipped = clipOutput(browserText);
211
+ setCachedFetch(url, clipped);
212
+ return clipped;
91
213
  }
92
214
  catch (err) {
93
215
  const msg = err instanceof Error ? err.message : String(err);
94
216
  const causeMsg = err instanceof Error && err.cause instanceof Error ? String(err.cause.message) : "";
95
217
  const combined = (msg + " " + causeMsg).toLowerCase();
96
- const needsPlaywright = combined.includes("executable doesn't exist") ||
218
+ const needsPlaywrightInstall = combined.includes("executable doesn't exist") ||
97
219
  combined.includes("browser was not found") ||
98
220
  combined.includes("chromium revision is not downloaded") ||
99
221
  (combined.includes("playwright") && (combined.includes("install") || combined.includes("not found")));
100
- if (needsPlaywright) {
222
+ if (needsPlaywrightInstall)
101
223
  return `error: ${PLAYWRIGHT_INSTALL_MSG}`;
102
- }
103
- return `error: ${msg}`;
224
+ return `error: web_fetch failed after ${fallbackReason || "fallback"}. ${msg}`;
104
225
  }
105
226
  }
106
227
  async function braveSearchApi(query, apiKey) {
package/dist/ui/format.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { marked } from "marked";
2
- import { markedTerminal } from "marked-terminal";
2
+ import Table from "cli-table3";
3
3
  import chalk from "chalk";
4
4
  import boxen from "boxen";
5
5
  import { colors, icons, theme, inkColors } from "./theme.js";
@@ -7,27 +7,258 @@ const hrLine = () => {
7
7
  const cols = process.stdout.columns ?? 80;
8
8
  return colors.muted("─".repeat(Math.min(cols, 100)));
9
9
  };
10
- marked.use(markedTerminal({
11
- code: chalk.hex(theme.syntax.code),
12
- codespan: chalk.hex(theme.syntax.code),
13
- blockquote: chalk.hex(theme.colors.muted.main).italic,
14
- strong: chalk.bold,
15
- em: chalk.italic,
16
- heading: chalk.hex(theme.syntax.heading).bold,
17
- firstHeading: chalk.hex(theme.colors.primary.dim).underline.bold,
18
- link: chalk.hex(theme.syntax.link),
19
- href: chalk.hex(theme.syntax.href).underline,
20
- hr: () => hrLine(),
21
- }, { theme: { keyword: chalk.hex(theme.syntax.keyword), string: chalk.hex(theme.syntax.string) } }));
22
10
  const codeStyle = (s) => chalk.hex(theme.syntax.code)(s);
23
- export function renderMarkdown(text) {
24
- let out = marked.parse(text, { async: false });
25
- out = out.replace(/\`([^`]+)\`/g, (_, code) => codeStyle(code));
26
- out = out.replace(/\*\*([^*]+)\*\*/g, (_, boldText) => chalk.bold(boldText));
27
- out = out.replace(/(^|\n)\* /g, "$1• ");
28
- out = out.replace(/(^|\n)([ \t]*[-*_]{3,}[ \t]*)(\n|$)/gm, (_, before, _rule, after) => before + hrLine() + after);
11
+ function stripAnsi(input) {
12
+ return input.replace(/\x1b\[[0-9;]*m/g, "");
13
+ }
14
+ function visibleLen(input) {
15
+ return stripAnsi(input).length;
16
+ }
17
+ function preprocessMarkdown(input) {
18
+ return input
19
+ .replace(/\r\n/g, "\n")
20
+ .replace(/<br\s*\/?>/gi, " / ")
21
+ .replace(/\t/g, " ");
22
+ }
23
+ function normalizeTableCellText(input) {
24
+ let s = input.replace(/\s+/g, " ").trim();
25
+ if (!s)
26
+ return s;
27
+ // Many models emit " / " as pseudo-newlines in tables; promote to bullet lines.
28
+ // Keep URL-like tokens intact by only rewriting slash tokens surrounded by spaces.
29
+ s = s.replace(/\s+\/\s+(?=[^\s])/g, "\n• ");
30
+ s = s.replace(/(^|\n)\s*•\s*•\s*/g, "$1• ");
31
+ if (!s.startsWith("• ") && s.includes("\n• "))
32
+ s = "• " + s;
33
+ return s;
34
+ }
35
+ function splitRenderedLines(rendered) {
36
+ return rendered.split("\n").map((l) => l.replace(/[ \t]+$/g, ""));
37
+ }
38
+ function renderInlineTokens(tokens) {
39
+ if (!tokens || tokens.length === 0)
40
+ return "";
41
+ return tokens
42
+ .map((token) => {
43
+ const t = token.type ?? "";
44
+ if (t === "text")
45
+ return token.text ?? "";
46
+ if (t === "escape")
47
+ return token.text ?? "";
48
+ if (t === "strong")
49
+ return chalk.bold(renderInlineTokens(token.tokens));
50
+ if (t === "em")
51
+ return chalk.italic(renderInlineTokens(token.tokens));
52
+ if (t === "codespan")
53
+ return codeStyle(token.text ?? "");
54
+ if (t === "del")
55
+ return chalk.strikethrough(renderInlineTokens(token.tokens));
56
+ if (t === "link") {
57
+ const label = renderInlineTokens(token.tokens) || token.text || token.href || "";
58
+ const labelStyled = chalk.hex(theme.syntax.link).underline(label);
59
+ if (!token.href || label === token.href)
60
+ return labelStyled;
61
+ return `${labelStyled}${colors.mutedDark(` (${token.href})`)}`;
62
+ }
63
+ if (t === "br")
64
+ return "\n";
65
+ if (t === "html") {
66
+ const raw = token.raw ?? token.text ?? "";
67
+ if (/^<br\s*\/?\s*>$/i.test(raw.trim()))
68
+ return "\n";
69
+ return raw.replace(/<[^>]+>/g, "");
70
+ }
71
+ return token.text ?? token.raw ?? "";
72
+ })
73
+ .join("");
74
+ }
75
+ function headingRule(text) {
76
+ const width = Math.min(96, Math.max(24, visibleLen(text) + 8));
77
+ return colors.muted("─".repeat(width));
78
+ }
79
+ function renderHeading(token) {
80
+ const depth = token.depth ?? 2;
81
+ const headingText = renderInlineTokens(token.tokens) || token.text || "";
82
+ const clean = headingText.trim();
83
+ if (!clean)
84
+ return [];
85
+ if (depth <= 1) {
86
+ return [chalk.hex(theme.syntax.heading).bold.underline(clean), headingRule(clean), ""];
87
+ }
88
+ if (depth === 2) {
89
+ return [chalk.hex(theme.syntax.heading).bold(clean), headingRule(clean), ""];
90
+ }
91
+ if (depth === 3) {
92
+ return [chalk.hex(theme.syntax.heading).bold(clean), ""];
93
+ }
94
+ return [colors.accentDim(clean), ""];
95
+ }
96
+ function renderCodeBlock(token) {
97
+ const raw = (token.text ?? "").replace(/\n$/, "");
98
+ const lang = (token.lang ?? "").trim();
99
+ const body = raw || "(empty)";
100
+ const title = lang ? ` ${lang} ` : " code ";
101
+ const boxed = boxen(codeStyle(body), {
102
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
103
+ margin: { top: 0, bottom: 0, left: 0, right: 0 },
104
+ borderColor: inkColors.mutedDim,
105
+ borderStyle: "round",
106
+ title,
107
+ titleAlignment: "left",
108
+ });
109
+ return splitRenderedLines(boxed).concat("");
110
+ }
111
+ function renderTable(token) {
112
+ const headerCells = (token.header ?? []).map((c) => renderInlineTokens(c.tokens) || c.text || "");
113
+ const rows = (token.rows ?? []).map((row) => row.map((c) => normalizeTableCellText(renderInlineTokens(c.tokens) || c.text || "")));
114
+ const colCount = Math.max(headerCells.length, ...rows.map((r) => r.length), 0);
115
+ if (colCount === 0)
116
+ return [];
117
+ const paddedHeader = [...headerCells];
118
+ while (paddedHeader.length < colCount)
119
+ paddedHeader.push("");
120
+ const paddedRows = rows.map((r) => {
121
+ const copy = [...r];
122
+ while (copy.length < colCount)
123
+ copy.push("");
124
+ return copy;
125
+ });
126
+ const maxColsWidth = Math.max(48, (process.stdout.columns ?? 100) - 8);
127
+ const minColWidth = 10;
128
+ const maxColWidth = 40;
129
+ const widths = Array.from({ length: colCount }, (_, i) => {
130
+ const maxLen = Math.max(visibleLen(paddedHeader[i] ?? ""), ...paddedRows.map((r) => visibleLen(r[i] ?? "")), minColWidth);
131
+ return Math.min(maxColWidth, maxLen + 2);
132
+ });
133
+ const currentWidth = widths.reduce((a, b) => a + b, 0) + colCount + 1;
134
+ if (currentWidth > maxColsWidth) {
135
+ let excess = currentWidth - maxColsWidth;
136
+ while (excess > 0) {
137
+ let shrunk = false;
138
+ for (let i = 0; i < widths.length && excess > 0; i++) {
139
+ if ((widths[i] ?? minColWidth) > minColWidth) {
140
+ widths[i] = (widths[i] ?? minColWidth) - 1;
141
+ excess--;
142
+ shrunk = true;
143
+ }
144
+ }
145
+ if (!shrunk)
146
+ break;
147
+ }
148
+ }
149
+ const table = new Table({
150
+ head: paddedHeader.map((h) => chalk.bold(colors.accentPale(h))),
151
+ colWidths: widths,
152
+ wordWrap: true,
153
+ style: { border: [], head: [] },
154
+ chars: {
155
+ top: colors.muted("─"),
156
+ "top-mid": colors.muted("┬"),
157
+ "top-left": colors.muted("┌"),
158
+ "top-right": colors.muted("┐"),
159
+ bottom: colors.muted("─"),
160
+ "bottom-mid": colors.muted("┴"),
161
+ "bottom-left": colors.muted("└"),
162
+ "bottom-right": colors.muted("┘"),
163
+ left: colors.muted("│"),
164
+ "left-mid": colors.muted("├"),
165
+ mid: colors.muted("─"),
166
+ "mid-mid": colors.muted("┼"),
167
+ right: colors.muted("│"),
168
+ "right-mid": colors.muted("┤"),
169
+ middle: colors.muted("│"),
170
+ },
171
+ });
172
+ for (const row of paddedRows)
173
+ table.push(row);
174
+ return splitRenderedLines(table.toString()).concat("");
175
+ }
176
+ function renderList(token, indent) {
177
+ const out = [];
178
+ const ordered = !!token.ordered;
179
+ const start = Number(token.start ?? 1) || 1;
180
+ const items = token.items ?? [];
181
+ for (let i = 0; i < items.length; i++) {
182
+ const item = items[i] ?? {};
183
+ const marker = ordered ? `${start + i}.` : "•";
184
+ const itemLines = renderBlockTokens(item.tokens ?? [], indent + 2, true);
185
+ const first = (itemLines[0] ?? "").trimStart();
186
+ out.push(`${" ".repeat(indent)}${marker} ${first}`.trimEnd());
187
+ const continuationPrefix = " ".repeat(indent + marker.length + 1);
188
+ for (let j = 1; j < itemLines.length; j++) {
189
+ out.push(`${continuationPrefix}${itemLines[j] ?? ""}`.trimEnd());
190
+ }
191
+ }
29
192
  return out;
30
193
  }
194
+ function renderBlockTokens(tokens, indent = 0, compact = false) {
195
+ const out = [];
196
+ for (const token of tokens) {
197
+ const t = token.type ?? "";
198
+ if (t === "space") {
199
+ if (!compact)
200
+ out.push("");
201
+ continue;
202
+ }
203
+ if (t === "heading") {
204
+ out.push(...renderHeading(token));
205
+ continue;
206
+ }
207
+ if (t === "paragraph" || t === "text") {
208
+ const line = renderInlineTokens(token.tokens ?? [{ type: "text", text: token.text ?? "" }]);
209
+ if (line.trim())
210
+ out.push(`${" ".repeat(indent)}${line}`.trimEnd());
211
+ if (!compact)
212
+ out.push("");
213
+ continue;
214
+ }
215
+ if (t === "blockquote") {
216
+ const inner = renderBlockTokens(token.tokens ?? [], indent + 2, true);
217
+ for (const line of inner)
218
+ out.push(`${colors.muted("│")} ${colors.muted(line)}`.trimEnd());
219
+ if (!compact)
220
+ out.push("");
221
+ continue;
222
+ }
223
+ if (t === "list") {
224
+ out.push(...renderList(token, indent));
225
+ if (!compact)
226
+ out.push("");
227
+ continue;
228
+ }
229
+ if (t === "code") {
230
+ out.push(...renderCodeBlock(token));
231
+ continue;
232
+ }
233
+ if (t === "table") {
234
+ out.push(...renderTable(token));
235
+ continue;
236
+ }
237
+ if (t === "hr") {
238
+ out.push(hrLine(), "");
239
+ continue;
240
+ }
241
+ const fallback = (token.text ?? token.raw ?? "").trim();
242
+ if (fallback) {
243
+ out.push(`${" ".repeat(indent)}${fallback}`);
244
+ if (!compact)
245
+ out.push("");
246
+ }
247
+ }
248
+ while (out.length > 0 && out[out.length - 1] === "")
249
+ out.pop();
250
+ return out;
251
+ }
252
+ export function renderMarkdown(text) {
253
+ const normalized = preprocessMarkdown(text);
254
+ const tokens = marked.lexer(normalized, { gfm: true, breaks: true });
255
+ const lines = renderBlockTokens(tokens, 0, false)
256
+ .join("\n")
257
+ .replace(/\n{3,}/g, "\n\n")
258
+ .replace(/[ \t]+\n/g, "\n")
259
+ .trimEnd();
260
+ return lines;
261
+ }
31
262
  export function separator() {
32
263
  const cols = process.stdout.columns ?? 80;
33
264
  return colors.muted("─".repeat(Math.min(cols, 100)));
@@ -66,7 +297,7 @@ export function userPromptBox(prompt) {
66
297
  const cols = process.stdout.columns ?? 80;
67
298
  const boxWidth = Math.max(20, cols - 4);
68
299
  const innerWidth = boxWidth - 2 - 2;
69
- const text = wrapToWidth((prompt.trim() || "\u00A0"), innerWidth);
300
+ const text = wrapToWidth(prompt.trim() || "\u00A0", innerWidth);
70
301
  return boxen(text, {
71
302
  width: boxWidth,
72
303
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
@@ -76,14 +307,15 @@ export function userPromptBox(prompt) {
76
307
  });
77
308
  }
78
309
  const TOOL_INDENT = " ";
79
- const toolSubdued = chalk.hex("#3d3d3d");
80
- export function toolCallBox(toolName, argPreview, success = true) {
310
+ const toolSubdued = chalk.gray;
311
+ export function toolCallBox(toolName, argPreview, success = true, extraIndent = 0) {
312
+ const indent = TOOL_INDENT + " ".repeat(Math.max(0, extraIndent));
81
313
  const diamondColor = success ? colors.toolSuccess : colors.toolFail;
82
- const nameColor = success ? colors.toolSuccess : colors.toolFail;
314
+ const nameColor = diamondColor;
83
315
  const argColor = success ? toolSubdued : colors.toolFail;
84
- const parenColor = chalk.white;
85
- const name = " " + toolName.charAt(0).toUpperCase() + toolName.slice(1);
86
- return `${TOOL_INDENT}${diamondColor(icons.tool)}${nameColor(name)}${parenColor("(")}${argColor(argPreview)}${parenColor(")")}`;
316
+ const parenColor = colors.mutedDark;
317
+ const name = " " + toolName.trim().toLowerCase();
318
+ return `${indent}${diamondColor(icons.tool)}${nameColor(name)}${parenColor("(")}${argColor(argPreview)}${parenColor(")")}`;
87
319
  }
88
320
  export function toolResultLine(preview, success = true) {
89
321
  const pipeColor = success ? toolSubdued : colors.toolFail;
@@ -95,11 +327,12 @@ function formatTokenCount(n) {
95
327
  return (n / 1000).toFixed(1).replace(/\.0$/, "") + "K";
96
328
  return String(n);
97
329
  }
98
- export function toolResultTokenLine(tokens, success = true) {
330
+ export function toolResultTokenLine(tokens, success = true, extraIndent = 0) {
331
+ const indent = TOOL_INDENT + " ".repeat(Math.max(0, extraIndent));
99
332
  const pipeColor = success ? toolSubdued : colors.toolFail;
100
- const textColor = success ? toolSubdued : colors.toolFail;
333
+ const textColor = toolSubdued;
101
334
  const tokenStr = `+${formatTokenCount(tokens)} tokens`;
102
- return `${TOOL_INDENT}${TOOL_INDENT}${pipeColor(icons.pipe + " ")}${textColor(tokenStr)}`;
335
+ return `${indent}${TOOL_INDENT}${pipeColor(icons.pipe + " ")}${textColor(tokenStr)}`;
103
336
  }
104
337
  export function agentMessage(text) {
105
338
  const rendered = renderMarkdown(text.trim());
package/dist/ui/theme.js CHANGED
@@ -22,7 +22,7 @@ const THEME_DARK = {
22
22
  error: { main: "#f87171", dim: "#dc2626" },
23
23
  muted: { main: "#8a9a7a", dim: "#6a7a5a", dark: "#4a5a3a" },
24
24
  background: { dark: "#1a1a1a", darker: "#0f0f0f" },
25
- text: { primary: "#e2e8f0", secondary: "#94a3b8", disabled: "#64748b" },
25
+ text: { primary: "#e2e8f0", secondary: "#98a08f", disabled: "#6f7867" },
26
26
  },
27
27
  ui: {
28
28
  borderColor: "#7F9A65",
@@ -51,7 +51,7 @@ const THEME_LIGHT = {
51
51
  error: { main: "#dc2626", dim: "#b91c1c" },
52
52
  muted: { main: "#6b7a5a", dim: "#5a6a4a", dark: "#4a5a3a" },
53
53
  background: { dark: "#f1f5f9", darker: "#e2e8f0" },
54
- text: { primary: "#1e293b", secondary: "#475569", disabled: "#64748b" },
54
+ text: { primary: "#1e293b", secondary: "#5f6758", disabled: "#778070" },
55
55
  },
56
56
  ui: {
57
57
  borderColor: "#5a7247",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ideacode",
3
- "version": "1.1.9",
3
+ "version": "1.2.1",
4
4
  "description": "CLI TUI for AI agents via OpenRouter — agentic loop, tools, markdown",
5
5
  "type": "module",
6
6
  "repository": {