jinzd-ai-cli 0.1.85 → 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 +57 -4
- package/dist/{chunk-QHZGVP5X.js → chunk-PCT4OP6Q.js} +1 -1
- package/dist/{chunk-VR7VECPG.js → chunk-SMLN357K.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/{run-tests-IFXCV4UW.js → run-tests-Y2PN5FYJ.js} +1 -1
- package/dist/{server-2LECXHO6.js → server-BFNZIL3O.js} +119 -5
- package/dist/web/client/app.js +137 -3
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -352,6 +352,59 @@ 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
|
+
|
|
355
408
|
## 本轮开发完成记录(2026-03-16,v0.1.84 → v0.1.85)
|
|
356
409
|
|
|
357
410
|
### Web UI P1 增强:侧边栏 Tools Tab + Diff 语法高亮
|
|
@@ -1978,10 +2031,10 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
|
|
|
1978
2031
|
|---|------|------|------|
|
|
1979
2032
|
| P1-1 | **侧边栏增强** | [x] | Sessions/Tools 双 Tab + Built-in Tools(危险级别圆点)+ MCP Servers(连接状态+工具数)+ Skills 列表 + 搜索过滤 |
|
|
1980
2033
|
| P1-2 | **Diff 渲染增强** | [x] | confirm 对话框 diff 语法高亮(绿色增行、红色删行、紫色 hunk header、灰色上下文) |
|
|
1981
|
-
| P1-3 | **更多命令支持** | [
|
|
1982
|
-
| P1-4 | **键盘快捷键** | [
|
|
1983
|
-
| P1-5 | **Markdown 导出** | [
|
|
1984
|
-
| P1-6 | **断线重连恢复** | [
|
|
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)、重连后状态恢复提示 |
|
|
1985
2038
|
|
|
1986
2039
|
### P2 — 差异化功能(Web 独有优势)
|
|
1987
2040
|
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
theme,
|
|
36
36
|
truncateOutput,
|
|
37
37
|
undoStack
|
|
38
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
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-
|
|
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 () => {
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
setupProxy,
|
|
24
24
|
spawnAgentContext,
|
|
25
25
|
truncateOutput
|
|
26
|
-
} from "./chunk-
|
|
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-
|
|
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"]);
|
|
@@ -962,6 +962,10 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
|
|
|
962
962
|
" /status \u2014 Show session info & token usage",
|
|
963
963
|
" /cost \u2014 Show cumulative token usage",
|
|
964
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",
|
|
965
969
|
" /help \u2014 Show this help message",
|
|
966
970
|
"",
|
|
967
971
|
"\u{1F4A1} Tips:",
|
|
@@ -969,7 +973,9 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
|
|
|
969
973
|
" \u2022 Drag & drop or Ctrl+V to paste images",
|
|
970
974
|
" \u2022 Type @ to reference files from your project",
|
|
971
975
|
" \u2022 Use Shift+Enter for multi-line input",
|
|
972
|
-
" \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"
|
|
973
979
|
].join("\n")
|
|
974
980
|
});
|
|
975
981
|
break;
|
|
@@ -989,6 +995,22 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
|
|
|
989
995
|
case "tools":
|
|
990
996
|
this.sendToolsList();
|
|
991
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
|
+
}
|
|
992
1014
|
default:
|
|
993
1015
|
this.send({ type: "error", message: `Unknown command: /${name}. Type /help for available commands.` });
|
|
994
1016
|
}
|
|
@@ -1072,6 +1094,90 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
|
|
|
1072
1094
|
skills
|
|
1073
1095
|
});
|
|
1074
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
|
+
}
|
|
1075
1181
|
sendSessionMessages() {
|
|
1076
1182
|
const session = this.sessions.current;
|
|
1077
1183
|
if (!session) return;
|
|
@@ -1329,7 +1435,15 @@ async function startWebServer(options = {}) {
|
|
|
1329
1435
|
activeHandler = handler;
|
|
1330
1436
|
ws.on("message", async (data) => {
|
|
1331
1437
|
try {
|
|
1332
|
-
|
|
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);
|
|
1333
1447
|
} catch (err) {
|
|
1334
1448
|
const message = err instanceof Error ? err.message : String(err);
|
|
1335
1449
|
console.error(` Error: ${message}`);
|
package/dist/web/client/app.js
CHANGED
|
@@ -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
|
|
|
@@ -56,22 +59,41 @@ marked.setOptions({
|
|
|
56
59
|
|
|
57
60
|
// ── WebSocket ──────────────────────────────────────────────────────
|
|
58
61
|
|
|
62
|
+
let heartbeatTimer = null;
|
|
63
|
+
let reconnectDelay = 1000; // Start at 1s, exponential backoff
|
|
64
|
+
|
|
59
65
|
function connect() {
|
|
60
66
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
61
67
|
ws = new WebSocket(`${protocol}//${location.host}`);
|
|
62
68
|
|
|
63
69
|
ws.onopen = () => {
|
|
64
70
|
connected = true;
|
|
71
|
+
reconnectDelay = 1000; // Reset backoff on success
|
|
65
72
|
connectionStatus.textContent = '🟢 Connected';
|
|
66
73
|
connectionStatus.className = 'status-connected';
|
|
67
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
|
+
}
|
|
68
87
|
};
|
|
69
88
|
|
|
70
89
|
ws.onclose = () => {
|
|
71
90
|
connected = false;
|
|
72
|
-
|
|
91
|
+
clearInterval(heartbeatTimer);
|
|
92
|
+
connectionStatus.textContent = '🔴 Disconnected — reconnecting...';
|
|
73
93
|
connectionStatus.className = 'status-disconnected';
|
|
74
|
-
|
|
94
|
+
// Exponential backoff: 1s, 2s, 4s, max 10s
|
|
95
|
+
setTimeout(connect, reconnectDelay);
|
|
96
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
|
75
97
|
};
|
|
76
98
|
|
|
77
99
|
ws.onerror = () => {
|
|
@@ -81,7 +103,9 @@ function connect() {
|
|
|
81
103
|
|
|
82
104
|
ws.onmessage = (event) => {
|
|
83
105
|
try {
|
|
84
|
-
|
|
106
|
+
const msg = JSON.parse(event.data);
|
|
107
|
+
if (msg.type === 'pong') return; // Heartbeat response, ignore
|
|
108
|
+
handleServerMessage(msg);
|
|
85
109
|
} catch (e) {
|
|
86
110
|
console.error('Failed to parse message:', e);
|
|
87
111
|
}
|
|
@@ -113,6 +137,8 @@ function handleServerMessage(msg) {
|
|
|
113
137
|
case 'session_list': renderSessionList(msg.sessions); break;
|
|
114
138
|
case 'session_messages':renderSessionMessages(msg.messages); break;
|
|
115
139
|
case 'tools_list': renderToolsList(msg); switchSidebarTab('tools'); break;
|
|
140
|
+
case 'export_data': handleExportData(msg); break;
|
|
141
|
+
case 'memory_content': handleMemoryContent(msg); break;
|
|
116
142
|
case 'info': addInfoMessage(msg.message); break;
|
|
117
143
|
case 'error': addErrorMessage(msg.message); setProcessing(false); break;
|
|
118
144
|
case 'round_progress': break;
|
|
@@ -519,6 +545,12 @@ async function sendMessage() {
|
|
|
519
545
|
const args = parts.slice(1);
|
|
520
546
|
send({ type: 'command', name, args });
|
|
521
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;
|
|
522
554
|
userInput.value = '';
|
|
523
555
|
userInput.style.height = 'auto';
|
|
524
556
|
return;
|
|
@@ -545,6 +577,14 @@ async function sendMessage() {
|
|
|
545
577
|
}
|
|
546
578
|
}
|
|
547
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
|
+
|
|
548
588
|
addUserMessage(text, pendingImages);
|
|
549
589
|
const msg = { type: 'chat', content: resolvedText };
|
|
550
590
|
if (pendingImages.length > 0) {
|
|
@@ -936,6 +976,72 @@ userInput.addEventListener('keydown', (e) => {
|
|
|
936
976
|
}
|
|
937
977
|
}
|
|
938
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
|
|
939
1045
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
940
1046
|
e.preventDefault();
|
|
941
1047
|
sendMessage();
|
|
@@ -1015,6 +1121,34 @@ userInput.addEventListener('paste', (e) => {
|
|
|
1015
1121
|
}
|
|
1016
1122
|
});
|
|
1017
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
|
+
|
|
1018
1152
|
// ── Initialize ─────────────────────────────────────────────────────
|
|
1019
1153
|
|
|
1020
1154
|
// Restore theme
|