@ghenya/clinn 0.8.8 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Src/index.jsx CHANGED
@@ -11,7 +11,7 @@ import os from "os";
11
11
  const require = createRequire(import.meta.url);
12
12
 
13
13
  const Tools = require("../Tools");
14
- const { listRecentTurns, searchHistory, getFileList, loadFileTurns } = require("../Mem/history");
14
+ const { listRecentTurns, searchHistory, getFileList, loadFileTurns, listSessions, getSession, loadSessionTurns, globalSearch, endSession, startSession } = require("../Mem/history");
15
15
  const kao = require("../Tools/kaomoji");
16
16
 
17
17
  const __filename = new URL(import.meta.url).pathname;
@@ -22,7 +22,7 @@ const CLINN_CONFIG = path.join(CLINN_DIR, "config.json");
22
22
  const PKG_CONFIG = path.join(__dirname, "..", "config.json");
23
23
  const LOGO_PATH = path.join(__dirname, "..", "Logos", "StartLogo.txt");
24
24
 
25
- const VER = "0.8.8";
25
+ const VER = "0.9.1";
26
26
 
27
27
  function ensureDir() { if (!fs.existsSync(CLINN_DIR)) fs.mkdirSync(CLINN_DIR, { recursive: true }); }
28
28
  function loadConfig() {
@@ -133,7 +133,6 @@ function formatMd(text, maxW) {
133
133
  }
134
134
 
135
135
  function Msg({ role, content }) {
136
- const lines = content.split("\n");
137
136
  const C = {
138
137
  user: "greenBright",
139
138
  system: "yellow",
@@ -143,12 +142,23 @@ function Msg({ role, content }) {
143
142
  const face = content._kao || kao.matchFace(content, role);
144
143
  const displayLabel = `${face} ${label}`;
145
144
  const bodyColor = role === "user" ? "white" : role === "system" ? "yellow" : undefined;
145
+
146
+ const MAX = role === "user" ? 300 : 99999;
147
+ const folded = content.length > MAX;
148
+ const lines = (folded ? content.slice(0, 200) : content).split("\n");
149
+ const hidden = folded ? content.length - 200 : 0;
150
+
146
151
  return (
147
152
  <Box flexDirection="column">
148
153
  <Text color={C[role]} bold>{displayLabel}</Text>
149
154
  <Text color={bodyColor}>
150
155
  {lines.map((l, i) => (i === 0 ? "" : "\n") + " " + (l || " ")).join("")}
151
156
  </Text>
157
+ {folded && (
158
+ <Text color="gray" dimColor>
159
+ {" [+" + Math.ceil(hidden / 100) * 100 + " MORE]"}
160
+ </Text>
161
+ )}
152
162
  </Box>
153
163
  );
154
164
  }
@@ -169,24 +179,82 @@ function Streaming({ content }) {
169
179
  );
170
180
  }
171
181
 
172
- function ToolLog({ tools }) {
182
+ function ToolLog({ tools, collapsed }) {
173
183
  if (!tools.length) return null;
184
+
185
+ function colorResult(text) {
186
+ if (!text) return null;
187
+ const lower = text.toLowerCase();
188
+ let color = "gray";
189
+ let sym = "";
190
+ if (/error|失败|错误|eacces|eperm|enoent/i.test(lower)) { color = "red"; sym = "✗ "; }
191
+ else if (/\[ok\]|完成|成功|created|wrote|写入|创建|已保存|passed/i.test(lower)) { color = "green"; sym = " "; }
192
+ else if (/deleted|删除|移除|removed/i.test(lower)) { color = "red"; sym = "✗ "; }
193
+ else if (/modified|修改|更新|patched|changed/i.test(lower)) { color = "yellow"; sym = "~ "; }
194
+ else if (/not found|未找到|不存在|no match/i.test(lower)) { color = "red"; sym = "? "; }
195
+ return { text: sym + text.slice(0, 100), color };
196
+ }
197
+
198
+ if (collapsed) {
199
+ return (
200
+ <Box flexDirection="column" paddingLeft={2}>
201
+ <Text color="gray" dimColor>{"─".repeat(50)} 工具调用 ({tools.length})</Text>
202
+ {tools.map((t, i) => {
203
+ const cr = colorResult(t.result);
204
+ return (
205
+ <Box key={i} flexDirection="row">
206
+ {t.status === "done"
207
+ ? <Text color="green"> ✓ </Text>
208
+ : <Text color="yellow"><Spinner /></Text>}
209
+ <Text color="cyan">{t.name}</Text>
210
+ {cr ? <Text color={cr.color}> {cr.text}</Text> : null}
211
+ </Box>
212
+ );
213
+ })}
214
+ </Box>
215
+ );
216
+ }
217
+
174
218
  return (
175
219
  <Box flexDirection="column" paddingLeft={2}>
220
+ <Text color="gray" dimColor>{"─".repeat(50)} 工具调用 ({tools.length})</Text>
176
221
  {tools.map((t, i) => (
177
- <Box key={i} flexDirection="column">
178
- <Box>
222
+ <Box key={i} flexDirection="column" marginBottom={1}>
223
+ <Box flexDirection="row">
179
224
  {t.status === "done"
180
225
  ? <Text color="green">✓ </Text>
181
- : <Text color="yellow"><Spinner /> </Text>}
182
- <Text dimColor>{t.name}</Text>
183
- {t.args ? <Text color="gray"> {t.args}</Text> : null}
226
+ : <Text color="yellow"><Spinner type="dots" /> </Text>}
227
+ <Text color="cyan" bold>{t.name}</Text>
184
228
  </Box>
185
- {t.result ? (
186
- <Box paddingLeft={4}>
187
- <Text color="gray">{t.result.slice(0, 120)}</Text>
229
+ {t.args ? (
230
+ <Box paddingLeft={3}>
231
+ <Text color="gray">{t.args}</Text>
188
232
  </Box>
189
233
  ) : null}
234
+ {t.result ? (() => {
235
+ const lines = t.result.split("\n").filter(l => l.trim());
236
+ if (lines.length <= 1) {
237
+ const cr = colorResult(t.result);
238
+ return (
239
+ <Box paddingLeft={3}>
240
+ <Text color={cr?.color || "gray"}>{t.result.slice(0, 200)}</Text>
241
+ </Box>
242
+ );
243
+ }
244
+ return (
245
+ <Box flexDirection="column" paddingLeft={3}>
246
+ {lines.map((l, j) => {
247
+ const cr = colorResult(l);
248
+ return (
249
+ <Text key={j} color={cr?.color || "gray"}>
250
+ {cr?.text || l.slice(0, 200)}
251
+ </Text>
252
+ );
253
+ }).slice(0, 6)}
254
+ {lines.length > 6 ? <Text color="gray" dimColor>... (+{lines.length - 6} 行)</Text> : null}
255
+ </Box>
256
+ );
257
+ })() : null}
190
258
  </Box>
191
259
  ))}
192
260
  </Box>
@@ -236,6 +304,9 @@ const COMMANDS = [
236
304
  { cmd: "/history files", desc: "历史文件列表" },
237
305
  { cmd: "/history search", desc: "搜索历史" },
238
306
  { cmd: "/history read", desc: "读取历史文件" },
307
+ { cmd: "/sessions", desc: "列出对话 session" },
308
+ { cmd: "/session", desc: "查看某个 session" },
309
+ { cmd: "/session_search", desc: "全局搜索 session" },
239
310
  { cmd: "/trusted", desc: "查看受信任工具" },
240
311
  { cmd: "/trust", desc: "永久信任工具" },
241
312
  { cmd: "/untrust", desc: "取消信任" },
@@ -253,29 +324,27 @@ function App() {
253
324
  const [thinking, setThinking] = useState(false);
254
325
  const [streaming, setStreaming] = useState("");
255
326
  const [tools, setTools] = useState([]);
327
+ const [toolsCollapsed, setToolsCollapsed] = useState(false);
256
328
  const [ctxPct, setCtxPct] = useState(0);
257
329
  const [blocked, setBlocked] = useState(false);
258
330
  const [slashIdx, setSlashIdx] = useState(0);
259
331
  const [mascotFace, setMascotFace] = useState(kao.mascotForFrame());
260
332
  const [promptFace, setPromptFace] = useState(kao.promptFace(""));
333
+ const [queue, setQueue] = useState([]);
334
+ const abortRef = useRef(null);
261
335
 
262
- // 状态栏每 8 秒切换表情
263
336
  useEffect(() => {
264
337
  const timer = setInterval(() => setMascotFace(kao.mascotForFrame()), 1000);
265
338
  return () => clearInterval(timer);
266
339
  }, []);
267
340
 
268
- // 输入框表情: 启动动画 + 根据输入内容实时匹配
269
341
  useEffect(() => {
270
342
  const timer = setInterval(() => setPromptFace(kao.promptFace(input)), 150);
271
343
  return () => clearInterval(timer);
272
344
  }, [input]);
273
- const [msgs, setMsgs] = useState([
274
- { role: "system", content: `欢迎来到 Clinn v${VER}!Ink 全屏终端界面。` },
275
- { role: "system", content: "按 / 查看命令菜单 ↑↓选择 回车选取" },
276
- ]);
345
+ const [msgs, setMsgs] = useState([]);
346
+ const [started, setStarted] = useState(false);
277
347
 
278
- // 启动时从 agent 获取真实上下文占比
279
348
  useEffect(() => {
280
349
  setCtxPct(getAgent().estimateContextPct());
281
350
  }, []);
@@ -293,50 +362,80 @@ function App() {
293
362
  setThinking(true);
294
363
  setStreaming("");
295
364
  setTools([]);
365
+ setToolsCollapsed(false);
366
+
367
+ const controller = new AbortController();
368
+ abortRef.current = controller;
296
369
 
297
370
  let buf = "";
298
371
  const toolMap = new Map();
299
372
 
300
373
  try {
301
- await getAgent().run(query, {
302
- onContent: (tok) => {
303
- buf += tok;
304
- setStreaming(buf.replace(/\*\*(.+?)\*\*/g, "$1"));
305
- },
306
- onToolCall: (name, args = {}) => {
307
- const short = Object.entries(args).map(([k, v]) => {
308
- const s = String(v);
309
- return k + "=" + (s.length > 50 ? s.slice(0, 50) + "…" : s);
310
- }).join(" ");
311
- toolMap.set(name, { name, args: short, status: "running", result: "" });
312
- setTools([...toolMap.values()]);
313
- },
314
- onToolResult: (name, result) => {
315
- const t = toolMap.get(name);
316
- if (t) {
317
- t.status = "done";
318
- t.result = String(result || "").replace(/\n/g, " ").slice(0, 150);
319
- }
320
- setTools([...toolMap.values()]);
321
- },
322
- onContextPct: (pct) => setCtxPct(pct),
323
- });
374
+ await Promise.race([
375
+ getAgent().run(query, {
376
+ onContent: (tok) => {
377
+ buf += tok;
378
+ setStreaming(buf.replace(/\*\*(.+?)\*\*/g, "$1"));
379
+ },
380
+ onToolCall: (name, args = {}) => {
381
+ const short = Object.entries(args).map(([k, v]) => {
382
+ const s = String(v);
383
+ return k + "=" + (s.length > 50 ? s.slice(0, 50) + "\u2026" : s);
384
+ }).join(" ");
385
+ toolMap.set(name, { name, args: short, status: "running", result: "" });
386
+ setTools([...toolMap.values()]);
387
+ },
388
+ onToolResult: (name, result) => {
389
+ const t = toolMap.get(name);
390
+ if (t) {
391
+ t.status = "done";
392
+ t.result = String(result || "").slice(0, 500);
393
+ }
394
+ setTools([...toolMap.values()]);
395
+ },
396
+ onContextPct: (pct) => setCtxPct(pct),
397
+ }),
398
+ new Promise((_, reject) => {
399
+ controller.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
400
+ }),
401
+ ]);
324
402
  } catch (e) {
325
- addMsg("system", "错误: " + e.message);
403
+ if (e.name === "AbortError" || e.message === "Aborted") {
404
+ addMsg("system", "\u23f9 已中断");
405
+ buf = "";
406
+ } else {
407
+ addMsg("system", "\u2717 错误: " + e.message);
408
+ }
326
409
  }
327
410
 
328
411
  if (buf) addMsg("assistant", formatMd(buf, cols - 4));
329
412
  setStreaming("");
330
413
  setThinking(false);
331
- setTools([]);
332
- setBlocked(false);
414
+ setToolsCollapsed(true);
415
+ abortRef.current = null;
416
+
417
+ // 处理队列中的下一条
418
+ setQueue(prev => {
419
+ if (prev.length <= 1) {
420
+ setBlocked(false);
421
+ return [];
422
+ }
423
+ // delay next to allow React to update
424
+ const [done, ...rest] = prev;
425
+ setTimeout(() => {
426
+ const next = rest[0];
427
+ addMsg("user", next);
428
+ run(next);
429
+ }, 50);
430
+ return rest;
431
+ });
333
432
  }, [blocked, addMsg, cols]);
334
433
 
335
434
  const slashRef = useRef({ filtered: [], clamped: 0 });
336
435
 
337
436
  const handleSubmit = useCallback((val) => {
338
437
  const v = val.trim();
339
- if (!v || thinking) return;
438
+ if (!v) return;
340
439
  const { filtered, clamped } = slashRef.current;
341
440
  if (filtered.length > 0 && clamped < filtered.length) {
342
441
  const sel = filtered[clamped].cmd;
@@ -346,8 +445,21 @@ function App() {
346
445
  return;
347
446
  }
348
447
  }
448
+
449
+ // 如果正在执行中,加入队列而非阻塞
450
+ if (blocked || thinking) {
451
+ if (v === "/reset" || v === "/clear" || v === "/exit" || v === "/help" || v === "/version" || v === "/status" || v === "/ctx") {
452
+ // 这些命令仍可直接执行(不阻塞)
453
+ } else {
454
+ setQueue(prev => [...prev, v]);
455
+ setInput("");
456
+ return;
457
+ }
458
+ }
459
+
349
460
  addMsg("user", v);
350
461
  setInput("");
462
+ setStarted(true);
351
463
 
352
464
  const parts = v.slice(1).trim().split(/\s+/);
353
465
  const cmd = parts[0].toLowerCase();
@@ -357,48 +469,54 @@ function App() {
357
469
 
358
470
  if (v === "/help") {
359
471
  say([
360
- "╭────────────────────────────────────╮",
361
- " /help 查看帮助 ",
362
- " /exit 退出程序 ",
363
- " /reset 重置对话 ",
364
- " /clear 清除对话 ",
365
- " /version 查看版本 ",
366
- " /status 当前状态 ",
367
- " /ctx 上下文使用量 ",
368
- " /tools 列出工具 ",
369
- " /tools_more 全部工具 ",
370
- " /tool_search <q> 搜索工具 ",
371
- " /tool_list_saved 列出持久化工具 ",
372
- " /tool_save <name> 持久化保存工具 ",
373
- " /tool_del <name> 删除持久化工具 ",
374
- " /temp <0-2> 设置温度 ",
375
- " /token <n> 设置maxTokens ",
376
- " /memory 记忆统计 ",
377
- " /memory_list [n] 列出记忆条目 ",
378
- " /memory_search <q> 搜索记忆 ",
379
- " /memory_del <id> 删除记忆 ",
380
- " /memory_clear 清空记忆 ",
381
- " /compress 手动压缩上下文 ",
382
- " /history [n] 最近历史 ",
383
- " /history files 历史文件列表 ",
384
- " /history search <q>搜索历史 ",
385
- " /history read <f> 读取历史文件 ",
386
- " /trusted 受信任工具 │",
387
- " /trust <name> 永久信任工具 ",
388
- " /untrust <name> 取消信任 │",
389
- " /api 查看API配置 │",
390
- " /api key <K> 设置Key │",
391
- " /api url <U> 设置地址 │",
392
- " /api model <M> 设置模型 │",
393
- "╰────────────────────────────────────╯",
472
+ "\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
473
+ "\u2502 /help 查看帮助 \u2502",
474
+ "\u2502 /exit 退出程序 \u2502",
475
+ "\u2502 /reset 重置对话 \u2502",
476
+ "\u2502 /clear 清除对话 \u2502",
477
+ "\u2502 /version 查看版本 \u2502",
478
+ "\u2502 /status 当前状态 \u2502",
479
+ "\u2502 /ctx 上下文使用量 \u2502",
480
+ "\u2502 /tools 列出工具 \u2502",
481
+ "\u2502 /tools_more 全部工具 \u2502",
482
+ "\u2502 /tool_search <q> 搜索工具 \u2502",
483
+ "\u2502 /tool_list_saved 列出持久化工具 \u2502",
484
+ "\u2502 /tool_save <name> 持久化保存工具 \u2502",
485
+ "\u2502 /tool_del <name> 删除持久化工具 \u2502",
486
+ "\u2502 /temp <0-2> 设置温度 \u2502",
487
+ "\u2502 /token <n> 设置maxTokens \u2502",
488
+ "\u2502 /memory 记忆统计 \u2502",
489
+ "\u2502 /memory_list [n] 列出记忆条目 \u2502",
490
+ "\u2502 /memory_search <q> 搜索记忆 \u2502",
491
+ "\u2502 /memory_del <id> 删除记忆 \u2502",
492
+ "\u2502 /memory_clear 清空记忆 \u2502",
493
+ "\u2502 /compress 手动压缩上下文 \u2502",
494
+ "\u2502 /history [n] 最近历史 \u2502",
495
+ "\u2502 /history files 历史文件列表 \u2502",
496
+ "\u2502 /history search <q>搜索历史 \u2502",
497
+ "\u2502 /history read <f> 读取历史文件 \u2502",
498
+ "\u2502 /sessions [n] 列出对话session\u2502",
499
+ "\u2502 /session <id> 查看session \u2502",
500
+ "\u2502 /session_search <q>全局搜索session\u2502",
501
+ "\u2502 /trusted 受信任工具 \u2502",
502
+ "\u2502 /trust <name> 永久信任工具 \u2502",
503
+ "\u2502 /untrust <name> 取消信任 \u2502",
504
+ "\u2502 /api 查看API配置 \u2502",
505
+ "\u2502 /api key <K> 设置Key \u2502",
506
+ "\u2502 /api url <U> 设置地址 \u2502",
507
+ "\u2502 /api model <M> 设置模型 \u2502",
508
+ "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
394
509
  ].join("\n"));
395
510
  }
396
511
  else if (cmd === "exit") process.exit(0);
397
512
  else if (cmd === "reset") {
398
- getAgent().reset();
399
- setMsgs([{ role: "system", content: "对话已重置。" }]);
513
+ const a = getAgent();
514
+ endSession(a.sessionId);
515
+ a.reset();
516
+ a.sessionId = startSession();
517
+ setMsgs([{ role: "system", content: "对话已重置,新 session 已创建。" }]);
400
518
  setCtxPct(0);
401
- say("对话已重置 (历史+token计数已清零)");
519
+ say("对话已重置, 新 session 已创建");
402
520
  }
403
521
  else if (cmd === "clear") {
404
522
  setMsgs([{ role: "system", content: "对话已清除。" }]);
@@ -414,13 +532,13 @@ function App() {
414
532
  const pct = a.estimateContextPct();
415
533
  const names = Tools.listToolNames();
416
534
  say([
417
- "╭────────────────────────────────────╮",
535
+ "\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
418
536
  " 模型: " + CONFIG.llm.model + " 温度: " + CONFIG.llm.temperature,
419
537
  " Tokens: 输入" + usage.prompt + " · 输出" + usage.completion,
420
538
  " 上下文: " + pct + "% (上限 " + a.getMaxContextTokens() + " tokens)",
421
- " 记忆: " + mem.entries + "/" + mem.maxEntries + " · 历史 " + mem.historyMessages + " 轮",
539
+ " 记忆: ROM" + (mem.romEntries || mem.entries) + " RAM" + (mem.ramEntries || 0) + " · 历史 " + mem.historyMessages + " 轮",
422
540
  " 工具: " + names.length + " 个 — " + names.slice(0, 8).join(", ") + (names.length > 8 ? " ..." : ""),
423
- "╰────────────────────────────────────╯",
541
+ "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
424
542
  ].join("\n"));
425
543
  }
426
544
  else if (cmd === "ctx") {
@@ -430,7 +548,7 @@ function App() {
430
548
  const barW = 30;
431
549
  const filled = Math.max(1, Math.round(pct / 100 * barW));
432
550
  const empty = barW - filled;
433
- const bar = "".repeat(filled) + "".repeat(empty);
551
+ const bar = "\u2588".repeat(filled) + "\u2500".repeat(empty);
434
552
  say([
435
553
  "上下文: [" + bar + "] " + pct + "%",
436
554
  "上限: " + a.getMaxContextTokens() + " tokens",
@@ -440,13 +558,16 @@ function App() {
440
558
  }
441
559
  else if (cmd === "memory") {
442
560
  const s = getAgent().memory.stats();
443
- say("记忆: " + s.entries + "/" + s.maxEntries + " · 历史消息 " + s.historyMessages + " 轮");
561
+ say("记忆: ROM" + (s.romEntries || s.entries) + " RAM" + (s.ramEntries || 0) + " · 历史消息 " + s.historyMessages + " 轮");
444
562
  }
445
563
  else if (cmd === "memory_list") {
446
564
  const n = parseInt(rest) || 20;
447
565
  const all = getAgent().memory.getAllEntries().slice(-n);
448
566
  if (!all.length) { say("(无记忆条目)"); return; }
449
- say(all.map((e) => "#" + e.id + " [" + (e.tags?.join(",") || "-") + "] " + e.text).join("\n"));
567
+ say(all.map((e) => {
568
+ const tag = e._type === "ram" ? "[RAM]" : "[ROM]";
569
+ return tag + " #" + e.id + " [" + (e.tags?.join(",") || "-") + "] " + e.text;
570
+ }).join("\n"));
450
571
  }
451
572
  else if (cmd === "memory_search") {
452
573
  if (!rest) { say("用法: /memory_search <关键词>"); return; }
@@ -461,15 +582,15 @@ function App() {
461
582
  say(ok ? "已删除 #" + id : "不存在 #" + id);
462
583
  }
463
584
  else if (cmd === "memory_clear") {
464
- getAgent().memory.clearEntries();
465
- say("记忆已全部清空");
585
+ getAgent().memory.clearAllEntries();
586
+ say("记忆已全部清空 (ROM+RAM)");
466
587
  }
467
588
  else if (cmd === "compress") {
468
589
  const a = getAgent();
469
590
  const compressed = a.memory.compressHistory();
470
591
  if (!compressed) { say("对话太短, 无需压缩"); return; }
471
592
  a.memory.clear();
472
- a.memory.addEntry("手动压缩: " + compressed.slice(0, 180), ["manual-summary"]);
593
+ a.memory.addRamEntry("手动压缩: " + compressed.slice(0, 180), ["manual-summary"]);
473
594
  say("上下文已压缩, 历史清空, 摘要已存入记忆");
474
595
  }
475
596
  else if (cmd === "tools") {
@@ -542,6 +663,59 @@ function App() {
542
663
  say(turns.map((t, i) => (i + 1) + ". [" + (t.time?.slice(0, 16) || "?") + "] " + (t.user || "").slice(0, 120)).join("\n"));
543
664
  }
544
665
  }
666
+ else if (cmd === "sessions") {
667
+ const n = parseInt(rest) || 20;
668
+ const sessions = listSessions(n);
669
+ if (!sessions.length) { say("(暂无对话 session)"); return; }
670
+ const lines = sessions.map((s, i) => {
671
+ const marker = s.status === "active" ? "\u25cf" : "\u25cb";
672
+ return (i + 1) + ". " + marker + " [" + (s.started || "?").slice(0, 16) + "] " +
673
+ (s.title || "(无标题)") + " | " + s.turnCount + "轮" +
674
+ (s.status === "active" ? " \u25c0 当前" : "");
675
+ });
676
+ say("对话 Sessions (" + sessions.length + "):\n" + lines.join("\n") +
677
+ "\n\n用 /session <编号> 查看详情, /session_search <关键词> 搜索");
678
+ }
679
+ else if (cmd === "session") {
680
+ if (!rest) { say("用法: /session <session_id 或列表序号>\n请先用 /sessions 查看列表"); return; }
681
+ const sessions = listSessions(999);
682
+ let sid = rest.trim();
683
+ if (/^\d+$/.test(sid)) {
684
+ const idx = parseInt(sid) - 1;
685
+ if (idx < 0 || idx >= sessions.length) { say("序号超出范围 (1-" + sessions.length + ")"); return; }
686
+ sid = sessions[idx].id;
687
+ }
688
+ const session = getSession(sid);
689
+ if (!session) { say("未找到 session: " + sid); return; }
690
+ const turns = loadSessionTurns(sid, 30);
691
+ const header = "=== " + (session.title || "(无标题)") + " ===\n" +
692
+ "ID: " + session.id + " | " + (session.started || "?").slice(0, 16) + " | " + session.turnCount + "轮\n";
693
+ if (!turns.length) { say(header + "\n(这个 session 暂无对话轮次)"); return; }
694
+ const body = turns.map((t, i) => {
695
+ return "[" + (i + 1) + "] " + (t.time || "?").slice(0, 16) + "\n" +
696
+ " 问: " + (t.user || "").slice(0, 300) + "\n" +
697
+ " 答: " + (t.assistant || "").slice(0, 300);
698
+ }).join("\n\n");
699
+ say(header + "\n" + body + "\n\n(共 " + turns.length + " 轮, 显示最近 30 轮)");
700
+ }
701
+ else if (cmd === "session_search") {
702
+ if (!rest) { say("用法: /session_search <关键词> [数量]\n在整个历史中搜索相关 session"); return; }
703
+ const parts = rest.trim().split(/\s+/);
704
+ const limit = parseInt(parts[parts.length - 1]) || 10;
705
+ const query = isNaN(parseInt(parts[parts.length - 1])) ? rest.trim() : parts.slice(0, -1).join(" ");
706
+ const results = globalSearch(query, limit);
707
+ if (!results.length) { say("全史搜索未找到 \"" + query + "\""); return; }
708
+ const lines = [];
709
+ for (const [i, r] of results.entries()) {
710
+ lines.push((i + 1) + ". [" + (r.sessionStarted || "?").slice(0, 16) + "] " +
711
+ r.sessionTitle + " | " + r.matchCount + "处匹配 | 得分" + r.totalScore);
712
+ for (const m of r.topMatches) {
713
+ lines.push(" · " + (m.user || "").slice(0, 100));
714
+ }
715
+ }
716
+ say("搜索 \"" + query + "\" — " + results.length + " 个 session:\n" + lines.join("\n") +
717
+ "\n\n用 /session <编号> 查看详情");
718
+ }
545
719
  else if (cmd === "trusted") {
546
720
  const trusted = CONFIG.tools?.trustedTools || [];
547
721
  if (!trusted.length) { say("(暂无受信任工具)"); return; }
@@ -590,7 +764,7 @@ function App() {
590
764
  }
591
765
  }
592
766
  else { run(v); }
593
- }, [thinking, addMsg, run]);
767
+ }, [thinking, blocked, addMsg, run]);
594
768
 
595
769
  const slashInput = input.startsWith("/");
596
770
  const slashFiltered = slashInput
@@ -599,7 +773,17 @@ function App() {
599
773
  const slashClamped = Math.max(0, Math.min(slashIdx, slashFiltered.length - 1));
600
774
  slashRef.current = { filtered: slashFiltered, clamped: slashClamped };
601
775
 
602
- useInput((_, key) => {
776
+ useInput((inputVal, key) => {
777
+ if (key.ctrl && inputVal === "c") {
778
+ if (abortRef.current) {
779
+ abortRef.current.abort();
780
+ return;
781
+ }
782
+ if (input.length > 0) {
783
+ setInput("");
784
+ }
785
+ return;
786
+ }
603
787
  if (key.escape) process.exit(0);
604
788
  if (!slashInput || !slashFiltered.length) return;
605
789
  if (key.upArrow) setSlashIdx((slashClamped - 1 + slashFiltered.length) % slashFiltered.length);
@@ -607,7 +791,34 @@ function App() {
607
791
  });
608
792
 
609
793
  return (
610
- <Box borderStyle="round" borderColor="cyan" flexDirection="column" paddingX={1}>
794
+ <Box flexDirection="column" paddingLeft={1}>
795
+ {!started ? (
796
+ <Box flexDirection="column" flexShrink={0}>
797
+ {/* 启动页: LOGO + 欢迎框 */}
798
+ <Box flexDirection="row">
799
+ {/* 左边 LOGO */}
800
+ <Box flexDirection="column" paddingRight={2}>
801
+ {LOGO_LINES.map((line, i) => (
802
+ <Text key={i} color="cyanBright">{line}</Text>
803
+ ))}
804
+ <Text color="green">{CONFIG.llm.model} · {CONFIG.llm.contextWindow || 131072} tokens</Text>
805
+ </Box>
806
+ {/* 右边欢迎框 */}
807
+ <Box borderStyle="round" borderColor="cyan" flexDirection="column" paddingX={2} paddingY={1}>
808
+ <Text color="cyanBright" bold>Clinn v{VER}</Text>
809
+ <Text color="magenta" dimColor>Self-Evolving AI · Terminal Agent · Ink</Text>
810
+ <Text> </Text>
811
+ <Text dimColor>Terminal-native AI coding assistant</Text>
812
+ <Text> </Text>
813
+ <Text dimColor>/ command menu · ↑↓ select · Enter confirm</Text>
814
+ </Box>
815
+ </Box>
816
+ <Box marginTop={1}>
817
+ <Text color="gray" dimColor>{"─".repeat(cols - 2)}</Text>
818
+ </Box>
819
+ </Box>
820
+ ) : (
821
+ <Box flexDirection="column" flexGrow={1}>
611
822
  <Box flexDirection="column" flexShrink={0}>
612
823
  {LOGO_LINES.map((line, i) => (
613
824
  <Text key={i} color="cyanBright">{line}</Text>
@@ -615,16 +826,21 @@ function App() {
615
826
  <Box marginTop={1}>
616
827
  <Text color="magenta" dimColor>Self-Evolving AI · Terminal Agent · Ink</Text>
617
828
  </Box>
618
- <Text color="gray">{"─".repeat(cols - 4)}</Text>
829
+ <Text color="gray" dimColor>{"─".repeat(cols - 2)}</Text>
619
830
  </Box>
620
831
 
621
- <Box flexDirection="column" flexGrow={1}>
832
+ <Box flexDirection="column">
622
833
  {msgs.map((m, i) => (
623
834
  <Msg key={i} role={m.role} content={m.content} />
624
835
  ))}
625
836
  {streaming ? <Streaming content={streaming} /> : null}
626
837
  {thinking && !streaming ? <Thinking /> : null}
627
- <ToolLog tools={tools} />
838
+ {tools.length > 0 && (
839
+ <Box flexDirection="column">
840
+ <Text color="gray" dimColor>{"\u2500\u2500\u2500".repeat(8)}</Text>
841
+ <ToolLog tools={tools} collapsed={toolsCollapsed} />
842
+ </Box>
843
+ )}
628
844
  </Box>
629
845
 
630
846
  {slashInput && slashFiltered.length > 0 && (
@@ -633,20 +849,22 @@ function App() {
633
849
  const sel = i === slashClamped;
634
850
  return (
635
851
  <Box key={c.cmd} paddingLeft={1}>
636
- <Text color={sel ? "cyan" : "gray"}>{sel ? " " : " "}</Text>
852
+ <Text color={sel ? "cyan" : "gray"}>{sel ? "\u25b8 " : " "}</Text>
637
853
  <Text color={sel ? "cyanBright" : undefined} bold={sel}>{c.cmd}</Text>
638
- <Text color="gray">{" · " + c.desc}</Text>
854
+ <Text color="gray">{" \u00b7 " + c.desc}</Text>
639
855
  </Box>
640
856
  );
641
857
  })}
642
858
  <Box paddingLeft={1}>
643
- <Text color="gray"> ↑↓ 切换 · 回车 选取</Text>
859
+ <Text color="gray"> \u2191\u2193 切换 \u00b7 回车 选取</Text>
644
860
  </Box>
645
861
  </Box>
646
862
  )}
647
863
 
864
+ </Box>
865
+ )}
648
866
  <Box flexDirection="column" flexShrink={0}>
649
- <Text color="gray">{"─".repeat(cols - 4)}</Text>
867
+ <Text color="gray" dimColor>{"─".repeat(cols - 2)}</Text>
650
868
  <Box paddingX={1}>
651
869
  <Text dimColor>
652
870
  <Text color="cyan">{mascotFace}</Text> {CONFIG.llm.model}
@@ -659,20 +877,60 @@ function App() {
659
877
  </Text>
660
878
  </Box>
661
879
 
662
- <Box paddingY={1}>
663
- <Box borderStyle="round" borderColor="cyan" paddingX={1} width={cols - 4}>
664
- <Text color="greenBright" bold>{promptFace} </Text>
880
+ {queue.length > 0 && (
881
+ <Box paddingX={1} paddingBottom={1}>
882
+ <Text color="yellow" dimColor>
883
+ {"│ "}队列: {queue.length} 条待执行
884
+ {queue.slice(0, 3).map((q, i) => (
885
+ <Text key={i} color="gray">{" · "}{q.slice(0, 30)}{q.length > 30 ? "…" : ""}</Text>
886
+ ))}
887
+ {queue.length > 3 ? <Text color="gray"> ...</Text> : null}
888
+ </Text>
889
+ </Box>
890
+ )}
891
+
892
+ <Text color="cyan">{"─".repeat(cols - 2)}</Text>
893
+
894
+ {input.length > 200 && (
895
+ <Box paddingX={1}>
896
+ <Text color="gray" dimColor> [{input.length} chars]</Text>
897
+ </Box>
898
+ )}
899
+
900
+ <Box paddingY={0} flexDirection="row">
901
+ <Text color="greenBright" bold>{promptFace} </Text>
902
+ <Box flexGrow={1}>
665
903
  <TextInput
666
- value={input}
667
- onChange={v => { setInput(v); setSlashIdx(0); }}
904
+ value={input.length > 500 ? "…" + input.slice(-300) : input}
905
+ onChange={v => {
906
+ setSlashIdx(0);
907
+ if (v.startsWith("…")) {
908
+ setInput(input.slice(0, Math.max(0, input.length - 300)) + v.slice(1));
909
+ } else {
910
+ setInput(v);
911
+ }
912
+ }}
668
913
  onSubmit={handleSubmit}
669
- placeholder={thinking ? "思考中,请稍候..." : "输入你的问题,或按 / 查看命令…"}
914
+ placeholder={thinking ? "thinking..." : "> "}
670
915
  />
671
916
  </Box>
672
917
  </Box>
918
+
919
+ <Text color="cyan">{"─".repeat(cols - 2)}</Text>
673
920
  </Box>
674
921
  </Box>
675
922
  );
676
923
  }
677
924
 
678
925
  const { waitUntilExit } = render(<App />);
926
+
927
+ // 拦截 SIGINT:Ctrl+C 由 Ink useInput 处理,不直接退出
928
+ let sigintCount = 0;
929
+ process.on("SIGINT", () => {
930
+ sigintCount++;
931
+ if (sigintCount >= 3) {
932
+ process.exit(0);
933
+ }
934
+ // 单次/双次 Ctrl+C 不退出,交给 Ink 的 useInput 处理
935
+ setTimeout(() => { sigintCount = 0; }, 1000);
936
+ });