ideacode 1.2.4 → 1.3.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
@@ -39,7 +39,15 @@ const MAX_TOOL_RESULT_CHARS = 3500;
39
39
  const MAX_AT_SUGGESTIONS = 12;
40
40
  const INITIAL_BANNER_LINES = 12;
41
41
  const ENABLE_PARALLEL_TOOL_CALLS = process.env.IDEACODE_PARALLEL_TOOL_CALLS !== "0";
42
- const PARALLEL_SAFE_TOOLS = new Set(["read", "glob", "grep", "web_fetch", "web_search"]);
42
+ const PARALLEL_SAFE_TOOLS = new Set([
43
+ "read",
44
+ "glob",
45
+ "grep",
46
+ "web_fetch",
47
+ "web_search",
48
+ "bash_status",
49
+ "bash_logs",
50
+ ]);
43
51
  const LOADING_TICK_MS = 80;
44
52
  const MAX_EMPTY_ASSISTANT_RETRIES = 3;
45
53
  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.)";
@@ -150,7 +158,7 @@ function summarizeBashCommand(cmdRaw) {
150
158
  return (shown.join(", ") + suffix).slice(0, 140);
151
159
  }
152
160
  function toolArgPreview(toolName, toolArgs) {
153
- if (toolName === "bash") {
161
+ if (toolName === "bash" || toolName === "bash_detach") {
154
162
  const cmd = String(toolArgs.cmd ?? "").trim();
155
163
  return cmd ? summarizeBashCommand(cmd) : "—";
156
164
  }
@@ -281,30 +289,6 @@ function orbitDots(frame) {
281
289
  .map((ch, i) => (i === activeIndex ? colors.gray(ch) : colors.mutedDark(ch)))
282
290
  .join("");
283
291
  }
284
- const LoadingStatus = React.memo(function LoadingStatus({ active, label, }) {
285
- const [frame, setFrame] = useState(0);
286
- const startedAtRef = useRef(null);
287
- useEffect(() => {
288
- if (!active) {
289
- startedAtRef.current = null;
290
- return;
291
- }
292
- if (startedAtRef.current == null) {
293
- startedAtRef.current = Date.now();
294
- setFrame(0);
295
- }
296
- const anim = setInterval(() => setFrame((n) => n + 1), LOADING_TICK_MS);
297
- return () => {
298
- clearInterval(anim);
299
- };
300
- }, [active]);
301
- if (!active)
302
- return _jsx(Text, { color: inkColors.textSecondary, children: "\u00A0" });
303
- const startedAt = startedAtRef.current ?? Date.now();
304
- const elapsedSeconds = Math.max(0, (Date.now() - startedAt) / 1000);
305
- const elapsedText = elapsedSeconds < 10 ? `${elapsedSeconds.toFixed(1)}s` : `${Math.floor(elapsedSeconds)}s`;
306
- return (_jsxs(Text, { color: inkColors.textSecondary, children: [" ", orbitDots(frame), " ", colors.gray(label), " ", colors.gray(elapsedText)] }));
307
- });
308
292
  export function Repl({ apiKey, cwd, onQuit }) {
309
293
  const { rows: termRows, columns: termColumns } = useTerminalSize();
310
294
  // Big ASCII art logo for ideacode
@@ -403,6 +387,10 @@ export function Repl({ apiKey, cwd, onQuit }) {
403
387
  }, [cwd, onQuit]);
404
388
  const [loading, setLoading] = useState(false);
405
389
  const [loadingLabel, setLoadingLabel] = useState("Thinking…");
390
+ const loadingActiveRef = useRef(false);
391
+ const loadingLabelRef = useRef(loadingLabel);
392
+ const loadingFooterLinesRef = useRef(2);
393
+ const loadingRenderRef = useRef(null);
406
394
  const cursorBlinkOn = true;
407
395
  const [showPalette, setShowPalette] = useState(false);
408
396
  const [paletteIndex, setPaletteIndex] = useState(0);
@@ -428,6 +416,55 @@ export function Repl({ apiKey, cwd, onQuit }) {
428
416
  process.stdout.write("\x1b[?1006l\x1b[?1000l");
429
417
  };
430
418
  }, []);
419
+ useEffect(() => {
420
+ loadingActiveRef.current = loading;
421
+ loadingLabelRef.current = loadingLabel;
422
+ if (!process.stdout.isTTY)
423
+ return;
424
+ const clearLoadingLine = () => {
425
+ const up = Math.max(1, loadingFooterLinesRef.current);
426
+ try {
427
+ writeSync(process.stdout.fd, `\x1b7\x1b[${up}A\r\x1b[2K\x1b8`);
428
+ }
429
+ catch {
430
+ // Best effort only.
431
+ }
432
+ };
433
+ if (!loading) {
434
+ if (loadingRenderRef.current) {
435
+ clearInterval(loadingRenderRef.current);
436
+ loadingRenderRef.current = null;
437
+ }
438
+ clearLoadingLine();
439
+ return;
440
+ }
441
+ const startedAt = Date.now();
442
+ let frame = 0;
443
+ const renderTick = () => {
444
+ if (!loadingActiveRef.current || !process.stdout.isTTY)
445
+ return;
446
+ const elapsedSeconds = Math.max(0, (Date.now() - startedAt) / 1000);
447
+ const elapsedText = elapsedSeconds < 10 ? `${elapsedSeconds.toFixed(1)}s` : `${Math.floor(elapsedSeconds)}s`;
448
+ const line = ` ${orbitDots(frame)} ${colors.gray(loadingLabelRef.current)} ${colors.gray(elapsedText)}`;
449
+ const up = Math.max(1, loadingFooterLinesRef.current);
450
+ try {
451
+ writeSync(process.stdout.fd, `\x1b7\x1b[${up}A\r\x1b[2K${line}\x1b8`);
452
+ }
453
+ catch {
454
+ // Best effort only.
455
+ }
456
+ frame = (frame + 1) % 6;
457
+ };
458
+ renderTick();
459
+ loadingRenderRef.current = setInterval(renderTick, LOADING_TICK_MS);
460
+ return () => {
461
+ if (loadingRenderRef.current) {
462
+ clearInterval(loadingRenderRef.current);
463
+ loadingRenderRef.current = null;
464
+ }
465
+ clearLoadingLine();
466
+ };
467
+ }, [loading, loadingLabel]);
431
468
  const estimatedTokens = useMemo(() => estimateTokens(messages, undefined), [messages]);
432
469
  const contextWindowK = useMemo(() => {
433
470
  const ctx = modelList.find((m) => m.id === currentModel)?.context_length;
@@ -614,7 +651,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
614
651
  appendLog(userPromptBox(userInput));
615
652
  appendLog("");
616
653
  let state = [...messages, { role: "user", content: userInput }];
617
- const systemPrompt = `Concise coding assistant. cwd: ${cwd}. PRIORITIZE grep to locate; then read with offset and limit to fetch only relevant sections. Do not read whole files unless the user explicitly asks. Use focused greps (specific patterns, narrow paths) and read in chunks when files are large; avoid one huge grep or read that floods context. When exploring a dependency, set path to that package (e.g. node_modules/<pkg>) and list/read only what you need. Prefer grep or keyword search for the most recent or specific occurrence; avoid tail/read of thousands of lines. If a tool result says it was truncated, call the tool again with offset, limit, or a narrower pattern to get what you need. Use as many parallel read/search/web tool calls as needed in one turn when they are independent (often more than 3 is appropriate for broad research), but keep each call high-signal, non-redundant, and minimal in output size. For bash tool calls, avoid decorative echo headers; run direct commands and keep commands concise.`;
654
+ const systemPrompt = `Concise coding assistant. cwd: ${cwd}. PRIORITIZE grep to locate; then read with offset and limit to fetch only relevant sections. Do not read whole files unless the user explicitly asks. Use focused greps (specific patterns, narrow paths) and read in chunks when files are large; avoid one huge grep or read that floods context. When exploring a dependency, set path to that package (e.g. node_modules/<pkg>) and list/read only what you need. Prefer grep or keyword search for the most recent or specific occurrence; avoid tail/read of thousands of lines. If a tool result says it was truncated, call the tool again with offset, limit, or a narrower pattern to get what you need. Use as many parallel read/search/web tool calls as needed in one turn when they are independent (often more than 3 is appropriate for broad research), but keep each call high-signal, non-redundant, and minimal in output size. For bash tool calls, avoid decorative echo headers; run direct commands and keep commands concise. For long-running commands, prefer bash_detach then poll with bash_status and bash_logs instead of blocking bash.`;
618
655
  const modelContext = modelList.find((m) => m.id === currentModel)?.context_length;
619
656
  const maxContextTokens = Math.floor((modelContext ?? CONTEXT_WINDOW_K * 1024) * 0.85);
620
657
  const stateBeforeCompress = state;
@@ -1173,7 +1210,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
1173
1210
  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 })] }));
1174
1211
  }
1175
1212
  const footerLines = suggestionBoxLines + 1 + stableInputLineCount;
1176
- return (_jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: [_jsx(LogViewport, { lines: visibleLogLines, startIndex: logStartIndex, height: logViewportHeight }), _jsx(Box, { flexDirection: "row", marginTop: 1, marginBottom: 0, children: _jsx(LoadingStatus, { active: loading, label: loadingLabel }) })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, height: footerLines, children: [showSlashSuggestions && (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 2, borderStyle: "single", borderColor: inkColors.textDisabled, children: [filteredSlashCommands.length === 0 ? (_jsx(Text, { color: inkColors.textSecondary, children: " No match " })) : ([...filteredSlashCommands].reverse().map((c, rev) => {
1213
+ loadingFooterLinesRef.current = footerLines;
1214
+ return (_jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: [_jsx(LogViewport, { lines: visibleLogLines, startIndex: logStartIndex, height: logViewportHeight }), _jsx(Box, { flexDirection: "row", marginTop: 1, marginBottom: 0, children: _jsx(Text, { color: inkColors.textSecondary, children: "\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) => {
1177
1215
  const i = filteredSlashCommands.length - 1 - rev;
1178
1216
  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));
1179
1217
  })), _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) => {
@@ -1,4 +1,73 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { closeSync, openSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ const DEFAULT_TIMEOUT_MS = 30_000;
7
+ const MIN_TIMEOUT_MS = 1_000;
8
+ const MAX_TIMEOUT_MS = 60 * 60 * 1000;
9
+ const TERM_GRACE_MS = 2_000;
10
+ const JOBS_DIR = path.join(tmpdir(), "ideacode-jobs");
11
+ const JOBS_DB_PATH = path.join(JOBS_DIR, "jobs.json");
12
+ function clampTimeoutMs(value) {
13
+ if (!Number.isFinite(value))
14
+ return DEFAULT_TIMEOUT_MS;
15
+ return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, Math.round(value)));
16
+ }
17
+ function resolveTimeoutMs(args) {
18
+ const fromArgs = Number(args.timeout_ms);
19
+ if (Number.isFinite(fromArgs) && fromArgs > 0)
20
+ return clampTimeoutMs(fromArgs);
21
+ const fromEnv = Number.parseInt(process.env.IDEACODE_BASH_TIMEOUT_MS ?? "", 10);
22
+ if (Number.isFinite(fromEnv) && fromEnv > 0)
23
+ return clampTimeoutMs(fromEnv);
24
+ return DEFAULT_TIMEOUT_MS;
25
+ }
26
+ function shSingleQuote(v) {
27
+ return `'${v.replace(/'/g, `'\\''`)}'`;
28
+ }
29
+ function makeJobId() {
30
+ const rand = Math.random().toString(36).slice(2, 8);
31
+ return `job_${Date.now().toString(36)}_${rand}`;
32
+ }
33
+ async function ensureJobsDir() {
34
+ await mkdir(JOBS_DIR, { recursive: true });
35
+ }
36
+ async function loadJobs() {
37
+ try {
38
+ const raw = await readFile(JOBS_DB_PATH, "utf8");
39
+ const parsed = JSON.parse(raw);
40
+ return parsed && typeof parsed === "object" ? parsed : {};
41
+ }
42
+ catch {
43
+ return {};
44
+ }
45
+ }
46
+ async function saveJobs(db) {
47
+ await ensureJobsDir();
48
+ await writeFile(JOBS_DB_PATH, JSON.stringify(db, null, 2), "utf8");
49
+ }
50
+ function isPidRunning(pid) {
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ async function readExitCode(statusPath) {
60
+ try {
61
+ const raw = await readFile(statusPath, "utf8");
62
+ const m = raw.match(/EXIT_CODE:(-?\d+)/);
63
+ if (!m)
64
+ return null;
65
+ return Number.parseInt(m[1] ?? "", 10);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
2
71
  export function runBash(args) {
3
72
  return new Promise((resolve) => {
4
73
  const cmd = args.cmd ?? "";
@@ -14,14 +83,97 @@ export function runBash(args) {
14
83
  const onData = (s) => outputChunks.push(s);
15
84
  proc.stdout?.on("data", (chunk) => onData(chunk.toString()));
16
85
  proc.stderr?.on("data", (chunk) => onData(chunk.toString()));
86
+ const timeoutMs = resolveTimeoutMs(args);
17
87
  const t = setTimeout(() => {
18
- proc.kill("SIGKILL");
19
- outputChunks.push("\n(timed out after 30s)");
20
- done(outputChunks.join("").trim() || "(empty)");
21
- }, 30_000);
88
+ proc.kill("SIGTERM");
89
+ outputChunks.push(`\n(timeout ${Math.round(timeoutMs / 1000)}s reached, sending SIGTERM…)`);
90
+ const killTimer = setTimeout(() => {
91
+ proc.kill("SIGKILL");
92
+ outputChunks.push("\n(process did not exit after SIGTERM; sent SIGKILL)");
93
+ }, TERM_GRACE_MS);
94
+ proc.on("close", () => clearTimeout(killTimer));
95
+ }, timeoutMs);
22
96
  proc.on("close", () => {
23
97
  clearTimeout(t);
24
98
  done(outputChunks.join("").trim() || "(empty)");
25
99
  });
26
100
  });
27
101
  }
102
+ export async function runBashDetach(args) {
103
+ const cmd = String(args.cmd ?? "").trim();
104
+ if (!cmd)
105
+ return "error: missing cmd";
106
+ await ensureJobsDir();
107
+ const id = makeJobId();
108
+ const cwd = process.cwd();
109
+ const logPath = path.join(JOBS_DIR, `${id}.log`);
110
+ const statusPath = path.join(JOBS_DIR, `${id}.status`);
111
+ await appendFile(logPath, `# ideacode detached job ${id}\n# cwd: ${cwd}\n# cmd: ${cmd}\n\n`);
112
+ const wrapped = [
113
+ cmd,
114
+ "__ec=$?",
115
+ `printf "EXIT_CODE:%s\\n" "$__ec" > ${shSingleQuote(statusPath)}`,
116
+ "exit $__ec",
117
+ ].join("\n");
118
+ const logFd = openSync(logPath, "a");
119
+ const proc = spawn("/bin/sh", ["-lc", wrapped], {
120
+ cwd,
121
+ detached: true,
122
+ stdio: ["ignore", logFd, logFd],
123
+ });
124
+ closeSync(logFd);
125
+ proc.unref();
126
+ const db = await loadJobs();
127
+ db[id] = {
128
+ id,
129
+ cmd,
130
+ pid: proc.pid ?? -1,
131
+ cwd,
132
+ startedAt: new Date().toISOString(),
133
+ logPath,
134
+ statusPath,
135
+ };
136
+ await saveJobs(db);
137
+ return `ok: ${id} pid=${proc.pid ?? -1}\nlog=${logPath}\nuse bash_status(job_id=${id}) or bash_logs(job_id=${id})`;
138
+ }
139
+ export async function runBashStatus(args) {
140
+ const jobId = String(args.job_id ?? "").trim();
141
+ if (!jobId)
142
+ return "error: missing job_id";
143
+ const db = await loadJobs();
144
+ const job = db[jobId];
145
+ if (!job)
146
+ return `error: unknown job_id ${jobId}`;
147
+ const running = job.pid > 0 ? isPidRunning(job.pid) : false;
148
+ const exitCode = await readExitCode(job.statusPath);
149
+ const status = running ? "running" : exitCode == null ? "unknown (not running, no exit code)" : `finished (exit=${exitCode})`;
150
+ return [
151
+ `job_id: ${job.id}`,
152
+ `status: ${status}`,
153
+ `pid: ${job.pid}`,
154
+ `started_at: ${job.startedAt}`,
155
+ `cwd: ${job.cwd}`,
156
+ `log: ${job.logPath}`,
157
+ ].join("\n");
158
+ }
159
+ export async function runBashLogs(args) {
160
+ const jobId = String(args.job_id ?? "").trim();
161
+ if (!jobId)
162
+ return "error: missing job_id";
163
+ const tailLinesRaw = Number(args.tail_lines);
164
+ const tailLines = Number.isFinite(tailLinesRaw)
165
+ ? Math.max(1, Math.min(500, Math.round(tailLinesRaw)))
166
+ : 80;
167
+ const db = await loadJobs();
168
+ const job = db[jobId];
169
+ if (!job)
170
+ return `error: unknown job_id ${jobId}`;
171
+ try {
172
+ const content = await readFile(job.logPath, "utf8");
173
+ const lines = content.split(/\r?\n/);
174
+ return lines.slice(Math.max(0, lines.length - tailLines)).join("\n").trim() || "(empty)";
175
+ }
176
+ catch (err) {
177
+ return `error: failed to read logs for ${jobId}: ${err instanceof Error ? err.message : String(err)}`;
178
+ }
179
+ }
@@ -1,7 +1,7 @@
1
1
  import { getBraveSearchApiKey } from "../config.js";
2
2
  import { readFile, writeFile, editFile } from "./file.js";
3
3
  import { globFiles, grepFiles } from "./search.js";
4
- import { runBash } from "./bash.js";
4
+ import { runBash, runBashDetach, runBashLogs, runBashStatus } from "./bash.js";
5
5
  import { webFetch, webSearch } from "./web.js";
6
6
  export const TOOLS = {
7
7
  read: [
@@ -26,10 +26,25 @@ export const TOOLS = {
26
26
  grepFiles,
27
27
  ],
28
28
  bash: [
29
- "Run shell command. Use for things the other tools don't cover (e.g. running tests, installs, one-off commands, ephemeral << PY scripts, etc.). Always avoid dump outputs. Prefer read/grep/glob for file content and search; use targeted commands and avoid dumping huge output.",
30
- { cmd: "string" },
29
+ "Run shell command. Supports optional timeout_ms (default 30000). Use for things other tools don't cover. Prefer targeted commands and avoid huge outputs.",
30
+ { cmd: "string", timeout_ms: "number?" },
31
31
  runBash,
32
32
  ],
33
+ bash_detach: [
34
+ "Start a long-running shell command in the background and return a job_id for later polling.",
35
+ { cmd: "string" },
36
+ runBashDetach,
37
+ ],
38
+ bash_status: [
39
+ "Check status of a detached bash job.",
40
+ { job_id: "string" },
41
+ runBashStatus,
42
+ ],
43
+ bash_logs: [
44
+ "Read recent log lines from a detached bash job.",
45
+ { job_id: "string", tail_lines: "number?" },
46
+ runBashLogs,
47
+ ],
33
48
  web_fetch: [
34
49
  "Fetch a URL and return the main text content (handles JS-rendered pages). Use for docs, raw GitHub, any web page.",
35
50
  { url: "string" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ideacode",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "description": "CLI TUI for AI agents via OpenRouter — agentic loop, tools, markdown",
5
5
  "type": "module",
6
6
  "repository": {