ideacode 1.0.3 → 1.1.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/config.js CHANGED
@@ -7,6 +7,9 @@ const CONFIG_DIR = process.env.XDG_CONFIG_HOME
7
7
  ? path.join(process.env.LOCALAPPDATA ?? os.homedir(), "ideacode")
8
8
  : path.join(os.homedir(), ".config", "ideacode");
9
9
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
10
+ export function getConfigDir() {
11
+ return CONFIG_DIR;
12
+ }
10
13
  function loadConfigFile() {
11
14
  try {
12
15
  const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
@@ -0,0 +1,30 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getConfigDir } from "./config.js";
5
+ function hashCwd(cwd) {
6
+ return crypto.createHash("sha256").update(cwd, "utf8").digest("hex").slice(0, 16);
7
+ }
8
+ export function getConversationPath(cwd) {
9
+ const dir = path.join(getConfigDir(), "conversations");
10
+ return path.join(dir, `${hashCwd(cwd)}.json`);
11
+ }
12
+ export function loadConversation(cwd) {
13
+ const filePath = getConversationPath(cwd);
14
+ try {
15
+ const raw = fs.readFileSync(filePath, "utf-8");
16
+ const data = JSON.parse(raw);
17
+ if (!Array.isArray(data))
18
+ return [];
19
+ return data;
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ }
25
+ export function saveConversation(cwd, messages) {
26
+ const filePath = getConversationPath(cwd);
27
+ const dir = path.dirname(filePath);
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ fs.writeFileSync(filePath, JSON.stringify(messages), "utf-8");
30
+ }
package/dist/repl.js CHANGED
@@ -10,11 +10,12 @@ import gradient from "gradient-string";
10
10
  // Custom matcha-themed gradient: matcha green → dark sepia
11
11
  const matchaGradient = gradient(["#7F9A65", "#5C4033"]);
12
12
  import { getModel, saveModel, saveBraveSearchApiKey, getBraveSearchApiKey } from "./config.js";
13
+ import { loadConversation, saveConversation } from "./conversation.js";
13
14
  import { callApi, fetchModels } from "./api.js";
14
15
  import { estimateTokens, ensureUnderBudget } from "./context.js";
15
16
  import { runTool } from "./tools/index.js";
16
17
  import { COMMANDS, matchCommand, resolveCommand } from "./commands.js";
17
- import { colors, icons, separator, agentMessage, toolCallBox, toolResultLine, inkColors, } from "./ui/index.js";
18
+ import { colors, icons, separator, agentMessage, toolCallBox, toolResultLine, userPromptBox, inkColors, } from "./ui/index.js";
18
19
  function wordStartBackward(value, cursor) {
19
20
  let i = cursor - 1;
20
21
  while (i >= 0 && /\s/.test(value[i]))
@@ -30,8 +31,15 @@ function wordEndForward(value, cursor) {
30
31
  return i;
31
32
  }
32
33
  const CONTEXT_WINDOW_K = 128;
34
+ const MAX_TOOL_RESULT_CHARS = 3500;
33
35
  const MAX_AT_SUGGESTIONS = 12;
34
36
  const INITIAL_BANNER_LINES = 12;
37
+ 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.)";
38
+ function truncateToolResult(content) {
39
+ if (content.length <= MAX_TOOL_RESULT_CHARS)
40
+ return content;
41
+ return content.slice(0, MAX_TOOL_RESULT_CHARS) + TRUNCATE_NOTE;
42
+ }
35
43
  const isMac = process.platform === "darwin";
36
44
  const pasteShortcut = isMac ? "Cmd+V" : "Ctrl+V";
37
45
  function listFilesWithFilter(cwd, filter) {
@@ -76,6 +84,46 @@ function wrapLine(line, width) {
76
84
  }
77
85
  return out.length > 0 ? out : [""];
78
86
  }
87
+ function replayMessagesToLogLines(messages) {
88
+ const lines = [];
89
+ for (let i = 0; i < messages.length; i++) {
90
+ const msg = messages[i];
91
+ if (msg.role === "user") {
92
+ if (typeof msg.content === "string") {
93
+ lines.push(...userPromptBox(msg.content).split("\n"), "");
94
+ }
95
+ else if (Array.isArray(msg.content)) {
96
+ const prev = messages[i - 1];
97
+ const toolResults = msg.content;
98
+ if (prev?.role === "assistant" && Array.isArray(prev.content)) {
99
+ const blocks = prev.content;
100
+ const toolUses = blocks.filter((b) => b.type === "tool_use");
101
+ for (const tr of toolResults) {
102
+ const block = toolUses.find((b) => b.id === tr.tool_use_id);
103
+ if (block?.name) {
104
+ const firstVal = block.input && typeof block.input === "object" ? Object.values(block.input)[0] : undefined;
105
+ const argPreview = String(firstVal ?? "").slice(0, 50);
106
+ const ok = !(tr.content ?? "").startsWith("error:");
107
+ lines.push(toolCallBox(block.name, argPreview, ok));
108
+ const preview = (tr.content ?? "").split("\n")[0]?.slice(0, 60) ?? "";
109
+ lines.push(toolResultLine(preview, ok));
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ else if (msg.role === "assistant" && Array.isArray(msg.content)) {
116
+ const blocks = msg.content;
117
+ for (const block of blocks) {
118
+ if (block.type === "text" && block.text) {
119
+ lines.push("");
120
+ lines.push(...agentMessage(block.text).trimEnd().split("\n"));
121
+ }
122
+ }
123
+ }
124
+ }
125
+ return lines;
126
+ }
79
127
  function useTerminalSize() {
80
128
  const { stdout } = useStdout();
81
129
  const [size, setSize] = useState(() => ({
@@ -117,7 +165,45 @@ export function Repl({ apiKey, cwd, onQuit }) {
117
165
  });
118
166
  const [inputValue, setInputValue] = useState("");
119
167
  const [currentModel, setCurrentModel] = useState(getModel);
120
- const [messages, setMessages] = useState([]);
168
+ const [messages, setMessages] = useState(() => loadConversation(cwd));
169
+ const messagesRef = useRef(messages);
170
+ const hasRestoredLogRef = useRef(false);
171
+ useEffect(() => {
172
+ messagesRef.current = messages;
173
+ }, [messages]);
174
+ useEffect(() => {
175
+ if (messages.length > 0 && !hasRestoredLogRef.current) {
176
+ hasRestoredLogRef.current = true;
177
+ const model = getModel();
178
+ const banner = [
179
+ "",
180
+ matchaGradient(bigLogo),
181
+ colors.accent(` ${model}`) + colors.dim(" · ") + colors.accentPale("OpenRouter") + colors.dim(` · ${cwd}`),
182
+ colors.mutedDark(" / commands ! shell @ files · Ctrl+P palette · Ctrl+C or /q to quit"),
183
+ "",
184
+ ];
185
+ setLogLines([...banner, ...replayMessagesToLogLines(messages)]);
186
+ }
187
+ }, [messages, cwd]);
188
+ const saveDebounceRef = useRef(null);
189
+ useEffect(() => {
190
+ if (saveDebounceRef.current)
191
+ clearTimeout(saveDebounceRef.current);
192
+ saveDebounceRef.current = setTimeout(() => {
193
+ saveDebounceRef.current = null;
194
+ saveConversation(cwd, messages);
195
+ }, 500);
196
+ return () => {
197
+ if (saveDebounceRef.current)
198
+ clearTimeout(saveDebounceRef.current);
199
+ };
200
+ }, [cwd, messages]);
201
+ const handleQuit = useCallback(() => {
202
+ if (saveDebounceRef.current)
203
+ clearTimeout(saveDebounceRef.current);
204
+ saveConversation(cwd, messagesRef.current);
205
+ onQuit();
206
+ }, [cwd, onQuit]);
121
207
  const [loading, setLoading] = useState(false);
122
208
  const [showPalette, setShowPalette] = useState(false);
123
209
  const [paletteIndex, setPaletteIndex] = useState(0);
@@ -133,7 +219,6 @@ export function Repl({ apiKey, cwd, onQuit }) {
133
219
  const skipNextSubmitRef = useRef(false);
134
220
  const queuedMessageRef = useRef(null);
135
221
  const lastUserMessageRef = useRef("");
136
- const [lastUserPrompt, setLastUserPrompt] = useState("");
137
222
  const [logScrollOffset, setLogScrollOffset] = useState(0);
138
223
  const prevEscRef = useRef(false);
139
224
  const [spinnerTick, setSpinnerTick] = useState(0);
@@ -296,9 +381,10 @@ export function Repl({ apiKey, cwd, onQuit }) {
296
381
  setLogScrollOffset(0);
297
382
  }
298
383
  lastUserMessageRef.current = userInput;
299
- setLastUserPrompt(userInput);
384
+ appendLog(userPromptBox(userInput));
385
+ appendLog("");
300
386
  let state = [...messages, { role: "user", content: userInput }];
301
- 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.`;
387
+ 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.`;
302
388
  const modelContext = modelList.find((m) => m.id === currentModel)?.context_length;
303
389
  const maxContextTokens = Math.floor((modelContext ?? CONTEXT_WINDOW_K * 1024) * 0.85);
304
390
  const stateBeforeCompress = state;
@@ -337,7 +423,8 @@ export function Repl({ apiKey, cwd, onQuit }) {
337
423
  preview += "...";
338
424
  appendLog(toolResultLine(preview, ok));
339
425
  if (block.id) {
340
- toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result });
426
+ const contentForApi = truncateToolResult(result);
427
+ toolResults.push({ type: "tool_result", tool_use_id: block.id, content: contentForApi });
341
428
  }
342
429
  }
343
430
  }
@@ -380,7 +467,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
380
467
  try {
381
468
  const cont = await processInput(value);
382
469
  if (!cont) {
383
- onQuit();
470
+ handleQuit();
384
471
  return;
385
472
  }
386
473
  const queued = queuedMessageRef.current;
@@ -393,7 +480,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
393
480
  appendLog(colors.error(`${icons.error} ${err instanceof Error ? err.message : String(err)}`));
394
481
  appendLog("");
395
482
  }
396
- }, [processInput, onQuit, appendLog, openModelSelector, openBraveKeyModal, openHelpModal]);
483
+ }, [processInput, handleQuit, appendLog, openModelSelector, openBraveKeyModal, openHelpModal]);
397
484
  useInput((input, key) => {
398
485
  if (showHelpModal) {
399
486
  setShowHelpModal(false);
@@ -463,7 +550,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
463
550
  setShowPalette(false);
464
551
  processInput(selected.cmd).then((cont) => {
465
552
  if (!cont)
466
- onQuit();
553
+ handleQuit();
467
554
  }).catch((err) => {
468
555
  appendLog(colors.error(`${icons.error} ${err instanceof Error ? err.message : String(err)}`));
469
556
  appendLog("");
@@ -514,7 +601,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
514
601
  }
515
602
  processInput(selected.cmd).then((cont) => {
516
603
  if (!cont)
517
- onQuit();
604
+ handleQuit();
518
605
  }).catch((err) => {
519
606
  appendLog(colors.error(`${icons.error} ${err instanceof Error ? err.message : String(err)}`));
520
607
  appendLog("");
@@ -710,7 +797,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
710
797
  setShowPalette(true);
711
798
  }
712
799
  if (key.ctrl && input === "c") {
713
- onQuit();
800
+ handleQuit();
714
801
  }
715
802
  });
716
803
  if (showModelSelector) {
@@ -739,11 +826,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
739
826
  const lines = inputValue.split("\n");
740
827
  return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / wrapWidth)), 0);
741
828
  })();
742
- const lastPromptLineCount = lastUserPrompt ? lastUserPrompt.split("\n").length : 0;
743
- const lastPromptLines = lastUserPrompt
744
- ? (lastPromptLineCount > 3 ? 4 : lastPromptLineCount)
745
- : 0;
746
- const reservedLines = 1 + lastPromptLines + inputLineCount + (loading ? 2 : 1);
829
+ const reservedLines = 1 + inputLineCount + (loading ? 2 : 1);
747
830
  const logViewportHeight = Math.max(1, termRows - reservedLines - suggestionBoxLines);
748
831
  const maxLogScrollOffset = Math.max(0, logLines.length - logViewportHeight);
749
832
  const logStartIndex = Math.max(0, logLines.length - logViewportHeight - Math.min(logScrollOffset, maxLogScrollOffset));
@@ -768,12 +851,7 @@ export function Repl({ apiKey, cwd, onQuit }) {
768
851
  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 })] }));
769
852
  }
770
853
  const footerLines = suggestionBoxLines + 1 + inputLineCount;
771
- return (_jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: [lastUserPrompt ? (_jsx(Box, { flexDirection: "column", children: (() => {
772
- const lines = lastUserPrompt.split("\n");
773
- const showLines = lines.slice(0, 3);
774
- const hasMore = lines.length > 3;
775
- return (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: inkColors.primary, dimColor: true, children: [icons.prompt, " Last:", " "] }), _jsx(Text, { dimColor: true, color: "gray", children: showLines[0] ?? "" })] }), showLines.slice(1).map((ln, i) => (_jsx(Box, { flexDirection: "row", paddingLeft: 8, children: _jsx(Text, { dimColor: true, color: "gray", children: ln }) }, i))), hasMore && (_jsx(Box, { flexDirection: "row", paddingLeft: 8, children: _jsx(Text, { dimColor: true, color: "gray", children: "\u2026" }) }))] }));
776
- })() })) : null, _jsx(Box, { flexDirection: "column", height: logViewportHeight, overflow: "hidden", children: visibleLogLines.map((line, i) => (_jsx(Text, { children: line === "" ? "\u00A0" : line }, logLines.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) => {
854
+ 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 }, logLines.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) => {
777
855
  const i = filteredSlashCommands.length - 1 - rev;
778
856
  return (_jsxs(Text, { color: i === clampedSlashIndex ? inkColors.primary : undefined, children: [i === clampedSlashIndex ? "› " : " ", c.cmd, _jsxs(Text, { color: "gray", children: [" \u2014 ", c.desc] })] }, c.cmd));
779
857
  })), _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) => {
@@ -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 to read a portion; avoid reading huge files in one go.",
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.",
9
9
  { path: "string", offset: "number?", limit: "number?" },
10
10
  readFile,
11
11
  ],
@@ -21,11 +21,15 @@ export const TOOLS = {
21
21
  globFiles,
22
22
  ],
23
23
  grep: [
24
- "Search files for regex. With path '.' (default), .gitignore entries are excluded; use path node_modules/<pkg> to search one package. Prefer specific patterns and narrow path. Returns at most limit matches (default 50, max 100).",
24
+ "Search files for regex. Prefer specific patterns and narrow path; search for the most recent or relevant occurrence by keyword. With path '.' (default), .gitignore entries are excluded; use path node_modules/<pkg> to search one package. Returns at most limit matches (default 50, max 100). Long output is truncated.",
25
25
  { pat: "string", path: "string?", limit: "number?" },
26
26
  grepFiles,
27
27
  ],
28
- bash: ["Run shell command", { cmd: "string" }, runBash],
28
+ bash: [
29
+ "Run shell command. Prefer targeted commands (e.g. grep, head, tail with small line count); avoid tail -1000 or dumping huge output.",
30
+ { cmd: "string" },
31
+ runBash,
32
+ ],
29
33
  web_fetch: [
30
34
  "Fetch a URL and return the main text content (handles JS-rendered pages). Use for docs, raw GitHub, any web page.",
31
35
  { url: "string" },
package/dist/ui/format.js CHANGED
@@ -40,6 +40,41 @@ export function header(title, subtitle) {
40
40
  borderStyle: "round",
41
41
  });
42
42
  }
43
+ function wrapToWidth(text, width) {
44
+ const lines = [];
45
+ for (const line of text.split("\n")) {
46
+ if (line.length <= width) {
47
+ lines.push(line);
48
+ continue;
49
+ }
50
+ let rest = line;
51
+ while (rest.length > 0) {
52
+ if (rest.length <= width) {
53
+ lines.push(rest);
54
+ break;
55
+ }
56
+ const chunk = rest.slice(0, width);
57
+ const lastSpace = chunk.lastIndexOf(" ");
58
+ const breakAt = lastSpace > width >> 1 ? lastSpace : width;
59
+ lines.push(rest.slice(0, breakAt).trimEnd());
60
+ rest = rest.slice(breakAt).trimStart();
61
+ }
62
+ }
63
+ return lines.join("\n");
64
+ }
65
+ export function userPromptBox(prompt) {
66
+ const cols = process.stdout.columns ?? 80;
67
+ const boxWidth = Math.max(20, cols - 4);
68
+ const innerWidth = boxWidth - 2 - 2;
69
+ const text = wrapToWidth((prompt.trim() || "\u00A0"), innerWidth);
70
+ return boxen(text, {
71
+ width: boxWidth,
72
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
73
+ margin: { bottom: 0 },
74
+ borderColor: inkColors.primary,
75
+ borderStyle: "round",
76
+ });
77
+ }
43
78
  const TOOL_INDENT = " ";
44
79
  const toolSubdued = chalk.hex("#3d3d3d");
45
80
  export function toolCallBox(toolName, argPreview, success = true) {
package/dist/ui/index.js CHANGED
@@ -1 +1 @@
1
- export { colors, icons, theme, inkColors, separator, agentMessage, toolCallBox, toolResultLine, bashOutputLine, renderMarkdown, header, } from "./format.js";
1
+ export { colors, icons, theme, inkColors, separator, agentMessage, toolCallBox, toolResultLine, userPromptBox, bashOutputLine, renderMarkdown, header, } from "./format.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ideacode",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "CLI TUI for AI agents via OpenRouter — agentic loop, tools, markdown",
5
5
  "type": "module",
6
6
  "repository": {