jinzd-ai-cli 0.1.84 → 0.1.86

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/CLAUDE.md CHANGED
@@ -352,6 +352,103 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
352
352
  - [x] **web_fetch DNS 解析时 SSRF 防护**(v0.1.25):新增 `resolveAndCheck()` 函数,用 `dns.promises.lookup()` 预解析域名,检查结果 IP 是否为私有地址。初始 URL 和 redirect 目标均校验。
353
353
  - [ ] **`persistentCwd` 全局状态**:bash 工具的当前工作目录是模块级全局变量,多 session 并发时可能串扰。现阶段单 session REPL 无影响,GUI 多会话扩展时需重构为 per-session 状态。
354
354
 
355
+ ## 本轮开发完成记录(2026-03-16,v0.1.85 → v0.1.86)
356
+
357
+ ### Web UI P1 增强(续):命令扩展 + 键盘快捷键 + 导出 + 断线重连
358
+
359
+ **P1-3:更多命令支持**
360
+
361
+ | 文件 | 变更类型 | 说明 |
362
+ |------|---------|------|
363
+ | `src/web/protocol.ts` | 修改 | 新增 `S2C_ExportData`(format/filename/content)+ `S2C_MemoryContent` 消息类型 |
364
+ | `src/web/session-handler.ts` | 修改 | 新增 `/export [md\|json]` + `/memory [show\|add\|clear]` 命令 + 3 个实现方法 |
365
+ | `src/web/client/app.js` | 修改 | `handleExportData()` 浏览器 Blob 下载 + `handleMemoryContent()` 卡片渲染 |
366
+
367
+ - `/export md`:格式化 session 消息为 Markdown(标题/元数据/User+Assistant 分段),触发浏览器下载
368
+ - `/export json`:导出结构化 JSON(title/provider/model/messages),触发浏览器下载
369
+ - `/memory`:读取 `~/.aicli/memory.md` 显示为带样式卡片
370
+ - `/memory add <text>`:追加带时间戳的条目
371
+ - `/memory clear`:清空记忆文件
372
+
373
+ **P1-4:键盘快捷键**
374
+
375
+ | 快捷键 | 功能 |
376
+ |--------|------|
377
+ | `Esc` | 停止 AI 生成(发送 abort) |
378
+ | `Ctrl+L` | 清屏(保留欢迎消息) |
379
+ | `Ctrl+K` | 清空输入框 |
380
+ | `↑` / `↓` | 历史消息回溯(最多 50 条,支持命令和聊天输入) |
381
+
382
+ - 输入历史在 `sendMessage()` 中记录,去重 + 50 条上限
383
+ - ↑ 键首次按下保存当前草稿,↓ 键到底恢复草稿
384
+ - 单行输入时 ↑ 触发历史,多行输入时保持原有光标移动
385
+
386
+ **P1-5:Markdown 导出**(合并到 P1-3 的 `/export md` 命令实现)
387
+
388
+ **P1-6:断线重连恢复**
389
+
390
+ | 文件 | 变更类型 | 说明 |
391
+ |------|---------|------|
392
+ | `src/web/client/app.js` | 修改 | 30s 心跳 ping + 指数退避重连(1s→2s→4s→10s)+ 重连后 UI 状态恢复 |
393
+ | `src/web/server.ts` | 修改 | WebSocket message handler 中识别 `ping` 并回复 `pong` |
394
+
395
+ - 心跳:客户端每 30s 发送 `{ type: 'ping' }`,服务器回复 `{ type: 'pong' }`
396
+ - 断线检测:连接关闭后显示 "Disconnected — reconnecting..."
397
+ - 指数退避:重连间隔 1s→2s→4s→8s→10s(上限),连接成功后重置
398
+ - 状态恢复:重连成功后自动请求 session list + tools list,processing 中断时显示提示
399
+
400
+ ### 版本与收尾
401
+ - `src/core/constants.ts`:VERSION `0.1.85` → `0.1.86`
402
+ - `package.json`:version 同步
403
+ - 构建验证:`npm run build` 零错误(ESM + CJS 双产物)
404
+ - Web UI 预览测试:/help 新命令显示、/memory 卡片渲染、/export 空 session 提示、心跳状态 全部通过
405
+
406
+ ---
407
+
408
+ ## 本轮开发完成记录(2026-03-16,v0.1.84 → v0.1.85)
409
+
410
+ ### Web UI P1 增强:侧边栏 Tools Tab + Diff 语法高亮
411
+
412
+ **P1-1:侧边栏增强 — Sessions/Tools 双 Tab**
413
+
414
+ 将原有单一 session 列表侧边栏升级为双 Tab 布局:
415
+
416
+ | 文件 | 变更类型 | 说明 |
417
+ |------|---------|------|
418
+ | `src/web/protocol.ts` | 修改 | 新增 `S2C_ToolsList` 消息类型(builtinTools/mcpServers/skills 三段数据) |
419
+ | `src/web/session-handler.ts` | 修改 | 新增 `/tools` 命令 + `sendToolsList()` 方法(遍历 ToolRegistry + MCP + Skills) |
420
+ | `src/web/client/index.html` | 修改 | 侧边栏重构为 Tabs(📋 Sessions / 🔧 Tools)+ 搜索框 |
421
+ | `src/web/client/style.css` | 修改 | Tab 激活样式 + 工具列表样式(section 标题/工具项/彩色圆点/MCP 折叠) |
422
+ | `src/web/client/app.js` | 修改 | Tab 切换逻辑 + `renderToolsList()` + `renderFilteredTools()` 搜索过滤 |
423
+
424
+ - **Built-in Tools**:显示所有内置工具,带危险级别彩色圆点(蓝=safe, 黄=write, 红=destructive)
425
+ - **MCP Servers**:显示连接状态(绿=connected)+ 工具数量 badge,可折叠展开查看工具列表
426
+ - **Skills**:显示所有可用技能,绿色=active,灰色=inactive
427
+ - **搜索过滤**:实时过滤工具名和描述,空 section 自动隐藏
428
+ - **Session 搜索**:Sessions tab 新增搜索框,按标题过滤
429
+
430
+ **P1-2:Diff 渲染增强 — 语法高亮**
431
+
432
+ 工具确认对话框中的 diff 预览从纯文本升级为语法高亮:
433
+
434
+ | 文件 | 变更类型 | 说明 |
435
+ |------|---------|------|
436
+ | `src/web/client/style.css` | 修改 | 新增 `.diff-add`/`.diff-del`/`.diff-hunk`/`.diff-ctx` 四种行样式 |
437
+ | `src/web/client/app.js` | 修改 | 新增 `renderDiffHtml()` 函数,按行首字符(+/-/@@)分类着色 |
438
+
439
+ - `+` 增加行:绿色文字 + 绿色半透明背景
440
+ - `-` 删除行:红色文字 + 红色半透明背景
441
+ - `@@` Hunk header:主题色(紫色),加粗
442
+ - 其他上下文行:降低不透明度
443
+
444
+ ### 版本与收尾
445
+ - `src/core/constants.ts`:VERSION `0.1.84` → `0.1.85`
446
+ - `package.json`:version 同步
447
+ - 构建验证:`npm run build` 零错误(ESM + CJS 双产物)
448
+ - Web UI 预览测试:侧边栏 Tab 切换、工具列表渲染、搜索过滤、MCP/Skills 显示 全部通过
449
+
450
+ ---
451
+
355
452
  ## 本轮开发完成记录(2026-03-16,v0.1.82 → v0.1.83)
356
453
 
357
454
  ### 新增功能:OpenRouter 内置 Provider
@@ -1932,12 +2029,12 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
1932
2029
 
1933
2030
  | # | 功能 | 状态 | 说明 |
1934
2031
  |---|------|------|------|
1935
- | P1-1 | **侧边栏增强** | [x] | Session 列表已实现;待增加 Skills + MCP 工具列表 + 搜索 |
1936
- | P1-2 | **Diff 渲染增强** | [ ] | confirm 对话框中的 diff 从纯文本升级为语法高亮的 diff 视图(绿/红增删行) |
1937
- | P1-3 | **更多命令支持** | [~] | `/cost`、`/session list\|load\|delete` 已实现;待增加 `/undo`、`/export [md\|json]`、`/tools`、`/memory` |
1938
- | P1-4 | **键盘快捷键** | [ ] | `Ctrl+L` 清屏、`Ctrl+K` 清空输入、`Esc` 停止生成、`↑` 历史消息回溯 |
1939
- | P1-5 | **Markdown 导出** | [ ] | 一键导出当前对话为 `.md` 文件下载 |
1940
- | P1-6 | **断线重连恢复** | [ ] | WebSocket 重连后恢复 session 状态(当前重连=全丢);心跳 ping/pong |
2032
+ | P1-1 | **侧边栏增强** | [x] | Sessions/Tools Tab + Built-in Tools(危险级别圆点)+ MCP Servers(连接状态+工具数)+ Skills 列表 + 搜索过滤 |
2033
+ | P1-2 | **Diff 渲染增强** | [x] | confirm 对话框 diff 语法高亮(绿色增行、红色删行、紫色 hunk header、灰色上下文) |
2034
+ | P1-3 | **更多命令支持** | [x] | `/export [md\|json]`(浏览器下载)、`/memory [show\|add\|clear]`、`/tools`、`/cost`、`/session` |
2035
+ | P1-4 | **键盘快捷键** | [x] | `Ctrl+L` 清屏、`Ctrl+K` 清空输入、`Esc` 停止生成、`↑↓` 历史消息回溯(50 条) |
2036
+ | P1-5 | **Markdown 导出** | [x] | `/export md` 一键导出对话为 `.md` 文件浏览器下载、`/export json` 导出 JSON |
2037
+ | P1-6 | **断线重连恢复** | [x] | 30s 心跳 ping/pong、指数退避重连(1s→2s→4s→10s)、重连后状态恢复提示 |
1941
2038
 
1942
2039
  ### P2 — 差异化功能(Web 独有优势)
1943
2040
 
@@ -16,7 +16,7 @@ import {
16
16
  SUBAGENT_MAX_ROUNDS_LIMIT,
17
17
  VERSION,
18
18
  runTestsTool
19
- } from "./chunk-YHB3S2KS.js";
19
+ } from "./chunk-SMLN357K.js";
20
20
 
21
21
  // src/config/config-manager.ts
22
22
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
@@ -14,7 +14,7 @@ import { platform } from "os";
14
14
  import chalk from "chalk";
15
15
 
16
16
  // src/core/constants.ts
17
- var VERSION = "0.1.84";
17
+ var VERSION = "0.1.86";
18
18
  var APP_NAME = "ai-cli";
19
19
  var CONFIG_DIR_NAME = ".aicli";
20
20
  var CONFIG_FILE_NAME = "config.json";
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  theme,
36
36
  truncateOutput,
37
37
  undoStack
38
- } from "./chunk-L2PQET5S.js";
38
+ } from "./chunk-PCT4OP6Q.js";
39
39
  import {
40
40
  AGENTIC_BEHAVIOR_GUIDELINE,
41
41
  AUTHOR,
@@ -55,7 +55,7 @@ import {
55
55
  REPO_URL,
56
56
  SKILLS_DIR_NAME,
57
57
  VERSION
58
- } from "./chunk-YHB3S2KS.js";
58
+ } from "./chunk-SMLN357K.js";
59
59
 
60
60
  // src/index.ts
61
61
  import { program } from "commander";
@@ -1904,7 +1904,7 @@ ${hint}` : "")
1904
1904
  description: "Run project tests and show structured report",
1905
1905
  usage: "/test [command|filter]",
1906
1906
  async execute(args, _ctx) {
1907
- const { executeTests } = await import("./run-tests-V2VE7ST5.js");
1907
+ const { executeTests } = await import("./run-tests-Y2PN5FYJ.js");
1908
1908
  const argStr = args.join(" ").trim();
1909
1909
  let testArgs = {};
1910
1910
  if (argStr) {
@@ -5292,7 +5292,7 @@ program.command("web").description("Start Web UI server with browser-based chat
5292
5292
  console.error("Error: Invalid port number. Must be between 1 and 65535.");
5293
5293
  process.exit(1);
5294
5294
  }
5295
- const { startWebServer } = await import("./server-XWSMOXVH.js");
5295
+ const { startWebServer } = await import("./server-BFNZIL3O.js");
5296
5296
  await startWebServer({ port, host: options.host });
5297
5297
  });
5298
5298
  program.command("sessions").description("List recent conversation sessions").action(async () => {
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  executeTests,
4
4
  runTestsTool
5
- } from "./chunk-YHB3S2KS.js";
5
+ } from "./chunk-SMLN357K.js";
6
6
  export {
7
7
  executeTests,
8
8
  runTestsTool
@@ -23,7 +23,7 @@ import {
23
23
  setupProxy,
24
24
  spawnAgentContext,
25
25
  truncateOutput
26
- } from "./chunk-L2PQET5S.js";
26
+ } from "./chunk-PCT4OP6Q.js";
27
27
  import {
28
28
  AGENTIC_BEHAVIOR_GUIDELINE,
29
29
  CONTEXT_FILE_CANDIDATES,
@@ -36,7 +36,7 @@ import {
36
36
  SKILLS_DIR_NAME,
37
37
  VERSION,
38
38
  __require
39
- } from "./chunk-YHB3S2KS.js";
39
+ } from "./chunk-SMLN357K.js";
40
40
 
41
41
  // src/web/server.ts
42
42
  import express from "express";
@@ -390,7 +390,7 @@ function loadMemoryContent(configDir) {
390
390
  }
391
391
 
392
392
  // src/web/session-handler.ts
393
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
393
+ import { existsSync as existsSync3, readFileSync as readFileSync3, appendFileSync, writeFileSync, mkdirSync } from "fs";
394
394
  import { join as join2, resolve } from "path";
395
395
  var MAX_TOOL_ROUNDS = 25;
396
396
  var FREE_ROUND_TOOLS = /* @__PURE__ */ new Set(["write_todos"]);
@@ -961,6 +961,11 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
961
961
  " /session delete <id> \u2014 Delete a session",
962
962
  " /status \u2014 Show session info & token usage",
963
963
  " /cost \u2014 Show cumulative token usage",
964
+ " /tools \u2014 Show tools, MCP servers & skills in sidebar",
965
+ " /export [md|json] \u2014 Export conversation as Markdown or JSON",
966
+ " /memory \u2014 Show persistent memory contents",
967
+ " /memory add <text> \u2014 Add entry to persistent memory",
968
+ " /memory clear \u2014 Clear persistent memory",
964
969
  " /help \u2014 Show this help message",
965
970
  "",
966
971
  "\u{1F4A1} Tips:",
@@ -968,7 +973,9 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
968
973
  " \u2022 Drag & drop or Ctrl+V to paste images",
969
974
  " \u2022 Type @ to reference files from your project",
970
975
  " \u2022 Use Shift+Enter for multi-line input",
971
- " \u2022 During AI processing, type to send corrections"
976
+ " \u2022 During AI processing, type to send corrections",
977
+ " \u2022 Ctrl+L to clear screen, Esc to stop generation",
978
+ " \u2022 \u2191/\u2193 arrow keys to recall previous messages"
972
979
  ].join("\n")
973
980
  });
974
981
  break;
@@ -985,6 +992,25 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
985
992
  });
986
993
  break;
987
994
  }
995
+ case "tools":
996
+ this.sendToolsList();
997
+ break;
998
+ case "export": {
999
+ const format = args[0] === "json" ? "json" : "md";
1000
+ this.exportConversation(format);
1001
+ break;
1002
+ }
1003
+ case "memory": {
1004
+ const sub = args[0];
1005
+ if (sub === "add" && args.length > 1) {
1006
+ this.memoryAdd(args.slice(1).join(" "));
1007
+ } else if (sub === "clear") {
1008
+ this.memoryClear();
1009
+ } else {
1010
+ this.memoryShow();
1011
+ }
1012
+ break;
1013
+ }
988
1014
  default:
989
1015
  this.send({ type: "error", message: `Unknown command: /${name}. Type /help for available commands.` });
990
1016
  }
@@ -1036,6 +1062,122 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
1036
1062
  }))
1037
1063
  });
1038
1064
  }
1065
+ sendToolsList() {
1066
+ const allDefs = this.toolRegistry.getDefinitions();
1067
+ const builtinTools = allDefs.filter((d) => !d.name.startsWith("mcp__")).map((d) => ({
1068
+ name: d.name,
1069
+ description: d.description,
1070
+ dangerLevel: getDangerLevel(d.name, {})
1071
+ }));
1072
+ const mcpServers = [];
1073
+ if (this.mcpManager) {
1074
+ for (const status of this.mcpManager.getStatus()) {
1075
+ const serverTools = allDefs.filter((d) => d.name.startsWith(`mcp__${status.serverId}__`)).map((d) => d.name.replace(`mcp__${status.serverId}__`, ""));
1076
+ mcpServers.push({
1077
+ serverId: status.serverId,
1078
+ serverName: status.serverName,
1079
+ toolCount: status.toolCount,
1080
+ connected: status.connected,
1081
+ tools: serverTools
1082
+ });
1083
+ }
1084
+ }
1085
+ const skills = (this.skillManager?.listSkills() ?? []).map((s) => ({
1086
+ name: s.meta.name,
1087
+ description: s.meta.description,
1088
+ isActive: this.skillManager?.getActive()?.meta.name === s.meta.name
1089
+ }));
1090
+ this.send({
1091
+ type: "tools_list",
1092
+ builtinTools,
1093
+ mcpServers,
1094
+ skills
1095
+ });
1096
+ }
1097
+ exportConversation(format) {
1098
+ const session = this.sessions.current;
1099
+ if (!session || session.messages.length === 0) {
1100
+ this.send({ type: "info", message: "No messages to export." });
1101
+ return;
1102
+ }
1103
+ const title = session.title ?? "Untitled";
1104
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1105
+ if (format === "json") {
1106
+ const data = {
1107
+ title,
1108
+ provider: this.currentProvider,
1109
+ model: this.currentModel,
1110
+ exported: (/* @__PURE__ */ new Date()).toISOString(),
1111
+ messages: session.messages.map((m) => ({
1112
+ role: m.role,
1113
+ content: getContentText(m.content),
1114
+ timestamp: m.timestamp?.toISOString()
1115
+ }))
1116
+ };
1117
+ this.send({
1118
+ type: "export_data",
1119
+ format: "json",
1120
+ filename: `aicli-${timestamp}.json`,
1121
+ content: JSON.stringify(data, null, 2)
1122
+ });
1123
+ } else {
1124
+ const lines = [`# ${title}`, "", `> Exported: ${(/* @__PURE__ */ new Date()).toLocaleString()} | Provider: ${this.currentProvider} | Model: ${this.currentModel}`, ""];
1125
+ for (const m of session.messages) {
1126
+ const text = getContentText(m.content);
1127
+ if (m.role === "user") {
1128
+ lines.push(`## \u{1F9D1} User`, "", text, "");
1129
+ } else if (m.role === "assistant") {
1130
+ lines.push(`## \u{1F916} Assistant`, "", text, "");
1131
+ }
1132
+ }
1133
+ this.send({
1134
+ type: "export_data",
1135
+ format: "md",
1136
+ filename: `aicli-${timestamp}.md`,
1137
+ content: lines.join("\n")
1138
+ });
1139
+ }
1140
+ }
1141
+ memoryShow() {
1142
+ const configDir = this.config.getConfigDir();
1143
+ const memPath = join2(configDir, MEMORY_FILE_NAME);
1144
+ let content = "";
1145
+ try {
1146
+ if (existsSync3(memPath)) {
1147
+ content = readFileSync3(memPath, "utf-8");
1148
+ }
1149
+ } catch {
1150
+ }
1151
+ this.send({
1152
+ type: "memory_content",
1153
+ content: content || "(empty \u2014 no persistent memory entries yet)",
1154
+ filePath: memPath
1155
+ });
1156
+ }
1157
+ memoryAdd(text) {
1158
+ const configDir = this.config.getConfigDir();
1159
+ const memPath = join2(configDir, MEMORY_FILE_NAME);
1160
+ try {
1161
+ mkdirSync(configDir, { recursive: true });
1162
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace("T", " ");
1163
+ appendFileSync(memPath, `
1164
+ - [${timestamp}] ${text}
1165
+ `, "utf-8");
1166
+ this.send({ type: "info", message: `\u{1F4DD} Memory entry added: "${text}"` });
1167
+ } catch (err) {
1168
+ this.send({ type: "error", message: `Failed to write memory: ${err.message}` });
1169
+ }
1170
+ }
1171
+ memoryClear() {
1172
+ const configDir = this.config.getConfigDir();
1173
+ const memPath = join2(configDir, MEMORY_FILE_NAME);
1174
+ try {
1175
+ writeFileSync(memPath, "", "utf-8");
1176
+ this.send({ type: "info", message: "\u{1F5D1}\uFE0F Persistent memory cleared." });
1177
+ } catch (err) {
1178
+ this.send({ type: "error", message: `Failed to clear memory: ${err.message}` });
1179
+ }
1180
+ }
1039
1181
  sendSessionMessages() {
1040
1182
  const session = this.sessions.current;
1041
1183
  if (!session) return;
@@ -1293,7 +1435,15 @@ async function startWebServer(options = {}) {
1293
1435
  activeHandler = handler;
1294
1436
  ws.on("message", async (data) => {
1295
1437
  try {
1296
- await handler.handleMessage(data.toString());
1438
+ const raw = data.toString();
1439
+ const parsed = JSON.parse(raw);
1440
+ if (parsed.type === "ping") {
1441
+ if (ws.readyState === ws.OPEN) {
1442
+ ws.send(JSON.stringify({ type: "pong" }));
1443
+ }
1444
+ return;
1445
+ }
1446
+ await handler.handleMessage(raw);
1297
1447
  } catch (err) {
1298
1448
  const message = err instanceof Error ? err.message : String(err);
1299
1449
  console.error(` Error: ${message}`);
@@ -14,6 +14,9 @@ let currentThinkingEl = null;
14
14
  let currentThinkingContent = '';
15
15
  let providers = [];
16
16
  let pendingImages = []; // { name, data (base64), mime }
17
+ let inputHistory = []; // Previous user inputs for ↑/↓ navigation
18
+ let historyIndex = -1; // -1 = not browsing history
19
+ let savedInputDraft = ''; // Saved current input when entering history mode
17
20
 
18
21
  // ── DOM refs ───────────────────────────────────────────────────────
19
22
 
@@ -34,6 +37,11 @@ const connectionStatus = document.getElementById('connection-status');
34
37
  const sidebar = document.getElementById('sidebar');
35
38
  const sessionListEl = document.getElementById('session-list');
36
39
  const btnNewSession = document.getElementById('btn-new-session');
40
+ const toolsListEl = document.getElementById('tools-list');
41
+ const sessionSearchInput = document.getElementById('session-search');
42
+ const toolsSearchInput = document.getElementById('tools-search');
43
+ let cachedSessions = [];
44
+ let cachedToolsData = null;
37
45
 
38
46
  // ── Configure marked.js ────────────────────────────────────────────
39
47
 
@@ -51,22 +59,41 @@ marked.setOptions({
51
59
 
52
60
  // ── WebSocket ──────────────────────────────────────────────────────
53
61
 
62
+ let heartbeatTimer = null;
63
+ let reconnectDelay = 1000; // Start at 1s, exponential backoff
64
+
54
65
  function connect() {
55
66
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
56
67
  ws = new WebSocket(`${protocol}//${location.host}`);
57
68
 
58
69
  ws.onopen = () => {
59
70
  connected = true;
71
+ reconnectDelay = 1000; // Reset backoff on success
60
72
  connectionStatus.textContent = '🟢 Connected';
61
73
  connectionStatus.className = 'status-connected';
62
74
  requestSessionList();
75
+ // Start heartbeat ping every 30s
76
+ clearInterval(heartbeatTimer);
77
+ heartbeatTimer = setInterval(() => {
78
+ if (ws && ws.readyState === WebSocket.OPEN) {
79
+ ws.send(JSON.stringify({ type: 'ping' }));
80
+ }
81
+ }, 30000);
82
+ // If we had a session & were processing, reset UI state
83
+ if (processing) {
84
+ setProcessing(false);
85
+ addInfoMessage('⚡ Reconnected — previous generation may have been interrupted.');
86
+ }
63
87
  };
64
88
 
65
89
  ws.onclose = () => {
66
90
  connected = false;
67
- connectionStatus.textContent = '🔴 Disconnected';
91
+ clearInterval(heartbeatTimer);
92
+ connectionStatus.textContent = '🔴 Disconnected — reconnecting...';
68
93
  connectionStatus.className = 'status-disconnected';
69
- setTimeout(connect, 3000);
94
+ // Exponential backoff: 1s, 2s, 4s, max 10s
95
+ setTimeout(connect, reconnectDelay);
96
+ reconnectDelay = Math.min(reconnectDelay * 2, 10000);
70
97
  };
71
98
 
72
99
  ws.onerror = () => {
@@ -76,7 +103,9 @@ function connect() {
76
103
 
77
104
  ws.onmessage = (event) => {
78
105
  try {
79
- handleServerMessage(JSON.parse(event.data));
106
+ const msg = JSON.parse(event.data);
107
+ if (msg.type === 'pong') return; // Heartbeat response, ignore
108
+ handleServerMessage(msg);
80
109
  } catch (e) {
81
110
  console.error('Failed to parse message:', e);
82
111
  }
@@ -107,6 +136,9 @@ function handleServerMessage(msg) {
107
136
  case 'status': handleStatus(msg); break;
108
137
  case 'session_list': renderSessionList(msg.sessions); break;
109
138
  case 'session_messages':renderSessionMessages(msg.messages); break;
139
+ case 'tools_list': renderToolsList(msg); switchSidebarTab('tools'); break;
140
+ case 'export_data': handleExportData(msg); break;
141
+ case 'memory_content': handleMemoryContent(msg); break;
110
142
  case 'info': addInfoMessage(msg.message); break;
111
143
  case 'error': addErrorMessage(msg.message); setProcessing(false); break;
112
144
  case 'round_progress': break;
@@ -205,7 +237,7 @@ function handleConfirmRequest(msg) {
205
237
  <span class="badge ${isDestructive ? 'badge-error' : 'badge-warning'} badge-sm">${isDestructive ? '⚠ DESTRUCTIVE' : '✎ Write'}</span>
206
238
  <span class="text-sm font-semibold">${escapeHtml(msg.toolName)}</span>
207
239
  </div>
208
- ${msg.diff ? `<div class="confirm-diff w-full">${escapeHtml(msg.diff)}</div>` : ''}
240
+ ${msg.diff ? `<div class="confirm-diff w-full">${renderDiffHtml(msg.diff)}</div>` : ''}
209
241
  <div class="flex gap-2 mt-2">
210
242
  <button class="btn btn-success btn-sm btn-outline" onclick="respondConfirm('${msg.requestId}', true)">✓ Approve</button>
211
243
  <button class="btn btn-error btn-sm btn-outline" onclick="respondConfirm('${msg.requestId}', false)">✗ Deny</button>
@@ -513,6 +545,12 @@ async function sendMessage() {
513
545
  const args = parts.slice(1);
514
546
  send({ type: 'command', name, args });
515
547
  addInfoMessage(`/${text.slice(1)}`);
548
+ // Track command in history
549
+ if (inputHistory[inputHistory.length - 1] !== text) {
550
+ inputHistory.push(text);
551
+ if (inputHistory.length > 50) inputHistory.shift();
552
+ }
553
+ historyIndex = -1;
516
554
  userInput.value = '';
517
555
  userInput.style.height = 'auto';
518
556
  return;
@@ -539,6 +577,14 @@ async function sendMessage() {
539
577
  }
540
578
  }
541
579
 
580
+ // Track input history
581
+ if (inputHistory[inputHistory.length - 1] !== text) {
582
+ inputHistory.push(text);
583
+ if (inputHistory.length > 50) inputHistory.shift();
584
+ }
585
+ historyIndex = -1;
586
+ savedInputDraft = '';
587
+
542
588
  addUserMessage(text, pendingImages);
543
589
  const msg = { type: 'chat', content: resolvedText };
544
590
  if (pendingImages.length > 0) {
@@ -591,8 +637,16 @@ modelSelect.addEventListener('change', () => {
591
637
  // ── Session management ──────────────────────────────────────────────
592
638
 
593
639
  function renderSessionList(sessions) {
640
+ cachedSessions = sessions || [];
641
+ renderFilteredSessions(sessionSearchInput?.value || '');
642
+ }
643
+
644
+ function renderFilteredSessions(filter) {
645
+ const sessions = filter
646
+ ? cachedSessions.filter(s => (s.title || '').toLowerCase().includes(filter.toLowerCase()))
647
+ : cachedSessions;
594
648
  if (!sessions || sessions.length === 0) {
595
- sessionListEl.innerHTML = '<div class="text-xs opacity-40 text-center py-4">No sessions yet</div>';
649
+ sessionListEl.innerHTML = `<div class="text-xs opacity-40 text-center py-4">${filter ? 'No matches' : 'No sessions yet'}</div>`;
596
650
  return;
597
651
  }
598
652
  sessionListEl.innerHTML = sessions.map(s => {
@@ -656,6 +710,122 @@ btnNewSession.addEventListener('click', () => {
656
710
  // Request session list on connect
657
711
  function requestSessionList() {
658
712
  send({ type: 'command', name: 'session', args: ['list'] });
713
+ // Also request tools list for sidebar
714
+ send({ type: 'command', name: 'tools', args: [] });
715
+ }
716
+
717
+ // Session search filter
718
+ if (sessionSearchInput) {
719
+ sessionSearchInput.addEventListener('input', () => {
720
+ renderFilteredSessions(sessionSearchInput.value);
721
+ });
722
+ }
723
+
724
+ // ── Sidebar tabs ──────────────────────────────────────────────────────
725
+
726
+ function switchSidebarTab(tabName) {
727
+ document.querySelectorAll('.sidebar-tab').forEach(btn => {
728
+ btn.classList.toggle('active', btn.dataset.tab === tabName);
729
+ });
730
+ document.querySelectorAll('.sidebar-tab-content').forEach(el => {
731
+ el.classList.toggle('hidden', el.id !== `tab-${tabName}`);
732
+ });
733
+ }
734
+
735
+ document.querySelectorAll('.sidebar-tab').forEach(btn => {
736
+ btn.addEventListener('click', () => switchSidebarTab(btn.dataset.tab));
737
+ });
738
+
739
+ // ── Tools list rendering ──────────────────────────────────────────────
740
+
741
+ function renderToolsList(data) {
742
+ cachedToolsData = data;
743
+ renderFilteredTools(toolsSearchInput?.value || '');
744
+ }
745
+
746
+ function renderFilteredTools(filter) {
747
+ if (!cachedToolsData) {
748
+ toolsListEl.innerHTML = '<div class="text-xs opacity-40 text-center py-4">Type /tools to load</div>';
749
+ return;
750
+ }
751
+ const fl = filter.toLowerCase();
752
+ let html = '';
753
+
754
+ // Built-in tools
755
+ const tools = fl
756
+ ? cachedToolsData.builtinTools.filter(t => t.name.includes(fl) || t.description.toLowerCase().includes(fl))
757
+ : cachedToolsData.builtinTools;
758
+ if (tools.length > 0) {
759
+ html += '<div class="tools-section-title">Built-in Tools</div>';
760
+ html += tools.map(t =>
761
+ `<div class="tool-item" title="${escapeHtml(t.description)}">
762
+ <span class="tool-dot tool-dot-${t.dangerLevel}"></span>
763
+ <span class="flex-1 truncate">${escapeHtml(t.name)}</span>
764
+ </div>`
765
+ ).join('');
766
+ }
767
+
768
+ // MCP servers
769
+ const servers = fl
770
+ ? cachedToolsData.mcpServers.filter(s =>
771
+ s.serverId.includes(fl) || s.serverName.toLowerCase().includes(fl) ||
772
+ s.tools.some(t => t.includes(fl)))
773
+ : cachedToolsData.mcpServers;
774
+ if (servers.length > 0) {
775
+ html += '<div class="tools-section-title mt-2">MCP Servers</div>';
776
+ for (const s of servers) {
777
+ const dotClass = s.connected ? 'tool-dot-connected' : 'tool-dot-disconnected';
778
+ html += `<details class="mcp-server-item">
779
+ <summary class="flex items-center gap-2 cursor-pointer select-none">
780
+ <span class="tool-dot ${dotClass}"></span>
781
+ <span class="flex-1 truncate">${escapeHtml(s.serverId)}</span>
782
+ <span class="text-xs opacity-40">${s.toolCount}</span>
783
+ </summary>
784
+ <div class="mcp-server-tools">
785
+ ${s.tools.map(t => `<div class="py-0.5">${escapeHtml(t)}</div>`).join('')}
786
+ </div>
787
+ </details>`;
788
+ }
789
+ }
790
+
791
+ // Skills
792
+ const skills = fl
793
+ ? cachedToolsData.skills.filter(s => s.name.includes(fl) || s.description.toLowerCase().includes(fl))
794
+ : cachedToolsData.skills;
795
+ if (skills.length > 0) {
796
+ html += '<div class="tools-section-title mt-2">Skills</div>';
797
+ html += skills.map(s =>
798
+ `<div class="tool-item" title="${escapeHtml(s.description)}">
799
+ <span class="tool-dot ${s.isActive ? 'tool-dot-active' : 'tool-dot-inactive'}"></span>
800
+ <span class="flex-1 truncate">${escapeHtml(s.name)}</span>
801
+ ${s.isActive ? '<span class="badge badge-success badge-xs">active</span>' : ''}
802
+ </div>`
803
+ ).join('');
804
+ }
805
+
806
+ if (!html) {
807
+ html = '<div class="text-xs opacity-40 text-center py-4">No matches</div>';
808
+ }
809
+ toolsListEl.innerHTML = html;
810
+ }
811
+
812
+ // Tools search filter
813
+ if (toolsSearchInput) {
814
+ toolsSearchInput.addEventListener('input', () => {
815
+ renderFilteredTools(toolsSearchInput.value);
816
+ });
817
+ }
818
+
819
+ // ── Diff syntax highlighting ──────────────────────────────────────────
820
+
821
+ function renderDiffHtml(diffText) {
822
+ return diffText.split('\n').map(line => {
823
+ const escaped = escapeHtml(line);
824
+ if (line.startsWith('@@')) return `<div class="diff-hunk">${escaped}</div>`;
825
+ if (line.startsWith('+')) return `<div class="diff-add">${escaped}</div>`;
826
+ if (line.startsWith('-')) return `<div class="diff-del">${escaped}</div>`;
827
+ return `<div class="diff-ctx">${escaped}</div>`;
828
+ }).join('');
659
829
  }
660
830
 
661
831
  // ── @ File reference autocomplete ───────────────────────────────────
@@ -806,6 +976,72 @@ userInput.addEventListener('keydown', (e) => {
806
976
  }
807
977
  }
808
978
 
979
+ // ── Keyboard shortcuts ───────────────────────────────────────
980
+
981
+ // Escape: stop generation
982
+ if (e.key === 'Escape') {
983
+ if (processing) {
984
+ e.preventDefault();
985
+ send({ type: 'abort' });
986
+ return;
987
+ }
988
+ }
989
+
990
+ // Ctrl+L: clear screen (keep welcome)
991
+ if (e.key === 'l' && (e.ctrlKey || e.metaKey)) {
992
+ e.preventDefault();
993
+ const welcome = messagesEl.querySelector('.chat');
994
+ messagesEl.innerHTML = '';
995
+ if (welcome) messagesEl.appendChild(welcome);
996
+ return;
997
+ }
998
+
999
+ // Ctrl+K: clear input
1000
+ if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
1001
+ e.preventDefault();
1002
+ userInput.value = '';
1003
+ userInput.style.height = 'auto';
1004
+ return;
1005
+ }
1006
+
1007
+ // ↑/↓: input history navigation (only when input is empty or browsing history, single-line)
1008
+ if (e.key === 'ArrowUp' && !e.shiftKey) {
1009
+ const isAtStart = userInput.selectionStart === 0 && userInput.selectionEnd === 0;
1010
+ const isSingleLine = !userInput.value.includes('\n');
1011
+ if ((isSingleLine || userInput.value === '') && (isAtStart || userInput.value === '')) {
1012
+ if (inputHistory.length > 0) {
1013
+ e.preventDefault();
1014
+ if (historyIndex === -1) {
1015
+ savedInputDraft = userInput.value;
1016
+ historyIndex = inputHistory.length - 1;
1017
+ } else if (historyIndex > 0) {
1018
+ historyIndex--;
1019
+ }
1020
+ userInput.value = inputHistory[historyIndex] || '';
1021
+ userInput.style.height = 'auto';
1022
+ userInput.style.height = Math.min(userInput.scrollHeight, 200) + 'px';
1023
+ return;
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ if (e.key === 'ArrowDown' && !e.shiftKey) {
1029
+ if (historyIndex >= 0) {
1030
+ e.preventDefault();
1031
+ if (historyIndex < inputHistory.length - 1) {
1032
+ historyIndex++;
1033
+ userInput.value = inputHistory[historyIndex] || '';
1034
+ } else {
1035
+ historyIndex = -1;
1036
+ userInput.value = savedInputDraft;
1037
+ }
1038
+ userInput.style.height = 'auto';
1039
+ userInput.style.height = Math.min(userInput.scrollHeight, 200) + 'px';
1040
+ return;
1041
+ }
1042
+ }
1043
+
1044
+ // Enter: send message
809
1045
  if (e.key === 'Enter' && !e.shiftKey) {
810
1046
  e.preventDefault();
811
1047
  sendMessage();
@@ -885,6 +1121,34 @@ userInput.addEventListener('paste', (e) => {
885
1121
  }
886
1122
  });
887
1123
 
1124
+ // ── Export & Memory handlers ────────────────────────────────────────
1125
+
1126
+ function handleExportData(msg) {
1127
+ // Trigger browser download
1128
+ const blob = new Blob([msg.content], { type: msg.format === 'json' ? 'application/json' : 'text/markdown' });
1129
+ const url = URL.createObjectURL(blob);
1130
+ const a = document.createElement('a');
1131
+ a.href = url;
1132
+ a.download = msg.filename;
1133
+ document.body.appendChild(a);
1134
+ a.click();
1135
+ document.body.removeChild(a);
1136
+ URL.revokeObjectURL(url);
1137
+ addInfoMessage(`📥 Exported: ${msg.filename}`);
1138
+ }
1139
+
1140
+ function handleMemoryContent(msg) {
1141
+ const el = document.createElement('div');
1142
+ el.className = 'msg-assistant my-2';
1143
+ el.innerHTML = `
1144
+ <div class="text-xs opacity-50 mb-1">📝 Persistent Memory</div>
1145
+ <pre class="text-sm whitespace-pre-wrap opacity-80">${escapeHtml(msg.content)}</pre>
1146
+ <div class="text-xs opacity-30 mt-1">${escapeHtml(msg.filePath)}</div>
1147
+ `;
1148
+ messagesEl.appendChild(el);
1149
+ scrollToBottom();
1150
+ }
1151
+
888
1152
  // ── Initialize ─────────────────────────────────────────────────────
889
1153
 
890
1154
  // Restore theme
@@ -50,12 +50,29 @@
50
50
 
51
51
  <!-- Sidebar -->
52
52
  <aside id="sidebar" class="sidebar bg-base-200 border-r border-base-content/10 flex flex-col w-64 flex-shrink-0 overflow-hidden transition-all duration-200">
53
- <div class="p-3 border-b border-base-content/10 flex items-center justify-between h-12">
54
- <span class="font-semibold text-sm leading-none">Sessions</span>
55
- <button id="btn-new-session" class="btn btn-xs btn-primary btn-outline leading-none" title="New session">+ New</button>
53
+ <!-- Sidebar tabs -->
54
+ <div class="flex border-b border-base-content/10 flex-shrink-0">
55
+ <button class="sidebar-tab active flex-1 text-xs font-semibold py-2 px-1 text-center" data-tab="sessions">📋 Sessions</button>
56
+ <button class="sidebar-tab flex-1 text-xs font-semibold py-2 px-1 text-center" data-tab="tools">🔧 Tools</button>
56
57
  </div>
57
- <div id="session-list" class="flex-1 overflow-y-auto p-2 flex flex-col gap-1 text-sm">
58
- <div class="text-xs opacity-40 text-center py-4">No sessions yet</div>
58
+ <!-- Sessions tab -->
59
+ <div id="tab-sessions" class="sidebar-tab-content flex flex-col flex-1 overflow-hidden">
60
+ <div class="p-2 border-b border-base-content/10 flex items-center justify-between">
61
+ <input id="session-search" type="text" class="input input-xs input-bordered flex-1 mr-2" placeholder="Search sessions...">
62
+ <button id="btn-new-session" class="btn btn-xs btn-primary btn-outline flex-shrink-0" title="New session">+ New</button>
63
+ </div>
64
+ <div id="session-list" class="flex-1 overflow-y-auto p-2 flex flex-col gap-1 text-sm">
65
+ <div class="text-xs opacity-40 text-center py-4">No sessions yet</div>
66
+ </div>
67
+ </div>
68
+ <!-- Tools tab -->
69
+ <div id="tab-tools" class="sidebar-tab-content flex flex-col flex-1 overflow-hidden hidden">
70
+ <div class="p-2 border-b border-base-content/10">
71
+ <input id="tools-search" type="text" class="input input-xs input-bordered w-full" placeholder="Search tools...">
72
+ </div>
73
+ <div id="tools-list" class="flex-1 overflow-y-auto p-2 text-sm">
74
+ <div class="text-xs opacity-40 text-center py-4">Type /tools to load</div>
75
+ </div>
59
76
  </div>
60
77
  </aside>
61
78
 
@@ -239,6 +239,20 @@
239
239
  .status-connected { color: oklch(var(--su)); }
240
240
  .status-disconnected { color: oklch(var(--er)); }
241
241
 
242
+ /* ── Sidebar tabs ──────────────────────────────────── */
243
+ .sidebar-tab {
244
+ cursor: pointer;
245
+ opacity: 0.5;
246
+ transition: all 0.15s;
247
+ border-bottom: 2px solid transparent;
248
+ }
249
+ .sidebar-tab:hover { opacity: 0.8; }
250
+ .sidebar-tab.active {
251
+ opacity: 1;
252
+ border-bottom-color: oklch(var(--p));
253
+ color: oklch(var(--p));
254
+ }
255
+
242
256
  /* ── Sidebar ───────────────────────────────────────── */
243
257
  .sidebar .session-item {
244
258
  padding: 0.5rem 0.6rem;
@@ -280,6 +294,73 @@
280
294
  display: inline-block;
281
295
  }
282
296
 
297
+ /* ── Diff syntax highlighting ─────────────────────── */
298
+ .confirm-diff .diff-add {
299
+ color: oklch(var(--su));
300
+ background: oklch(var(--su) / 0.1);
301
+ }
302
+ .confirm-diff .diff-del {
303
+ color: oklch(var(--er));
304
+ background: oklch(var(--er) / 0.1);
305
+ }
306
+ .confirm-diff .diff-hunk {
307
+ color: oklch(var(--p));
308
+ opacity: 0.8;
309
+ font-weight: 600;
310
+ }
311
+ .confirm-diff .diff-ctx {
312
+ opacity: 0.6;
313
+ }
314
+
315
+ /* ── Tools sidebar list ──────────────────────────── */
316
+ .tools-section-title {
317
+ font-size: 0.7rem;
318
+ font-weight: 700;
319
+ text-transform: uppercase;
320
+ letter-spacing: 0.05em;
321
+ opacity: 0.4;
322
+ padding: 0.5rem 0.25rem 0.25rem;
323
+ }
324
+ .tool-item {
325
+ padding: 0.3rem 0.5rem;
326
+ border-radius: 0.25rem;
327
+ font-size: 0.8rem;
328
+ display: flex;
329
+ align-items: center;
330
+ gap: 0.35rem;
331
+ }
332
+ .tool-item:hover {
333
+ background: oklch(var(--b3));
334
+ }
335
+ .tool-item .tool-dot {
336
+ width: 6px;
337
+ height: 6px;
338
+ border-radius: 50%;
339
+ flex-shrink: 0;
340
+ }
341
+ .tool-dot-safe { background: oklch(var(--in)); }
342
+ .tool-dot-write { background: oklch(var(--wa)); }
343
+ .tool-dot-destructive { background: oklch(var(--er)); }
344
+ .tool-dot-connected { background: oklch(var(--su)); }
345
+ .tool-dot-disconnected { background: oklch(var(--er)); }
346
+ .tool-dot-active { background: oklch(var(--su)); }
347
+ .tool-dot-inactive { background: oklch(var(--bc) / 0.3); }
348
+
349
+ .mcp-server-item {
350
+ padding: 0.3rem 0.5rem;
351
+ border-radius: 0.25rem;
352
+ cursor: pointer;
353
+ font-size: 0.8rem;
354
+ }
355
+ .mcp-server-item:hover {
356
+ background: oklch(var(--b3));
357
+ }
358
+ .mcp-server-tools {
359
+ padding-left: 1.25rem;
360
+ font-size: 0.75rem;
361
+ opacity: 0.6;
362
+ }
363
+
283
364
  /* ── Responsive ─────────────────────────────────────── */
284
365
  @media (max-width: 768px) {
285
366
  .sidebar { width: 0; padding: 0; border: none; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",