@zhongqian97-code/ecode 0.0.5 → 0.0.7

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.
Files changed (2) hide show
  1. package/dist/index.js +664 -137
  2. package/package.json +5 -2
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ const _ew=process.emitWarning.bind(process);process.emitWarning=function(w,...a){if((w?.message??w)?.includes?.('punycode'))return;_ew(w,...a);};
2
3
 
3
4
  // src/index.ts
4
5
  import { createRequire } from "module";
6
+ import React4 from "react";
7
+ import { render } from "ink";
5
8
 
6
9
  // src/config.ts
7
10
  import { existsSync, readFileSync } from "fs";
@@ -10,7 +13,25 @@ import { join } from "path";
10
13
  var DEFAULTS = {
11
14
  baseUrl: "https://api.openai.com/v1",
12
15
  apiKey: "",
13
- model: "gpt-4o"
16
+ model: "gpt-4o",
17
+ dangerousPatterns: [
18
+ "rm -rf",
19
+ "sudo",
20
+ "chmod",
21
+ "chown",
22
+ "mkfs",
23
+ "dd",
24
+ "fdisk",
25
+ "kill",
26
+ "pkill",
27
+ "killall",
28
+ "reboot",
29
+ "shutdown",
30
+ "halt",
31
+ "curl -X DELETE",
32
+ "wget --delete-after"
33
+ ],
34
+ logDir: void 0
14
35
  };
15
36
  function loadConfig() {
16
37
  const configPath = join(homedir(), ".ecode", "config.json");
@@ -25,58 +46,17 @@ function loadConfig() {
25
46
  return {
26
47
  baseUrl: process.env.ECODE_BASE_URL ?? fileConfig.baseUrl ?? DEFAULTS.baseUrl,
27
48
  apiKey: process.env.ECODE_API_KEY ?? fileConfig.apiKey ?? DEFAULTS.apiKey,
28
- model: process.env.ECODE_MODEL ?? fileConfig.model ?? DEFAULTS.model
49
+ model: process.env.ECODE_MODEL ?? fileConfig.model ?? DEFAULTS.model,
50
+ // dangerousPatterns: 文件配置覆盖默认值,不支持环境变量(命令数组不适合通过环境变量传递)
51
+ dangerousPatterns: fileConfig.dangerousPatterns ?? DEFAULTS.dangerousPatterns,
52
+ // logDir: ECODE_LOG_DIR > 文件配置 > undefined
53
+ logDir: process.env.ECODE_LOG_DIR ?? fileConfig.logDir ?? DEFAULTS.logDir
29
54
  };
30
55
  }
31
56
 
32
- // src/repl.ts
33
- import * as readline from "readline/promises";
34
-
35
- // src/safety.ts
36
- var ALLOWLIST = [
37
- "ls",
38
- "cat",
39
- "pwd",
40
- "echo",
41
- "head",
42
- "tail",
43
- "wc",
44
- "date",
45
- "whoami",
46
- "which",
47
- "env",
48
- "printenv"
49
- ];
50
- var DANGER_LIST = [
51
- "rm -rf",
52
- "sudo",
53
- "chmod",
54
- "chown",
55
- "mkfs",
56
- "dd",
57
- "fdisk",
58
- "kill",
59
- "pkill",
60
- "killall",
61
- "reboot",
62
- "shutdown",
63
- "halt",
64
- "curl -X DELETE",
65
- "wget --delete-after"
66
- ];
67
- function matchesEntry(cmd, entry) {
68
- return cmd === entry || cmd.startsWith(entry + " ");
69
- }
70
- function classifyCommand(cmd) {
71
- const trimmed = cmd.trim();
72
- for (const entry of DANGER_LIST) {
73
- if (matchesEntry(trimmed, entry)) return "danger";
74
- }
75
- for (const entry of ALLOWLIST) {
76
- if (matchesEntry(trimmed, entry)) return "allow";
77
- }
78
- return "normal";
79
- }
57
+ // src/ui/App.tsx
58
+ import { useState as useState3, useCallback, useRef, useEffect as useEffect3 } from "react";
59
+ import { Box as Box4, useInput as useInput2 } from "ink";
80
60
 
81
61
  // src/llm.ts
82
62
  import OpenAI from "openai";
@@ -92,12 +72,15 @@ function createLLMClient(config2) {
92
72
  const requestParams = {
93
73
  model: config2.model,
94
74
  messages,
95
- stream: true
75
+ stream: true,
76
+ stream_options: { include_usage: true }
96
77
  };
97
78
  if (tools && tools.length > 0) {
98
79
  requestParams.tools = tools;
99
80
  }
100
- const response = await openai.chat.completions.create(requestParams);
81
+ const response = await openai.chat.completions.create(
82
+ requestParams
83
+ );
101
84
  const tcAccumulator = /* @__PURE__ */ new Map();
102
85
  let reasoningAccumulator = "";
103
86
  for await (const chunk of response) {
@@ -124,12 +107,18 @@ function createLLMClient(config2) {
124
107
  }
125
108
  const isLast = choice.finish_reason !== null;
126
109
  if (isLast) {
110
+ const rawUsage = chunk.usage;
127
111
  yield {
128
112
  text: delta.content ?? "",
129
113
  done: true,
130
114
  finishReason: choice.finish_reason,
131
115
  toolCalls: tcAccumulator.size > 0 ? Array.from(tcAccumulator.values()) : void 0,
132
- reasoning: reasoningAccumulator || void 0
116
+ reasoning: reasoningAccumulator || void 0,
117
+ usage: rawUsage ? {
118
+ promptTokens: rawUsage.prompt_tokens,
119
+ completionTokens: rawUsage.completion_tokens,
120
+ totalTokens: rawUsage.total_tokens
121
+ } : void 0
133
122
  };
134
123
  } else {
135
124
  yield {
@@ -145,6 +134,56 @@ function createLLMClient(config2) {
145
134
  };
146
135
  }
147
136
 
137
+ // src/repl.ts
138
+ import * as readline from "readline/promises";
139
+
140
+ // src/safety.ts
141
+ var ALLOWLIST = [
142
+ "ls",
143
+ "cat",
144
+ "pwd",
145
+ "echo",
146
+ "head",
147
+ "tail",
148
+ "wc",
149
+ "date",
150
+ "whoami",
151
+ "which",
152
+ "env",
153
+ "printenv"
154
+ ];
155
+ var DEFAULT_DANGER_LIST = [
156
+ "rm -rf",
157
+ "sudo",
158
+ "chmod",
159
+ "chown",
160
+ "mkfs",
161
+ "dd",
162
+ "fdisk",
163
+ "kill",
164
+ "pkill",
165
+ "killall",
166
+ "reboot",
167
+ "shutdown",
168
+ "halt",
169
+ "curl -X DELETE",
170
+ "wget --delete-after"
171
+ ];
172
+ function matchesEntry(cmd, entry) {
173
+ return cmd === entry || cmd.startsWith(entry + " ");
174
+ }
175
+ function classifyCommand(cmd, dangerPatterns) {
176
+ const trimmed = cmd.trim();
177
+ const patterns = dangerPatterns ?? DEFAULT_DANGER_LIST;
178
+ for (const entry of patterns) {
179
+ if (matchesEntry(trimmed, entry)) return "danger";
180
+ }
181
+ for (const entry of ALLOWLIST) {
182
+ if (matchesEntry(trimmed, entry)) return "allow";
183
+ }
184
+ return "normal";
185
+ }
186
+
148
187
  // src/tools/bash.ts
149
188
  import { exec } from "child_process";
150
189
  var DEFAULT_TIMEOUT_MS = 3e4;
@@ -178,12 +217,14 @@ var BASH_TOOL = {
178
217
  }
179
218
  };
180
219
  async function handleBashTool(command, deps) {
181
- const { confirm, print } = deps;
182
- const cls = classifyCommand(command);
220
+ const { confirm, print, dangerousPatterns, autoApproveNormal } = deps;
221
+ const cls = classifyCommand(command, dangerousPatterns);
183
222
  if (cls === "normal") {
184
- const ok = await confirm(`Execute command: ${command}
223
+ if (!autoApproveNormal) {
224
+ const ok = await confirm(`Execute command: ${command}
185
225
  Proceed? (y/n) `);
186
- if (!ok) return SKIP_MESSAGE;
226
+ if (!ok) return SKIP_MESSAGE;
227
+ }
187
228
  } else if (cls === "danger") {
188
229
  print(`\u26A0\uFE0F DANGEROUS COMMAND: ${command}`);
189
230
  const first = await confirm("Are you sure? (y/n) ");
@@ -201,107 +242,593 @@ Proceed? (y/n) `);
201
242
  [exit code: ${result.exitCode}]`;
202
243
  return output || "(no output)";
203
244
  }
204
- async function startRepl(config2) {
205
- const rl = readline.createInterface({
206
- input: process.stdin,
207
- output: process.stdout
208
- });
209
- const llm = createLLMClient(config2);
210
- const messages = [];
211
- const print = (text) => process.stdout.write(text);
212
- const confirm = async (prompt) => {
213
- const answer = await rl.question(prompt);
214
- return answer.trim().toLowerCase() === "y";
245
+
246
+ // src/logger.ts
247
+ import * as fs from "fs";
248
+ import * as path from "path";
249
+ function createLogger(logDir, sessionStart) {
250
+ fs.mkdirSync(logDir, { recursive: true });
251
+ const filename = sessionStart.toISOString().replace(/:/g, "-").replace(/\..+/, "") + ".jsonl";
252
+ const filePath = path.join(logDir, filename);
253
+ return {
254
+ filePath,
255
+ append(entry) {
256
+ try {
257
+ fs.appendFileSync(filePath, JSON.stringify(entry) + "\n");
258
+ } catch (err) {
259
+ process.stderr.write(`[logger] Failed to write log entry: ${err}
260
+ `);
261
+ }
262
+ }
215
263
  };
216
- const deps = { executeBash, confirm, print };
217
- console.log('ecode \u2014 type "exit" to quit\n');
218
- while (true) {
219
- const input = await rl.question("you: ").catch(() => "exit");
220
- const trimmed = input.trim();
221
- if (trimmed === "exit" || trimmed === "quit") break;
222
- if (!trimmed) continue;
223
- messages.push({ role: "user", content: trimmed });
224
- let continueLoop = true;
225
- while (continueLoop) {
226
- continueLoop = false;
227
- process.stdout.write("assistant: ");
228
- let assistantText = "";
229
- let assistantReasoning;
230
- const toolCalls = [];
231
- for await (const chunk of llm.stream(messages, [BASH_TOOL])) {
232
- if (chunk.text) {
233
- process.stdout.write(chunk.text);
234
- assistantText += chunk.text;
235
- }
236
- if (chunk.done) {
237
- if (chunk.toolCalls) toolCalls.push(...chunk.toolCalls);
238
- if (chunk.reasoning) assistantReasoning = chunk.reasoning;
264
+ }
265
+
266
+ // src/ui/StatusBar.tsx
267
+ import { useEffect, useState } from "react";
268
+ import { Box, Text } from "ink";
269
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
270
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
271
+ var SPINNER_INTERVAL_MS = 80;
272
+ function formatTokenCount(n) {
273
+ if (n < 1e3) return String(n);
274
+ return (n / 1e3).toFixed(1) + "k";
275
+ }
276
+ function buildStatusLabel(status, toolName, confirmPrompt) {
277
+ if (confirmPrompt) {
278
+ return `[y/n] ${confirmPrompt}`;
279
+ }
280
+ switch (status) {
281
+ case "thinking":
282
+ return "\u601D\u8003\u4E2D";
283
+ case "tool_calling":
284
+ return `\u8C03\u7528\u5DE5\u5177: ${toolName ?? ""}`;
285
+ case "awaiting_confirm":
286
+ return "\u7B49\u5F85\u786E\u8BA4";
287
+ case "idle":
288
+ default:
289
+ return "\u7B49\u5F85\u8F93\u5165";
290
+ }
291
+ }
292
+ function statusIcon(status, confirmPrompt) {
293
+ if (confirmPrompt) return "";
294
+ switch (status) {
295
+ case "thinking":
296
+ return "\u27F3";
297
+ case "tool_calling":
298
+ return "\u2699";
299
+ case "awaiting_confirm":
300
+ case "idle":
301
+ default:
302
+ return "\u25CE";
303
+ }
304
+ }
305
+ function StatusBar({
306
+ status,
307
+ toolName,
308
+ confirmPrompt,
309
+ version: version2,
310
+ tokenUsage
311
+ }) {
312
+ const [spinnerIdx, setSpinnerIdx] = useState(0);
313
+ const isAnimating = !confirmPrompt && (status === "thinking" || status === "tool_calling");
314
+ useEffect(() => {
315
+ if (!isAnimating) return;
316
+ const timer = setInterval(() => {
317
+ setSpinnerIdx((prev) => (prev + 1) % SPINNER_FRAMES.length);
318
+ }, SPINNER_INTERVAL_MS);
319
+ return () => clearInterval(timer);
320
+ }, [isAnimating]);
321
+ const label = buildStatusLabel(status, toolName, confirmPrompt);
322
+ const icon = statusIcon(status, confirmPrompt);
323
+ const spinner = isAnimating ? SPINNER_FRAMES[spinnerIdx] : " ";
324
+ const usedStr = formatTokenCount(tokenUsage.used);
325
+ const limitStr = formatTokenCount(tokenUsage.limit);
326
+ const ctxStr = `ctx: ${tokenUsage.estimated ? "~" : ""}${usedStr}/${limitStr}`;
327
+ const versionStr = `v${version2}`;
328
+ return (
329
+ // justifyContent="space-between" 让 Ink 自动撑开左右,
330
+ // 避免用 string.length 手动计算填充(CJK 双宽字符会导致计算偏差)
331
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [
332
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "row", children: [
333
+ /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
334
+ spinner,
335
+ " "
336
+ ] }),
337
+ confirmPrompt ? (
338
+ // 确认模式:整体用黄色显示
339
+ /* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
340
+ icon,
341
+ label
342
+ ] })
343
+ ) : status === "thinking" || status === "tool_calling" ? (
344
+ // 进行中状态:icon 用绿色,文字用白色
345
+ /* @__PURE__ */ jsxs(Fragment, { children: [
346
+ /* @__PURE__ */ jsxs(Text, { color: "green", children: [
347
+ icon,
348
+ " "
349
+ ] }),
350
+ /* @__PURE__ */ jsx(Text, { color: "white", children: label })
351
+ ] })
352
+ ) : (
353
+ // 空闲 / 等待确认:淡灰
354
+ /* @__PURE__ */ jsxs(Fragment, { children: [
355
+ /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
356
+ icon,
357
+ " "
358
+ ] }),
359
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: label })
360
+ ] })
361
+ )
362
+ ] }),
363
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "row", children: [
364
+ /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
365
+ versionStr,
366
+ " "
367
+ ] }),
368
+ /* @__PURE__ */ jsx(Text, { color: tokenUsage.estimated ? "yellow" : "cyan", children: ctxStr })
369
+ ] })
370
+ ] })
371
+ );
372
+ }
373
+
374
+ // src/ui/ConversationHistory.tsx
375
+ import { Box as Box2, Text as Text2 } from "ink";
376
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
377
+ var TOOL_RESULT_MAX_LINES = 3;
378
+ function truncateLines(text, maxLines) {
379
+ const lines = text.split("\n");
380
+ if (lines.length <= maxLines) return text;
381
+ return lines.slice(0, maxLines).join("\n") + "\n\u2026";
382
+ }
383
+ function UserMessage({
384
+ content
385
+ }) {
386
+ return (
387
+ // 用户消息使用 ">" 前缀,白色,与助手消息对比
388
+ /* @__PURE__ */ jsx2(Box2, { flexDirection: "row", marginBottom: 0, children: /* @__PURE__ */ jsxs2(Text2, { color: "white", children: [
389
+ "> ",
390
+ content
391
+ ] }) })
392
+ );
393
+ }
394
+ function AssistantMessage({
395
+ content,
396
+ tool_calls,
397
+ reasoning_content,
398
+ expandTools
399
+ }) {
400
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginBottom: 0, children: [
401
+ reasoning_content && reasoning_content.length > 0 && (expandTools ? /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
402
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "<thinking>" }),
403
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: reasoning_content })
404
+ ] }) : /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "[+ thinking]" })),
405
+ content && content.trim().length > 0 && /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: content }),
406
+ tool_calls && tool_calls.length > 0 && (expandTools ? tool_calls.map((tc, idx) => {
407
+ let argsDisplay = tc.function.arguments;
408
+ try {
409
+ const parsed = JSON.parse(tc.function.arguments);
410
+ if (typeof parsed === "object" && parsed !== null) {
411
+ argsDisplay = Object.values(parsed).map(String).join(", ");
239
412
  }
413
+ } catch {
414
+ }
415
+ return /* @__PURE__ */ jsxs2(Text2, { color: "yellow", children: [
416
+ "\u2699 \u8C03\u7528\u5DE5\u5177: ",
417
+ tc.function.name,
418
+ "(",
419
+ argsDisplay,
420
+ ")"
421
+ ] }, idx);
422
+ }) : /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
423
+ "[+ ",
424
+ tool_calls.length,
425
+ " \u4E2A\u5DE5\u5177\u8C03\u7528]"
426
+ ] }))
427
+ ] });
428
+ }
429
+ function ToolMessage({
430
+ content,
431
+ expandTools
432
+ }) {
433
+ if (!expandTools) {
434
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "row", marginBottom: 0, children: /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "[+ \u5DE5\u5177\u7ED3\u679C]" }) });
435
+ }
436
+ const truncated = truncateLines(content, TOOL_RESULT_MAX_LINES);
437
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "row", marginBottom: 0, children: /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
438
+ "[\u5DE5\u5177\u7ED3\u679C] ",
439
+ truncated
440
+ ] }) });
441
+ }
442
+ function ConversationHistory({
443
+ messages,
444
+ maxHeight = 20,
445
+ expandTools = false
446
+ }) {
447
+ const visible = messages.filter(
448
+ (m) => m.role !== "system"
449
+ );
450
+ function estimateLines(msg) {
451
+ if (msg.role === "user") {
452
+ return Math.max(1, msg.content.split("\n").length);
453
+ }
454
+ if (msg.role === "assistant") {
455
+ const contentLines = msg.content ? msg.content.split("\n").length : 0;
456
+ const toolLines = msg.tool_calls?.length ?? 0;
457
+ return Math.max(1, contentLines + toolLines);
458
+ }
459
+ if (msg.role === "tool") {
460
+ return Math.min(TOOL_RESULT_MAX_LINES, msg.content.split("\n").length) + 1;
461
+ }
462
+ return 1;
463
+ }
464
+ let totalLines = 0;
465
+ let startIdx = visible.length;
466
+ for (let i = visible.length - 1; i >= 0; i--) {
467
+ const lines = estimateLines(visible[i]);
468
+ if (totalLines + lines > maxHeight) break;
469
+ totalLines += lines;
470
+ startIdx = i;
471
+ }
472
+ const displayMessages = visible.slice(startIdx);
473
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: displayMessages.map((msg, idx) => {
474
+ if (msg.role === "user") {
475
+ return /* @__PURE__ */ jsx2(UserMessage, { content: msg.content }, idx);
476
+ }
477
+ if (msg.role === "assistant") {
478
+ const assistantMsg = msg;
479
+ return /* @__PURE__ */ jsx2(
480
+ AssistantMessage,
481
+ {
482
+ content: assistantMsg.content,
483
+ tool_calls: assistantMsg.tool_calls,
484
+ reasoning_content: assistantMsg.reasoning_content,
485
+ expandTools
486
+ },
487
+ idx
488
+ );
489
+ }
490
+ if (msg.role === "tool") {
491
+ return /* @__PURE__ */ jsx2(ToolMessage, { content: msg.content, expandTools }, idx);
492
+ }
493
+ return null;
494
+ }) });
495
+ }
496
+
497
+ // src/ui/Input.tsx
498
+ import { useState as useState2, useEffect as useEffect2 } from "react";
499
+ import { Box as Box3, Text as Text3, useInput } from "ink";
500
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
501
+ var CURSOR_CHAR = "\u258C";
502
+ var BLINK_INTERVAL_MS = 530;
503
+ function Input({ isActive, onSubmit, placeholder }) {
504
+ const [lines, setLines] = useState2([""]);
505
+ const [cursorVisible, setCursorVisible] = useState2(true);
506
+ useEffect2(() => {
507
+ if (!isActive) {
508
+ setCursorVisible(true);
509
+ return;
510
+ }
511
+ const timer = setInterval(() => {
512
+ setCursorVisible((prev) => !prev);
513
+ }, BLINK_INTERVAL_MS);
514
+ return () => {
515
+ clearInterval(timer);
516
+ };
517
+ }, [isActive]);
518
+ useInput(
519
+ (input, key) => {
520
+ if (key.return && key.shift) {
521
+ setLines((prev) => [...prev, ""]);
522
+ return;
240
523
  }
241
- if (toolCalls.length > 0) {
242
- messages.push({
243
- role: "assistant",
244
- content: assistantText || null,
245
- tool_calls: toolCalls.map((tc) => ({
246
- id: tc.id,
247
- type: "function",
248
- function: { name: tc.name, arguments: tc.arguments }
249
- })),
250
- ...assistantReasoning ? { reasoning_content: assistantReasoning } : {}
524
+ if (key.return) {
525
+ const text = lines.join("\n");
526
+ onSubmit(text);
527
+ setLines([""]);
528
+ return;
529
+ }
530
+ if (key.backspace || key.delete) {
531
+ setLines((prev) => {
532
+ const next = [...prev];
533
+ const lastIdx = next.length - 1;
534
+ const lastLine = next[lastIdx];
535
+ if (lastLine.length > 0) {
536
+ next[lastIdx] = lastLine.slice(0, -1);
537
+ return next;
538
+ }
539
+ if (next.length > 1) {
540
+ return next.slice(0, -1);
541
+ }
542
+ return next;
251
543
  });
252
- for (const tc of toolCalls) {
253
- if (tc.name === "bash") {
254
- let args;
255
- try {
256
- args = JSON.parse(tc.arguments);
257
- } catch {
258
- args = { command: "" };
259
- }
260
- print(`
261
- [bash] ${args.command}
262
- `);
263
- const toolResult = await handleBashTool(args.command, deps);
264
- print(toolResult + "\n");
265
- messages.push({
266
- role: "tool",
267
- tool_call_id: tc.id,
268
- content: toolResult
544
+ return;
545
+ }
546
+ if (key.ctrl || key.escape || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.tab || key.pageUp || key.pageDown) {
547
+ return;
548
+ }
549
+ if (input.length > 0) {
550
+ setLines((prev) => {
551
+ const next = [...prev];
552
+ next[next.length - 1] = next[next.length - 1] + input;
553
+ return next;
554
+ });
555
+ }
556
+ },
557
+ { isActive }
558
+ );
559
+ const isEmpty = lines.every((line) => line === "");
560
+ const renderLines = () => {
561
+ if (isEmpty && placeholder) {
562
+ return /* @__PURE__ */ jsxs3(Box3, { children: [
563
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "> " }),
564
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: placeholder }),
565
+ isActive && cursorVisible && /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: CURSOR_CHAR })
566
+ ] });
567
+ }
568
+ return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: lines.map((line, idx) => {
569
+ const isLastLine = idx === lines.length - 1;
570
+ const prefix = idx === 0 ? "> " : " ";
571
+ return /* @__PURE__ */ jsxs3(Box3, { children: [
572
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: prefix }),
573
+ /* @__PURE__ */ jsx3(Text3, { children: line }),
574
+ isActive && isLastLine && cursorVisible && /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: CURSOR_CHAR })
575
+ ] }, idx);
576
+ }) });
577
+ };
578
+ return /* @__PURE__ */ jsx3(Box3, { children: isActive ? renderLines() : /* @__PURE__ */ jsxs3(Box3, { children: [
579
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "> " }),
580
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: isEmpty ? placeholder ?? "" : lines.join(" ") })
581
+ ] }) });
582
+ }
583
+
584
+ // src/ui/App.tsx
585
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
586
+ function App({ config: config2, version: version2, autoMode: autoMode2 = false }) {
587
+ const [messages, setMessages] = useState3([]);
588
+ const [status, setStatus] = useState3("idle");
589
+ const [tokenUsage, setTokenUsage] = useState3({
590
+ used: 0,
591
+ estimated: true,
592
+ limit: 128e3
593
+ });
594
+ const [toolName, setToolName] = useState3(void 0);
595
+ const [confirmPrompt, setConfirmPrompt] = useState3(void 0);
596
+ const [expandTools, setExpandTools] = useState3(false);
597
+ const pendingConfirmRef = useRef(null);
598
+ const llmRef = useRef(createLLMClient(config2));
599
+ const loggerRef = useRef(null);
600
+ const loggedCountRef = useRef(0);
601
+ useEffect3(() => {
602
+ if (config2.logDir) {
603
+ loggerRef.current = createLogger(config2.logDir, /* @__PURE__ */ new Date());
604
+ }
605
+ }, []);
606
+ useEffect3(() => {
607
+ if (!loggerRef.current) return;
608
+ for (let i = loggedCountRef.current; i < messages.length; i++) {
609
+ const msg = messages[i];
610
+ loggerRef.current.append({
611
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
612
+ role: msg.role,
613
+ content: typeof msg.content === "string" ? msg.content : null,
614
+ tool_call_id: "tool_call_id" in msg ? msg.tool_call_id : void 0,
615
+ tool_calls: "tool_calls" in msg ? msg.tool_calls : void 0
616
+ });
617
+ }
618
+ loggedCountRef.current = messages.length;
619
+ }, [messages]);
620
+ useInput2((_input, key) => {
621
+ if (key.tab) {
622
+ setExpandTools((prev) => !prev);
623
+ }
624
+ });
625
+ const confirm = useCallback((prompt) => {
626
+ return new Promise((resolve) => {
627
+ setStatus("awaiting_confirm");
628
+ setConfirmPrompt(prompt);
629
+ pendingConfirmRef.current = { resolve };
630
+ });
631
+ }, []);
632
+ const runLlmLoop = useCallback(
633
+ async (history) => {
634
+ const print = (_text) => {
635
+ };
636
+ const deps = {
637
+ executeBash,
638
+ confirm,
639
+ print,
640
+ dangerousPatterns: config2.dangerousPatterns,
641
+ autoApproveNormal: autoMode2
642
+ };
643
+ let currentMessages = history;
644
+ let continueLoop = true;
645
+ while (continueLoop) {
646
+ continueLoop = false;
647
+ setStatus("thinking");
648
+ let assistantText = "";
649
+ let assistantReasoning;
650
+ const toolCalls = [];
651
+ for await (const chunk of llmRef.current.stream(currentMessages, [BASH_TOOL])) {
652
+ if (chunk.text) {
653
+ assistantText += chunk.text;
654
+ setMessages((prev) => {
655
+ const last = prev[prev.length - 1];
656
+ if (last?.role === "assistant" && !last.tool_calls) {
657
+ return [...prev.slice(0, -1), { ...last, content: assistantText }];
658
+ }
659
+ return [...prev, { role: "assistant", content: assistantText }];
269
660
  });
270
661
  }
662
+ if (chunk.done) {
663
+ if (chunk.toolCalls) toolCalls.push(...chunk.toolCalls);
664
+ if (chunk.reasoning) assistantReasoning = chunk.reasoning;
665
+ if (chunk.usage) {
666
+ setTokenUsage({
667
+ used: chunk.usage.totalTokens,
668
+ estimated: false,
669
+ limit: 128e3
670
+ });
671
+ }
672
+ } else {
673
+ const estimatedUsed = Math.floor(
674
+ currentMessages.reduce(
675
+ (acc, m) => acc + (typeof m.content === "string" ? m.content.length : 0),
676
+ 0
677
+ ) * 0.25 + assistantText.length * 0.25
678
+ );
679
+ setTokenUsage((prev) => ({ ...prev, used: estimatedUsed, estimated: true }));
680
+ }
271
681
  }
272
- continueLoop = true;
273
- } else {
274
- process.stdout.write("\n");
275
- if (assistantText) {
276
- messages.push({
682
+ if (toolCalls.length > 0) {
683
+ const assistantMsg = {
277
684
  role: "assistant",
278
- content: assistantText,
685
+ content: assistantText || null,
686
+ tool_calls: toolCalls.map((tc) => ({
687
+ id: tc.id,
688
+ type: "function",
689
+ function: { name: tc.name, arguments: tc.arguments }
690
+ })),
279
691
  ...assistantReasoning ? { reasoning_content: assistantReasoning } : {}
692
+ };
693
+ setMessages((prev) => {
694
+ const last = prev[prev.length - 1];
695
+ const withoutStreaming = last?.role === "assistant" && !last.tool_calls ? prev.slice(0, -1) : prev;
696
+ return [...withoutStreaming, assistantMsg];
280
697
  });
698
+ currentMessages = [...currentMessages, assistantMsg];
699
+ setStatus("tool_calling");
700
+ for (const tc of toolCalls) {
701
+ if (tc.name === "bash") {
702
+ let args;
703
+ try {
704
+ args = JSON.parse(tc.arguments);
705
+ } catch {
706
+ args = { command: "" };
707
+ }
708
+ setToolName(tc.name);
709
+ const toolResult = await handleBashTool(args.command, deps);
710
+ const toolMsg = {
711
+ role: "tool",
712
+ tool_call_id: tc.id,
713
+ content: toolResult
714
+ };
715
+ setMessages((prev) => [...prev, toolMsg]);
716
+ currentMessages = [...currentMessages, toolMsg];
717
+ }
718
+ }
719
+ continueLoop = true;
720
+ } else {
721
+ setMessages((prev) => {
722
+ const last = prev[prev.length - 1];
723
+ if (last?.role === "assistant" && !last.tool_calls) {
724
+ const updated = {
725
+ role: "assistant",
726
+ content: assistantText,
727
+ ...assistantReasoning ? { reasoning_content: assistantReasoning } : {}
728
+ };
729
+ return [...prev.slice(0, -1), updated];
730
+ }
731
+ return prev;
732
+ });
733
+ if (assistantText) {
734
+ currentMessages = [
735
+ ...currentMessages,
736
+ {
737
+ role: "assistant",
738
+ content: assistantText,
739
+ ...assistantReasoning ? { reasoning_content: assistantReasoning } : {}
740
+ }
741
+ ];
742
+ }
281
743
  }
282
744
  }
283
- }
284
- }
285
- rl.close();
286
- console.log("\nBye!");
745
+ setStatus("idle");
746
+ setToolName(void 0);
747
+ },
748
+ [confirm, config2.dangerousPatterns, autoMode2]
749
+ );
750
+ const handleSubmit = useCallback(
751
+ (text) => {
752
+ const trimmed = text.trim();
753
+ if (status === "awaiting_confirm") {
754
+ const pending = pendingConfirmRef.current;
755
+ if (pending) {
756
+ pendingConfirmRef.current = null;
757
+ setConfirmPrompt(void 0);
758
+ setStatus("tool_calling");
759
+ pending.resolve(trimmed.toLowerCase() === "y");
760
+ }
761
+ return;
762
+ }
763
+ if (!trimmed) return;
764
+ const userMsg = { role: "user", content: trimmed };
765
+ const nextMessages = [...messages, userMsg];
766
+ setMessages(nextMessages);
767
+ runLlmLoop(nextMessages).catch((err) => {
768
+ setStatus("idle");
769
+ setToolName(void 0);
770
+ setMessages((prev) => [
771
+ ...prev,
772
+ { role: "assistant", content: `[error] ${String(err)}` }
773
+ ]);
774
+ });
775
+ },
776
+ [status, messages, runLlmLoop]
777
+ );
778
+ const isInputActive = status === "idle" || status === "awaiting_confirm";
779
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", height: "100%", children: [
780
+ /* @__PURE__ */ jsx4(ConversationHistory, { messages, expandTools }),
781
+ /* @__PURE__ */ jsx4(
782
+ StatusBar,
783
+ {
784
+ status,
785
+ toolName,
786
+ confirmPrompt,
787
+ version: version2,
788
+ tokenUsage
789
+ }
790
+ ),
791
+ /* @__PURE__ */ jsx4(
792
+ Input,
793
+ {
794
+ isActive: isInputActive,
795
+ onSubmit: handleSubmit,
796
+ placeholder: status === "awaiting_confirm" ? "y / n" : void 0
797
+ }
798
+ )
799
+ ] });
287
800
  }
288
801
 
289
802
  // src/index.ts
290
803
  var require2 = createRequire(import.meta.url);
291
804
  var { version } = require2("../package.json");
292
- var arg = process.argv[2];
293
- if (arg === "-v" || arg === "--version") {
294
- console.log(version);
295
- process.exit(0);
805
+ var VERSION = version;
806
+ var rawArgs = process.argv.slice(2);
807
+ var autoMode = false;
808
+ var cliLogDir;
809
+ for (let i = 0; i < rawArgs.length; i++) {
810
+ const arg = rawArgs[i];
811
+ if (arg === "-v" || arg === "--version") {
812
+ console.log(VERSION);
813
+ process.exit(0);
814
+ }
815
+ if (arg === "--auto") {
816
+ autoMode = true;
817
+ }
818
+ if (arg === "--log-dir") {
819
+ const next = rawArgs[i + 1];
820
+ if (next && !next.startsWith("-")) {
821
+ cliLogDir = next;
822
+ i++;
823
+ }
824
+ }
296
825
  }
297
826
  var config = loadConfig();
298
- if (!config.apiKey) {
827
+ var finalConfig = cliLogDir ? { ...config, logDir: cliLogDir } : config;
828
+ if (!finalConfig.apiKey) {
299
829
  console.error(
300
830
  "Error: no API key configured.\nSet ECODE_API_KEY or add apiKey to ~/.ecode/config.json"
301
831
  );
302
832
  process.exit(1);
303
833
  }
304
- startRepl(config).catch((err) => {
305
- console.error("Fatal error:", err);
306
- process.exit(1);
307
- });
834
+ render(React4.createElement(App, { config: finalConfig, version: VERSION, autoMode }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhongqian97-code/ecode",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "A minimal Claude Code clone with REPL interface and bash tool calling",
5
5
  "type": "module",
6
6
  "author": "zhongqian97-code",
@@ -39,7 +39,10 @@
39
39
  "test:watch": "vitest"
40
40
  },
41
41
  "dependencies": {
42
- "openai": "^4.0.0"
42
+ "@types/react": "^19.2.14",
43
+ "ink": "^7.0.2",
44
+ "openai": "^4.0.0",
45
+ "react": "^19.2.5"
43
46
  },
44
47
  "devDependencies": {
45
48
  "@types/node": "^18.0.0",