ideacode 1.1.8 → 1.2.0

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
  }
@@ -255,7 +263,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
255
263
  };
256
264
  }, []);
257
265
  const [spinnerTick, setSpinnerTick] = useState(0);
258
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
266
+ const loadingStartedAtRef = useRef(0);
267
+ const SPINNER = ["●○○", "○●○", "○○●", "○●○"];
259
268
  const estimatedTokens = useMemo(() => estimateTokens(messages, undefined), [messages]);
260
269
  const contextWindowK = useMemo(() => {
261
270
  const ctx = modelList.find((m) => m.id === currentModel)?.context_length;
@@ -289,7 +298,9 @@ export function Repl({ apiKey, cwd, onQuit }) {
289
298
  useEffect(() => {
290
299
  if (!loading)
291
300
  return;
292
- const t = setInterval(() => setSpinnerTick((n) => n + 1), 80);
301
+ loadingStartedAtRef.current = Date.now();
302
+ setSpinnerTick(0);
303
+ const t = setInterval(() => setSpinnerTick((n) => n + 1), LOADING_TICK_MS);
293
304
  return () => clearInterval(t);
294
305
  }, [loading]);
295
306
  const showSlashSuggestions = inputValue.startsWith("/");
@@ -434,7 +445,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
434
445
  appendLog(userPromptBox(userInput));
435
446
  appendLog("");
436
447
  let state = [...messages, { role: "user", content: userInput }];
437
- const systemPrompt = `Concise coding assistant. cwd: ${cwd}. 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.`;
448
+ 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
449
  const modelContext = modelList.find((m) => m.id === currentModel)?.context_length;
439
450
  const maxContextTokens = Math.floor((modelContext ?? CONTEXT_WINDOW_K * 1024) * 0.85);
440
451
  const stateBeforeCompress = state;
@@ -450,34 +461,63 @@ export function Repl({ apiKey, cwd, onQuit }) {
450
461
  const response = await callApi(apiKey, state, systemPrompt, currentModel);
451
462
  const contentBlocks = response.content ?? [];
452
463
  const toolResults = [];
464
+ const renderToolOutcome = (planned, result, extraIndent = 0) => {
465
+ const ok = !result.startsWith("error:");
466
+ appendLog(toolCallBox(planned.toolName, planned.argPreview, ok, extraIndent));
467
+ const contentForApi = truncateToolResult(result);
468
+ const tokens = estimateTokensForString(contentForApi);
469
+ appendLog(toolResultTokenLine(tokens, ok, extraIndent));
470
+ if (planned.block.id) {
471
+ toolResults.push({ type: "tool_result", tool_use_id: planned.block.id, content: contentForApi });
472
+ }
473
+ };
474
+ const runParallelBatch = async (batch) => {
475
+ if (batch.length === 0)
476
+ return;
477
+ const started = Date.now();
478
+ const groupedTools = Array.from(batch.reduce((acc, planned) => {
479
+ acc.set(planned.toolName, (acc.get(planned.toolName) ?? 0) + 1);
480
+ return acc;
481
+ }, new Map()))
482
+ .map(([name, count]) => (count > 1 ? `${name}×${count}` : name))
483
+ .join(", ");
484
+ appendLog(colors.gray(` ${icons.tool} parallel batch (${batch.length}): ${groupedTools}`));
485
+ const settled = await Promise.all(batch.map(async (planned) => ({ planned, result: await runTool(planned.toolName, planned.toolArgs) })));
486
+ const elapsed = Date.now() - started;
487
+ appendLog(colors.gray(` completed in ${elapsed}ms`));
488
+ for (const { planned, result } of settled) {
489
+ renderToolOutcome(planned, result, 1);
490
+ }
491
+ };
492
+ let parallelBatch = [];
453
493
  for (const block of contentBlocks) {
454
494
  if (block.type === "text" && block.text?.trim()) {
455
495
  if (lastLogLineRef.current !== "")
456
496
  appendLog("");
457
497
  appendLog(agentMessage(block.text).trimEnd());
498
+ continue;
458
499
  }
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 });
500
+ if (block.type !== "tool_use" || !block.name || !block.input)
501
+ continue;
502
+ const toolName = block.name.trim().toLowerCase();
503
+ const toolArgs = block.input;
504
+ const firstVal = Object.values(toolArgs)[0];
505
+ const planned = {
506
+ block,
507
+ toolName,
508
+ toolArgs,
509
+ argPreview: String(firstVal ?? "").slice(0, 100) || "",
510
+ };
511
+ if (ENABLE_PARALLEL_TOOL_CALLS && PARALLEL_SAFE_TOOLS.has(toolName)) {
512
+ parallelBatch.push(planned);
513
+ continue;
479
514
  }
515
+ await runParallelBatch(parallelBatch);
516
+ parallelBatch = [];
517
+ const result = await runTool(planned.toolName, planned.toolArgs);
518
+ renderToolOutcome(planned, result);
480
519
  }
520
+ await runParallelBatch(parallelBatch);
481
521
  setLoading(false);
482
522
  state = [...state, { role: "assistant", content: contentBlocks }];
483
523
  if (toolResults.length === 0) {
@@ -892,10 +932,10 @@ export function Repl({ apiKey, cwd, onQuit }) {
892
932
  const visibleModelCount = Math.min(filteredModelList.length, modelModalHeight - 4);
893
933
  const modelScrollOffset = Math.max(0, Math.min(modelIndex - Math.floor(visibleModelCount / 2), filteredModelList.length - visibleModelCount));
894
934
  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) => {
935
+ 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
936
  const actualIndex = modelScrollOffset + i;
897
937
  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 })] }));
938
+ })), _jsx(Text, { color: inkColors.textSecondary, children: " \u2191/\u2193 select Enter confirm Esc cancel Type to filter " })] })] }), _jsx(Box, { flexGrow: 1 })] }));
899
939
  }
900
940
  const slashSuggestionBoxLines = showSlashSuggestions
901
941
  ? 3 + Math.max(1, filteredSlashCommands.length)
@@ -909,7 +949,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
909
949
  const lines = inputValue.split("\n");
910
950
  return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / wrapWidth)), 0);
911
951
  })();
912
- const reservedLines = 1 + inputLineCount + (loading ? 2 : 1);
952
+ // Keep a fixed loading row reserved to avoid viewport jumps/flicker when loading starts/stops.
953
+ const reservedLines = 1 + inputLineCount + 2;
913
954
  const logViewportHeight = Math.max(1, termRows - reservedLines - suggestionBoxLines);
914
955
  const effectiveLogLines = logLines;
915
956
  const maxLogScrollOffset = Math.max(0, effectiveLogLines.length - logViewportHeight);
@@ -919,9 +960,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
919
960
  logStartIndex = 0;
920
961
  }
921
962
  const sliceEnd = logStartIndex + logViewportHeight;
922
- const visibleLogLines = logStartIndex === 0 && effectiveLogLines.length > 0
923
- ? ["", ...effectiveLogLines.slice(0, logViewportHeight - 1)]
924
- : effectiveLogLines.slice(logStartIndex, sliceEnd);
963
+ const visibleLogLines = effectiveLogLines.slice(logStartIndex, sliceEnd);
925
964
  if (showHelpModal) {
926
965
  const helpModalWidth = Math.min(88, Math.max(80, termColumns - 4));
927
966
  const helpContentRows = 20;
@@ -929,29 +968,32 @@ export function Repl({ apiKey, cwd, onQuit }) {
929
968
  const helpLeftPad = Math.max(0, Math.floor((termColumns - helpModalWidth) / 2));
930
969
  const labelWidth = 20;
931
970
  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 })] }));
971
+ 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
972
  }
934
973
  if (showBraveKeyModal) {
935
974
  const braveModalWidth = 52;
936
975
  const topPad = Math.max(0, Math.floor((termRows - 6) / 2));
937
976
  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 })] }));
977
+ 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
978
  }
940
979
  if (showPalette) {
941
980
  const paletteModalHeight = COMMANDS.length + 4;
942
981
  const paletteModalWidth = 52;
943
982
  const topPad = Math.max(0, Math.floor((termRows - paletteModalHeight) / 2));
944
983
  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 })] }));
984
+ 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
985
  }
947
986
  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) => {
987
+ 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
988
+ ? `${SPINNER[spinnerTick % SPINNER.length]} Thinking… ${((Date.now() - loadingStartedAtRef.current) /
989
+ 1000).toFixed(1)}s`
990
+ : "\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
991
  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) => {
992
+ 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));
993
+ })), _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
994
  const i = filteredFilePaths.length - 1 - rev;
953
995
  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..." })] })) : ((() => {
996
+ })), _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
997
  const lines = inputValue.split("\n");
956
998
  let lineStart = 0;
957
999
  return (_jsx(_Fragment, { children: lines.flatMap((lineText, lineIdx) => {
@@ -1062,7 +1104,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
1062
1104
  const isFirstRow = lineIdx === 0 && v === 0;
1063
1105
  const isLastLogicalLine = lineIdx === lines.length - 1;
1064
1106
  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}`));
1107
+ 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
1108
  });
1067
1109
  }) }));
1068
1110
  })()) })] })] }));
@@ -5,7 +5,7 @@ import { runBash } from "./bash.js";
5
5
  import { webFetch, webSearch } from "./web.js";
6
6
  export const TOOLS = {
7
7
  read: [
8
- "Read file with line numbers (file path, not directory). Use limit and offset to read a portion; avoid reading huge files in one go. Long output is truncated; use offset/limit to get more.",
8
+ "Read a portion of a file with line numbers (file path, not directory). Prefer offset and limit for anything beyond a small file; do not read entire large files in one call. Long output is truncated; use offset/limit to get more.",
9
9
  { path: "string", offset: "number?", limit: "number?" },
10
10
  readFile,
11
11
  ],
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.8",
3
+ "version": "1.2.0",
4
4
  "description": "CLI TUI for AI agents via OpenRouter — agentic loop, tools, markdown",
5
5
  "type": "module",
6
6
  "repository": {