jinzd-ai-cli 0.1.76 → 0.1.77

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
@@ -1853,3 +1853,64 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
1853
1853
  - [ ] **IDE 集成**:VS Code / JetBrains 扩展(架构已准备就绪)
1854
1854
  - [ ] **OAuth/浏览器登录**:无需手动填 API Key
1855
1855
  - [x] **Extended Thinking**(v0.1.38):Claude 深度推理模式集成,`/think` 运行时切换,thinking 块折叠显示
1856
+
1857
+ ---
1858
+
1859
+ ## Web UI 增强路线图(`aicli web`)
1860
+
1861
+ > 目标:将 `aicli web` 从"可用的聊天界面"升级为功能完备、体验出色的 AI 代码助手 Web 端。
1862
+
1863
+ ### 当前 Web UI 已实现功能(v0.1.76 基线)
1864
+
1865
+ - 实时流式聊天(WebSocket)
1866
+ - 完整 Agentic 循环(25 轮工具调用 + 流式工具调用)
1867
+ - Provider/Model 下拉切换
1868
+ - DaisyUI 主题切换(8 主题 + localStorage 持久化)
1869
+ - 工具确认对话框(单个 confirm + 批量 batch confirm + ask_user)
1870
+ - Extended Thinking 折叠显示
1871
+ - Plan Mode 只读规划
1872
+ - Markdown 渲染 + 代码高亮(marked.js + highlight.js)
1873
+ - 代码块复制按钮
1874
+ - 图片上传协议支持(C2S `images` 字段)
1875
+ - 用户纠偏(interjection)— 处理中可发送纠正指令
1876
+ - /clear /compact /think /plan /status /provider /model 命令
1877
+
1878
+ ### P0 — 最痛问题(当前就会遇到)
1879
+
1880
+ | # | 功能 | 状态 | 说明 |
1881
+ |---|------|------|------|
1882
+ | P0-1 | **Session 持久化与管理** | [ ] | 刷新页面不丢对话;会话列表侧边栏;新建/切换/恢复/删除会话;历史搜索 |
1883
+ | P0-2 | **`@文件` 引用** | [ ] | CLI 支持 `@file.ts` 读文件注入上下文,Web 需要:`@` 触发文件选择 + 服务端文件读取 API |
1884
+ | P0-3 | **图片拖拽/粘贴上传** | [ ] | 协议层已有 `images` 字段,缺少客户端 UI:拖拽到输入区 + `Ctrl+V` 粘贴截图 + 预览缩略图 |
1885
+ | P0-4 | **工具卡片折叠** | [ ] | 多轮工具调用后聊天区被淹没;safe 级别默认折叠、write/destructive 展开;可手动 toggle |
1886
+ | P0-5 | **`/help` 命令** | [ ] | 显示所有支持的 Web 命令及用法说明;输入不存在的命令给出提示 |
1887
+
1888
+ ### P1 — 明显提升体验
1889
+
1890
+ | # | 功能 | 状态 | 说明 |
1891
+ |---|------|------|------|
1892
+ | P1-1 | **侧边栏布局** | [ ] | 左侧:Session 列表 + Skills + MCP 工具列表 + 搜索;右侧:聊天区;响应式 mobile 折叠 |
1893
+ | P1-2 | **Diff 渲染增强** | [ ] | confirm 对话框中的 diff 从纯文本升级为语法高亮的 diff 视图(绿/红增删行) |
1894
+ | P1-3 | **更多命令支持** | [ ] | `/cost`(token 用量统计)、`/session list\|load`、`/undo`、`/export [md\|json]`、`/tools`(工具列表) |
1895
+ | P1-4 | **键盘快捷键** | [ ] | `Ctrl+L` 清屏、`Ctrl+K` 清空输入、`Esc` 停止生成、`↑` 历史消息回溯 |
1896
+ | P1-5 | **Markdown 导出** | [ ] | 一键导出当前对话为 `.md` 文件下载 |
1897
+ | P1-6 | **断线重连恢复** | [ ] | WebSocket 重连后恢复 session 状态(当前重连=全丢);心跳 ping/pong |
1898
+
1899
+ ### P2 — 差异化功能(Web 独有优势)
1900
+
1901
+ | # | 功能 | 状态 | 说明 |
1902
+ |---|------|------|------|
1903
+ | P2-1 | **多 Tab 会话** | [ ] | 浏览器内多 Tab 并行对话(CLI 做不到),类似 ChatGPT |
1904
+ | P2-2 | **文件树面板** | [ ] | 浏览项目目录结构,点击文件查看/插入上下文,可视化 `@` 引用 |
1905
+ | P2-3 | **工具执行可视化** | [ ] | 进度条、工具调用时间线、Agentic 循环图示 |
1906
+ | P2-4 | **Prompt 模板库** | [ ] | 保存常用 prompt 为模板,一键复用(localStorage) |
1907
+ | P2-5 | **代码主题联动** | [ ] | highlight.js 主题随 DaisyUI 主题切换(亮色 → github-light,暗色 → github-dark) |
1908
+
1909
+ ### P3 — 长远方向
1910
+
1911
+ | # | 功能 | 状态 | 说明 |
1912
+ |---|------|------|------|
1913
+ | P3-1 | **多用户支持** | [ ] | 认证 + 多 session handler(当前 `activeHandler` 单连接限制) |
1914
+ | P3-2 | **PWA 支持** | [ ] | Service Worker + manifest.json,可安装为桌面应用 |
1915
+ | P3-3 | **移动端适配** | [ ] | 响应式布局 + 触摸手势 |
1916
+ | P3-4 | **Electron 打包** | [ ] | 复用 Web UI 代码打包为桌面应用 |
@@ -16,7 +16,7 @@ import {
16
16
  SUBAGENT_MAX_ROUNDS_LIMIT,
17
17
  VERSION,
18
18
  runTestsTool
19
- } from "./chunk-6FYQR6TY.js";
19
+ } from "./chunk-MSQW4A3S.js";
20
20
 
21
21
  // src/config/config-manager.ts
22
22
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
2
8
 
3
9
  // src/tools/builtin/run-tests.ts
4
10
  import { execSync } from "child_process";
@@ -8,7 +14,7 @@ import { platform } from "os";
8
14
  import chalk from "chalk";
9
15
 
10
16
  // src/core/constants.ts
11
- var VERSION = "0.1.76";
17
+ var VERSION = "0.1.77";
12
18
  var APP_NAME = "ai-cli";
13
19
  var CONFIG_DIR_NAME = ".aicli";
14
20
  var CONFIG_FILE_NAME = "config.json";
@@ -441,6 +447,7 @@ var runTestsTool = {
441
447
  };
442
448
 
443
449
  export {
450
+ __require,
444
451
  VERSION,
445
452
  APP_NAME,
446
453
  CONFIG_DIR_NAME,
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  theme,
36
36
  truncateOutput,
37
37
  undoStack
38
- } from "./chunk-RROTI54R.js";
38
+ } from "./chunk-LKYVW34F.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-6FYQR6TY.js";
58
+ } from "./chunk-MSQW4A3S.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-W44VYI2J.js");
1907
+ const { executeTests } = await import("./run-tests-S3XK43NB.js");
1908
1908
  const argStr = args.join(" ").trim();
1909
1909
  let testArgs = {};
1910
1910
  if (argStr) {
@@ -5291,7 +5291,7 @@ program.command("web").description("Start Web UI server with browser-based chat
5291
5291
  console.error("Error: Invalid port number. Must be between 1 and 65535.");
5292
5292
  process.exit(1);
5293
5293
  }
5294
- const { startWebServer } = await import("./server-HINOHGE2.js");
5294
+ const { startWebServer } = await import("./server-WO2OXHJ6.js");
5295
5295
  await startWebServer({ port, host: options.host });
5296
5296
  });
5297
5297
  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-6FYQR6TY.js";
5
+ } from "./chunk-MSQW4A3S.js";
6
6
  export {
7
7
  executeTests,
8
8
  runTestsTool
@@ -8,6 +8,7 @@ import {
8
8
  SkillManager,
9
9
  TOOL_CALL_REMINDER,
10
10
  ToolRegistry,
11
+ askUserContext,
11
12
  checkPermission,
12
13
  detectsHallucinatedFileOp,
13
14
  getContentText,
@@ -22,7 +23,7 @@ import {
22
23
  setupProxy,
23
24
  spawnAgentContext,
24
25
  truncateOutput
25
- } from "./chunk-RROTI54R.js";
26
+ } from "./chunk-LKYVW34F.js";
26
27
  import {
27
28
  AGENTIC_BEHAVIOR_GUIDELINE,
28
29
  CONTEXT_FILE_CANDIDATES,
@@ -33,8 +34,9 @@ import {
33
34
  PLAN_MODE_READONLY_TOOLS,
34
35
  PLAN_MODE_SYSTEM_ADDON,
35
36
  SKILLS_DIR_NAME,
36
- VERSION
37
- } from "./chunk-6FYQR6TY.js";
37
+ VERSION,
38
+ __require
39
+ } from "./chunk-MSQW4A3S.js";
38
40
 
39
41
  // src/web/server.ts
40
42
  import express from "express";
@@ -445,6 +447,8 @@ var SessionHandler = class {
445
447
  const defaultPermission = this.config.get("defaultPermission");
446
448
  this.toolExecutor.setConfig({ hookConfig: hooks, permissionRules, defaultPermission });
447
449
  this.sendStatus();
450
+ askUserContext.rl = null;
451
+ askUserContext.prompting = false;
448
452
  }
449
453
  send(msg) {
450
454
  if (this.ws.readyState === this.ws.OPEN) {
@@ -895,8 +899,94 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
895
899
  });
896
900
  break;
897
901
  }
902
+ case "session": {
903
+ const sub = args[0];
904
+ if (sub === "new") {
905
+ this.sessions.save();
906
+ this.sessions.createSession(this.currentProvider, this.currentModel);
907
+ this.sessionTokenUsage = { inputTokens: 0, outputTokens: 0 };
908
+ this.send({ type: "info", message: "New session created." });
909
+ this.sendStatus();
910
+ this.sendSessionList();
911
+ } else if (sub === "load" && args[1]) {
912
+ const targetId = args[1];
913
+ this.sessions.save();
914
+ const list = this.sessions.listSessions();
915
+ const found = list.find((s) => s.id.startsWith(targetId));
916
+ if (found) {
917
+ this.sessions.loadSession(found.id);
918
+ this.sessionTokenUsage = { inputTokens: 0, outputTokens: 0 };
919
+ if (found.provider) this.currentProvider = found.provider;
920
+ if (found.model) this.currentModel = found.model;
921
+ this.send({ type: "info", message: `Loaded session: ${found.id.slice(0, 8)} "${found.title ?? ""}" (${found.messageCount} messages)` });
922
+ this.sendSessionMessages();
923
+ this.sendStatus();
924
+ this.sendSessionList();
925
+ } else {
926
+ this.send({ type: "error", message: `Session not found: ${targetId}` });
927
+ }
928
+ } else if (sub === "list") {
929
+ this.sendSessionList();
930
+ } else if (sub === "delete" && args[1]) {
931
+ const targetId = args[1];
932
+ const list = this.sessions.listSessions();
933
+ const found = list.find((s) => s.id.startsWith(targetId));
934
+ if (found) {
935
+ this.sessions.deleteSession(found.id);
936
+ this.send({ type: "info", message: `Deleted session: ${found.id.slice(0, 8)}` });
937
+ this.sendSessionList();
938
+ } else {
939
+ this.send({ type: "error", message: `Session not found: ${targetId}` });
940
+ }
941
+ } else {
942
+ this.send({ type: "info", message: "Usage: /session new | list | load <id> | delete <id>" });
943
+ }
944
+ break;
945
+ }
946
+ case "help":
947
+ this.send({
948
+ type: "info",
949
+ message: [
950
+ "\u{1F4D6} Available Web UI commands:",
951
+ "",
952
+ " /provider <id> \u2014 Switch AI provider",
953
+ " /model <id> \u2014 Switch model",
954
+ " /clear \u2014 Clear conversation & start new session",
955
+ " /compact [hint] \u2014 Compress conversation history",
956
+ " /think [on|off] \u2014 Toggle extended thinking mode",
957
+ " /plan [enter|exit] \u2014 Toggle read-only planning mode",
958
+ " /session new \u2014 Create a new session",
959
+ " /session list \u2014 List saved sessions",
960
+ " /session load <id> \u2014 Resume a saved session",
961
+ " /session delete <id> \u2014 Delete a session",
962
+ " /status \u2014 Show session info & token usage",
963
+ " /cost \u2014 Show cumulative token usage",
964
+ " /help \u2014 Show this help message",
965
+ "",
966
+ "\u{1F4A1} Tips:",
967
+ " \u2022 Change provider/model with the dropdowns above",
968
+ " \u2022 Drag & drop or Ctrl+V to paste images",
969
+ " \u2022 Type @ to reference files from your project",
970
+ " \u2022 Use Shift+Enter for multi-line input",
971
+ " \u2022 During AI processing, type to send corrections"
972
+ ].join("\n")
973
+ });
974
+ break;
975
+ case "cost": {
976
+ const total = this.sessionTokenUsage.inputTokens + this.sessionTokenUsage.outputTokens;
977
+ this.send({
978
+ type: "info",
979
+ message: `\u{1F4CA} Token Usage
980
+ Provider: ${this.currentProvider}
981
+ Model: ${this.currentModel}
982
+ Input: ${this.sessionTokenUsage.inputTokens.toLocaleString()}
983
+ Output: ${this.sessionTokenUsage.outputTokens.toLocaleString()}
984
+ Total: ${total.toLocaleString()}`
985
+ });
986
+ break;
987
+ }
898
988
  default:
899
- this.send({ type: "error", message: `Unknown command: /${name}` });
989
+ this.send({ type: "error", message: `Unknown command: /${name}. Type /help for available commands.` });
900
990
  }
901
991
  }
902
992
  async compactSession(instruction) {
@@ -931,6 +1021,34 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
931
1021
  this.send({ type: "error", message: `Compact failed: ${err.message}` });
932
1022
  }
933
1023
  }
1024
+ sendSessionList() {
1025
+ const list = this.sessions.listSessions();
1026
+ this.send({
1027
+ type: "session_list",
1028
+ sessions: list.slice(0, 50).map((s) => ({
1029
+ id: s.id,
1030
+ title: s.title ?? "",
1031
+ provider: s.provider ?? "",
1032
+ model: s.model ?? "",
1033
+ messageCount: s.messageCount,
1034
+ updated: s.updated instanceof Date ? s.updated.toISOString() : String(s.updated),
1035
+ isCurrent: s.id === this.sessions.current?.id
1036
+ }))
1037
+ });
1038
+ }
1039
+ sendSessionMessages() {
1040
+ const session = this.sessions.current;
1041
+ if (!session) return;
1042
+ const messages = session.messages.map((m) => ({
1043
+ role: m.role,
1044
+ content: getContentText(m.content),
1045
+ timestamp: m.timestamp?.toISOString()
1046
+ }));
1047
+ this.send({
1048
+ type: "session_messages",
1049
+ messages
1050
+ });
1051
+ }
934
1052
  // ── Helpers ──────────────────────────────────────────────────────
935
1053
  buildSystemPrompt() {
936
1054
  const skillContent = this.skillManager?.getActivePromptContent();
@@ -1099,6 +1217,71 @@ async function startWebServer(options = {}) {
1099
1217
  tools: toolRegistry.getDefinitions().length
1100
1218
  });
1101
1219
  });
1220
+ app.get("/api/files", (req, res) => {
1221
+ const { readdirSync, statSync } = __require("fs");
1222
+ const { join: pjoin, relative } = __require("path");
1223
+ const cwd = process.cwd();
1224
+ const prefix = req.query.prefix || "";
1225
+ const targetDir = pjoin(cwd, prefix);
1226
+ if (!resolve2(targetDir).startsWith(resolve2(cwd))) {
1227
+ res.json({ files: [] });
1228
+ return;
1229
+ }
1230
+ try {
1231
+ const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "dist-cjs", "release", "__pycache__", ".next", ".nuxt", "coverage", ".cache"]);
1232
+ const entries = readdirSync(targetDir, { withFileTypes: true });
1233
+ const files = entries.filter((e) => !SKIP.has(e.name) && !e.name.startsWith(".")).slice(0, 50).map((e) => ({
1234
+ name: e.name,
1235
+ path: relative(cwd, pjoin(targetDir, e.name)).replace(/\\/g, "/"),
1236
+ isDir: e.isDirectory()
1237
+ }));
1238
+ res.json({ files });
1239
+ } catch {
1240
+ res.json({ files: [] });
1241
+ }
1242
+ });
1243
+ app.get("/api/sessions", (_req, res) => {
1244
+ try {
1245
+ const list = sessions.listSessions();
1246
+ res.json({
1247
+ sessions: list.slice(0, 50).map((s) => ({
1248
+ id: s.id,
1249
+ title: s.title,
1250
+ provider: s.provider,
1251
+ model: s.model,
1252
+ messageCount: s.messageCount,
1253
+ updated: s.updated
1254
+ }))
1255
+ });
1256
+ } catch {
1257
+ res.json({ sessions: [] });
1258
+ }
1259
+ });
1260
+ app.get("/api/file-content", (req, res) => {
1261
+ const filePath = req.query.path;
1262
+ if (!filePath) {
1263
+ res.json({ error: "Missing path" });
1264
+ return;
1265
+ }
1266
+ const cwd = process.cwd();
1267
+ const fullPath = resolve2(join3(cwd, filePath));
1268
+ if (!fullPath.startsWith(resolve2(cwd))) {
1269
+ res.json({ error: "Access denied" });
1270
+ return;
1271
+ }
1272
+ try {
1273
+ const { statSync, readFileSync: readFileSync5 } = __require("fs");
1274
+ const stat = statSync(fullPath);
1275
+ if (stat.size > 512 * 1024) {
1276
+ res.json({ error: `File too large (${(stat.size / 1024).toFixed(0)} KB, max 512 KB)` });
1277
+ return;
1278
+ }
1279
+ const content = readFileSync5(fullPath, "utf-8");
1280
+ res.json({ content, size: stat.size });
1281
+ } catch (err) {
1282
+ res.json({ error: `Cannot read: ${err.message}` });
1283
+ }
1284
+ });
1102
1285
  let activeHandler = null;
1103
1286
  wss.on("connection", (ws) => {
1104
1287
  if (activeHandler) {
@@ -13,6 +13,7 @@ let currentAssistantContent = '';
13
13
  let currentThinkingEl = null;
14
14
  let currentThinkingContent = '';
15
15
  let providers = [];
16
+ let pendingImages = []; // { name, data (base64), mime }
16
17
 
17
18
  // ── DOM refs ───────────────────────────────────────────────────────
18
19
 
@@ -30,6 +31,10 @@ const modelSelect = document.getElementById('model-select');
30
31
  const statusSession = document.getElementById('status-session');
31
32
  const statusTokens = document.getElementById('status-tokens');
32
33
  const connectionStatus = document.getElementById('connection-status');
34
+ const sidebar = document.getElementById('sidebar');
35
+ const sessionListEl = document.getElementById('session-list');
36
+ const btnNewSession = document.getElementById('btn-new-session');
37
+ const sidebarToggle = document.getElementById('sidebar-toggle');
33
38
 
34
39
  // ── Configure marked.js ────────────────────────────────────────────
35
40
 
@@ -55,6 +60,7 @@ function connect() {
55
60
  connected = true;
56
61
  connectionStatus.textContent = '🟢 Connected';
57
62
  connectionStatus.className = 'status-connected';
63
+ requestSessionList();
58
64
  };
59
65
 
60
66
  ws.onclose = () => {
@@ -100,6 +106,8 @@ function handleServerMessage(msg) {
100
106
  case 'thinking_end': handleThinkingEnd(); break;
101
107
  case 'todo_update': handleTodoUpdate(msg.todos); break;
102
108
  case 'status': handleStatus(msg); break;
109
+ case 'session_list': renderSessionList(msg.sessions); break;
110
+ case 'session_messages':renderSessionMessages(msg.messages); break;
103
111
  case 'info': addInfoMessage(msg.message); break;
104
112
  case 'error': addErrorMessage(msg.message); setProcessing(false); break;
105
113
  case 'round_progress': break;
@@ -146,15 +154,21 @@ function handleToolCallStart(msg) {
146
154
  const levelBadge = msg.dangerLevel === 'destructive' ? 'badge-error'
147
155
  : msg.dangerLevel === 'write' ? 'badge-warning' : 'badge-info';
148
156
 
149
- const el = document.createElement('div');
157
+ // safe tools: collapsed by default; write/destructive: expanded
158
+ const isCollapsible = msg.dangerLevel === 'safe';
159
+ const el = document.createElement('details');
150
160
  el.id = `tool-${msg.callId}`;
151
161
  el.className = `tool-card ${levelBorder} my-1`;
162
+ if (!isCollapsible) el.open = true;
152
163
  el.innerHTML = `
153
- <div class="flex items-center gap-2 w-full mb-1">
164
+ <summary class="flex items-center gap-2 w-full cursor-pointer select-none py-1">
154
165
  <span class="badge ${levelBadge} badge-sm gap-1">${levelIcon} ${escapeHtml(msg.toolName)}</span>
155
166
  <span class="text-xs opacity-50">${msg.round}/${msg.totalRounds}</span>
167
+ <span class="tool-result-badge text-xs ml-auto"></span>
168
+ </summary>
169
+ <div class="tool-details-body pt-1">
170
+ <div class="tool-args w-full">${formatToolArgs(msg.args)}</div>
156
171
  </div>
157
- <div class="tool-args w-full">${formatToolArgs(msg.args)}</div>
158
172
  `;
159
173
  messagesEl.appendChild(el);
160
174
  scrollToBottom();
@@ -163,10 +177,21 @@ function handleToolCallStart(msg) {
163
177
  function handleToolCallResult(msg) {
164
178
  const el = document.getElementById(`tool-${msg.callId}`);
165
179
  if (el) {
166
- const resultDiv = document.createElement('div');
167
- resultDiv.className = `tool-result-content mt-2 pt-2 border-t border-base-content/10 w-full ${msg.isError ? 'text-error' : 'text-success'}`;
168
- resultDiv.textContent = `${msg.isError ? '✗' : '✓'} ${msg.content}`;
169
- el.appendChild(resultDiv);
180
+ // Add result inside the details body
181
+ const body = el.querySelector('.tool-details-body');
182
+ if (body) {
183
+ const resultDiv = document.createElement('div');
184
+ resultDiv.className = `tool-result-content mt-2 pt-2 border-t border-base-content/10 w-full ${msg.isError ? 'text-error' : 'text-success'}`;
185
+ const truncated = msg.content.length > 500 ? msg.content.slice(0, 500) + '...' : msg.content;
186
+ resultDiv.textContent = `${msg.isError ? '✗' : '✓'} ${truncated}`;
187
+ body.appendChild(resultDiv);
188
+ }
189
+ // Update the summary badge (visible when collapsed)
190
+ const badge = el.querySelector('.tool-result-badge');
191
+ if (badge) {
192
+ badge.textContent = msg.isError ? '✗' : '✓';
193
+ badge.className = `tool-result-badge text-xs ml-auto ${msg.isError ? 'text-error' : 'text-success'}`;
194
+ }
170
195
  }
171
196
  scrollToBottom();
172
197
  }
@@ -290,6 +315,11 @@ function handleStatus(msg) {
290
315
  if (msg.tokenUsage) {
291
316
  statusTokens.textContent = `📊 in: ${msg.tokenUsage.inputTokens} out: ${msg.tokenUsage.outputTokens}`;
292
317
  }
318
+
319
+ // Update sidebar active state
320
+ sessionListEl.querySelectorAll('.session-item').forEach(el => {
321
+ el.classList.toggle('active', el.dataset.sessionId === msg.sessionId);
322
+ });
293
323
  }
294
324
 
295
325
  // ── Response helpers ───────────────────────────────────────────────
@@ -353,10 +383,16 @@ function createAssistantMessage() {
353
383
  return el;
354
384
  }
355
385
 
356
- function addUserMessage(text) {
386
+ function addUserMessage(text, images) {
357
387
  const wrapper = document.createElement('div');
358
388
  wrapper.className = 'chat chat-end';
359
- wrapper.innerHTML = `<div class="chat-bubble chat-bubble-user chat-bubble-primary">${escapeHtml(text)}</div>`;
389
+ let imagesHtml = '';
390
+ if (images && images.length > 0) {
391
+ imagesHtml = `<div class="flex gap-1 flex-wrap mb-1">${images.map(img =>
392
+ `<img src="data:${img.mime};base64,${img.data}" class="rounded max-h-24 max-w-[150px] object-contain" alt="${escapeHtml(img.name)}">`
393
+ ).join('')}</div>`;
394
+ }
395
+ wrapper.innerHTML = `<div class="chat-bubble chat-bubble-user chat-bubble-primary">${imagesHtml}${escapeHtml(text)}</div>`;
360
396
  messagesEl.appendChild(wrapper);
361
397
  scrollToBottom();
362
398
  }
@@ -457,10 +493,12 @@ function updateModelSelect(providerId, currentModelId) {
457
493
 
458
494
  // ── Event handlers ─────────────────────────────────────────────────
459
495
 
460
- function sendMessage() {
496
+ async function sendMessage() {
461
497
  const text = userInput.value.trim();
462
498
  if (!text || !connected) return;
463
499
 
500
+ hideFileDropdown();
501
+
464
502
  // Processing state: send as interjection
465
503
  if (processing) {
466
504
  send({ type: 'interjection', content: text });
@@ -481,8 +519,35 @@ function sendMessage() {
481
519
  return;
482
520
  }
483
521
 
484
- addUserMessage(text);
485
- send({ type: 'chat', content: text });
522
+ // Resolve @file references: replace @path with file content
523
+ let resolvedText = text;
524
+ const atRefs = text.match(/@([\w./_-]+[\w._-])/g);
525
+ if (atRefs) {
526
+ for (const ref of atRefs) {
527
+ const filePath = ref.slice(1); // remove @
528
+ try {
529
+ const resp = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
530
+ const data = await resp.json();
531
+ if (data.content) {
532
+ resolvedText = resolvedText.replace(ref,
533
+ `[File: ${filePath}]\n\`\`\`\n${data.content}\n\`\`\``);
534
+ } else if (data.error) {
535
+ addInfoMessage(`⚠ ${ref}: ${data.error}`);
536
+ }
537
+ } catch {
538
+ addInfoMessage(`⚠ Failed to read ${ref}`);
539
+ }
540
+ }
541
+ }
542
+
543
+ addUserMessage(text, pendingImages);
544
+ const msg = { type: 'chat', content: resolvedText };
545
+ if (pendingImages.length > 0) {
546
+ msg.images = pendingImages.map(img => ({ name: img.name, data: img.data, mime: img.mime }));
547
+ }
548
+ send(msg);
549
+ pendingImages = [];
550
+ clearImagePreview();
486
551
  userInput.value = '';
487
552
  userInput.style.height = 'auto';
488
553
  setProcessing(true);
@@ -494,17 +559,7 @@ btnStop.addEventListener('click', () => {
494
559
  send({ type: 'abort' });
495
560
  });
496
561
 
497
- userInput.addEventListener('keydown', (e) => {
498
- if (e.key === 'Enter' && !e.shiftKey) {
499
- e.preventDefault();
500
- sendMessage();
501
- }
502
- });
503
-
504
- userInput.addEventListener('input', () => {
505
- userInput.style.height = 'auto';
506
- userInput.style.height = Math.min(userInput.scrollHeight, 200) + 'px';
507
- });
562
+ // keydown and input handlers are in the @ file reference section above
508
563
 
509
564
  btnClear.addEventListener('click', () => {
510
565
  send({ type: 'command', name: 'clear', args: [] });
@@ -534,6 +589,291 @@ modelSelect.addEventListener('change', () => {
534
589
  send({ type: 'command', name: 'model', args: [modelSelect.value] });
535
590
  });
536
591
 
592
+ // ── Session management ──────────────────────────────────────────────
593
+
594
+ function renderSessionList(sessions) {
595
+ if (!sessions || sessions.length === 0) {
596
+ sessionListEl.innerHTML = '<div class="text-xs opacity-40 text-center py-4">No sessions yet</div>';
597
+ return;
598
+ }
599
+ sessionListEl.innerHTML = sessions.map(s => {
600
+ const title = s.title || 'Untitled';
601
+ const date = new Date(s.updated);
602
+ const timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
603
+ return `<div class="session-item ${s.isCurrent ? 'active' : ''}" data-session-id="${s.id}" title="${escapeHtml(title)}">
604
+ <div class="session-title">${escapeHtml(title)}</div>
605
+ <div class="session-meta">${s.messageCount} msgs · ${timeStr}</div>
606
+ </div>`;
607
+ }).join('');
608
+
609
+ // Click to load session
610
+ sessionListEl.querySelectorAll('.session-item').forEach(el => {
611
+ el.addEventListener('click', () => {
612
+ const id = el.dataset.sessionId;
613
+ if (!id) return;
614
+ send({ type: 'command', name: 'session', args: ['load', id] });
615
+ });
616
+ });
617
+ }
618
+
619
+ function renderSessionMessages(messages) {
620
+ // Clear chat and re-render all messages from loaded session
621
+ messagesEl.innerHTML = '';
622
+ for (const msg of messages) {
623
+ if (msg.role === 'user') {
624
+ addUserMessage(msg.content);
625
+ } else if (msg.role === 'assistant') {
626
+ const el = createAssistantMessage();
627
+ renderMarkdown(el, msg.content);
628
+ }
629
+ }
630
+ scrollToBottom();
631
+ }
632
+
633
+ // Sidebar toggle for mobile
634
+ sidebarToggle.addEventListener('click', () => {
635
+ sidebar.classList.toggle('sidebar-open');
636
+ });
637
+
638
+ // New session button
639
+ btnNewSession.addEventListener('click', () => {
640
+ send({ type: 'command', name: 'session', args: ['new'] });
641
+ // Clear chat area
642
+ messagesEl.innerHTML = '';
643
+ });
644
+
645
+ // Request session list on connect
646
+ function requestSessionList() {
647
+ send({ type: 'command', name: 'session', args: ['list'] });
648
+ }
649
+
650
+ // ── @ File reference autocomplete ───────────────────────────────────
651
+
652
+ const fileDropdown = document.createElement('div');
653
+ fileDropdown.id = 'file-dropdown';
654
+ fileDropdown.className = 'hidden absolute bg-base-200 border border-base-content/20 rounded-lg shadow-lg max-h-[200px] overflow-y-auto z-50 w-72 text-sm';
655
+ document.body.appendChild(fileDropdown);
656
+
657
+ let fileDropdownVisible = false;
658
+ let fileDropdownItems = [];
659
+ let fileDropdownIndex = -1;
660
+ let atStartPos = -1; // cursor position where @ was typed
661
+
662
+ async function fetchFiles(prefix) {
663
+ try {
664
+ const resp = await fetch(`/api/files?prefix=${encodeURIComponent(prefix)}`);
665
+ const data = await resp.json();
666
+ return data.files || [];
667
+ } catch { return []; }
668
+ }
669
+
670
+ function showFileDropdown(items) {
671
+ if (items.length === 0) { hideFileDropdown(); return; }
672
+ fileDropdownItems = items;
673
+ fileDropdownIndex = 0;
674
+ fileDropdown.innerHTML = items.map((f, i) =>
675
+ `<div class="file-dropdown-item px-3 py-1.5 cursor-pointer hover:bg-primary/20 flex items-center gap-2 ${i === 0 ? 'bg-primary/20' : ''}" data-index="${i}">
676
+ <span>${f.isDir ? '📁' : '📄'}</span>
677
+ <span>${escapeHtml(f.name)}${f.isDir ? '/' : ''}</span>
678
+ </div>`
679
+ ).join('');
680
+
681
+ // Position above the textarea
682
+ const rect = userInput.getBoundingClientRect();
683
+ fileDropdown.style.left = `${rect.left}px`;
684
+ fileDropdown.style.bottom = `${window.innerHeight - rect.top + 4}px`;
685
+ fileDropdown.style.position = 'fixed';
686
+ fileDropdown.classList.remove('hidden');
687
+ fileDropdownVisible = true;
688
+
689
+ // Click handler
690
+ fileDropdown.querySelectorAll('.file-dropdown-item').forEach(el => {
691
+ el.addEventListener('mousedown', (e) => {
692
+ e.preventDefault();
693
+ selectFileItem(parseInt(el.dataset.index));
694
+ });
695
+ });
696
+ }
697
+
698
+ function hideFileDropdown() {
699
+ fileDropdown.classList.add('hidden');
700
+ fileDropdownVisible = false;
701
+ fileDropdownItems = [];
702
+ fileDropdownIndex = -1;
703
+ atStartPos = -1;
704
+ }
705
+
706
+ function updateFileDropdownHighlight() {
707
+ fileDropdown.querySelectorAll('.file-dropdown-item').forEach((el, i) => {
708
+ el.classList.toggle('bg-primary/20', i === fileDropdownIndex);
709
+ });
710
+ }
711
+
712
+ function selectFileItem(index) {
713
+ const item = fileDropdownItems[index];
714
+ if (!item) return;
715
+
716
+ const val = userInput.value;
717
+ const before = val.slice(0, atStartPos); // text before @
718
+ const after = val.slice(userInput.selectionStart);
719
+
720
+ if (item.isDir) {
721
+ // Replace @ prefix with directory path, keep dropdown open for subdir
722
+ userInput.value = before + '@' + item.path + '/' + after;
723
+ const newPos = before.length + 1 + item.path.length + 1;
724
+ userInput.setSelectionRange(newPos, newPos);
725
+ // Fetch sub-directory contents
726
+ fetchFiles(item.path).then(files => showFileDropdown(files));
727
+ } else {
728
+ // Insert file reference and close
729
+ userInput.value = before + '@' + item.path + ' ' + after;
730
+ const newPos = before.length + 1 + item.path.length + 1;
731
+ userInput.setSelectionRange(newPos, newPos);
732
+ hideFileDropdown();
733
+ }
734
+ userInput.focus();
735
+ }
736
+
737
+ userInput.addEventListener('input', async () => {
738
+ userInput.style.height = 'auto';
739
+ userInput.style.height = Math.min(userInput.scrollHeight, 200) + 'px';
740
+
741
+ // Check if we're in a @ reference context
742
+ const pos = userInput.selectionStart;
743
+ const val = userInput.value;
744
+
745
+ // Find the @ that started this reference
746
+ let atPos = -1;
747
+ for (let i = pos - 1; i >= 0; i--) {
748
+ if (val[i] === '@') { atPos = i; break; }
749
+ if (val[i] === ' ' || val[i] === '\n') break;
750
+ }
751
+
752
+ if (atPos >= 0 && (atPos === 0 || val[atPos - 1] === ' ' || val[atPos - 1] === '\n')) {
753
+ atStartPos = atPos;
754
+ const prefix = val.slice(atPos + 1, pos);
755
+ // Extract directory part for the API
756
+ const lastSlash = prefix.lastIndexOf('/');
757
+ const dirPrefix = lastSlash >= 0 ? prefix.slice(0, lastSlash) : '';
758
+ const nameFilter = lastSlash >= 0 ? prefix.slice(lastSlash + 1) : prefix;
759
+
760
+ const files = await fetchFiles(dirPrefix);
761
+ const filtered = nameFilter
762
+ ? files.filter(f => f.name.toLowerCase().includes(nameFilter.toLowerCase()))
763
+ : files;
764
+ showFileDropdown(filtered);
765
+ } else if (fileDropdownVisible) {
766
+ hideFileDropdown();
767
+ }
768
+ });
769
+
770
+ userInput.addEventListener('keydown', (e) => {
771
+ if (fileDropdownVisible) {
772
+ if (e.key === 'ArrowDown') {
773
+ e.preventDefault();
774
+ fileDropdownIndex = Math.min(fileDropdownIndex + 1, fileDropdownItems.length - 1);
775
+ updateFileDropdownHighlight();
776
+ return;
777
+ }
778
+ if (e.key === 'ArrowUp') {
779
+ e.preventDefault();
780
+ fileDropdownIndex = Math.max(fileDropdownIndex - 1, 0);
781
+ updateFileDropdownHighlight();
782
+ return;
783
+ }
784
+ if (e.key === 'Tab' || e.key === 'Enter') {
785
+ if (fileDropdownIndex >= 0) {
786
+ e.preventDefault();
787
+ selectFileItem(fileDropdownIndex);
788
+ return;
789
+ }
790
+ }
791
+ if (e.key === 'Escape') {
792
+ e.preventDefault();
793
+ hideFileDropdown();
794
+ return;
795
+ }
796
+ }
797
+
798
+ if (e.key === 'Enter' && !e.shiftKey) {
799
+ e.preventDefault();
800
+ sendMessage();
801
+ }
802
+ });
803
+
804
+ // ── Image upload (drag & drop + paste) ──────────────────────────────
805
+
806
+ const imagePreviewArea = document.createElement('div');
807
+ imagePreviewArea.id = 'image-preview';
808
+ imagePreviewArea.className = 'hidden max-w-4xl mx-auto flex gap-2 flex-wrap px-1 py-1';
809
+ // Insert before the input row
810
+ const inputRow = userInput.closest('.flex');
811
+ inputRow.parentElement.insertBefore(imagePreviewArea, inputRow);
812
+
813
+ function clearImagePreview() {
814
+ pendingImages = [];
815
+ imagePreviewArea.innerHTML = '';
816
+ imagePreviewArea.classList.add('hidden');
817
+ }
818
+
819
+ function addImageToPreview(file) {
820
+ if (!file.type.startsWith('image/')) return;
821
+ if (file.size > 10 * 1024 * 1024) {
822
+ addErrorMessage(`Image too large (${(file.size / 1024 / 1024).toFixed(1)} MB, max 10 MB): ${file.name}`);
823
+ return;
824
+ }
825
+ const reader = new FileReader();
826
+ reader.onload = () => {
827
+ const base64 = reader.result.split(',')[1];
828
+ pendingImages.push({ name: file.name, data: base64, mime: file.type });
829
+
830
+ const thumb = document.createElement('div');
831
+ thumb.className = 'image-thumb relative';
832
+ thumb.innerHTML = `
833
+ <img src="${reader.result}" class="rounded max-h-16 max-w-[100px] object-contain border border-base-content/20" alt="${escapeHtml(file.name)}">
834
+ <button class="btn btn-xs btn-circle btn-error absolute -top-1 -right-1 opacity-80" title="Remove">✕</button>
835
+ `;
836
+ thumb.querySelector('button').onclick = () => {
837
+ const idx = pendingImages.findIndex(img => img.name === file.name && img.data === base64);
838
+ if (idx >= 0) pendingImages.splice(idx, 1);
839
+ thumb.remove();
840
+ if (pendingImages.length === 0) imagePreviewArea.classList.add('hidden');
841
+ };
842
+ imagePreviewArea.appendChild(thumb);
843
+ imagePreviewArea.classList.remove('hidden');
844
+ };
845
+ reader.readAsDataURL(file);
846
+ }
847
+
848
+ // Drag & drop on chat area
849
+ chatArea.addEventListener('dragover', (e) => {
850
+ e.preventDefault();
851
+ chatArea.classList.add('ring-2', 'ring-primary', 'ring-inset');
852
+ });
853
+ chatArea.addEventListener('dragleave', () => {
854
+ chatArea.classList.remove('ring-2', 'ring-primary', 'ring-inset');
855
+ });
856
+ chatArea.addEventListener('drop', (e) => {
857
+ e.preventDefault();
858
+ chatArea.classList.remove('ring-2', 'ring-primary', 'ring-inset');
859
+ for (const file of e.dataTransfer.files) {
860
+ addImageToPreview(file);
861
+ }
862
+ });
863
+
864
+ // Ctrl+V paste image
865
+ userInput.addEventListener('paste', (e) => {
866
+ const items = e.clipboardData?.items;
867
+ if (!items) return;
868
+ for (const item of items) {
869
+ if (item.type.startsWith('image/')) {
870
+ e.preventDefault();
871
+ const file = item.getAsFile();
872
+ if (file) addImageToPreview(file);
873
+ }
874
+ }
875
+ });
876
+
537
877
  // ── Initialize ─────────────────────────────────────────────────────
538
878
 
539
879
  // Restore theme
@@ -45,18 +45,37 @@
45
45
  </div>
46
46
  </div>
47
47
 
48
- <!-- ── Chat Area ──────────────────────────────────── -->
49
- <main id="chat-area" class="flex-1 overflow-y-auto px-4 py-4">
50
- <div id="messages" class="max-w-4xl mx-auto flex flex-col gap-3">
51
- <!-- Welcome message -->
52
- <div class="chat chat-start">
53
- <div class="chat-bubble chat-bubble-primary">
54
- Welcome to <strong>ai-cli Web UI</strong>! Select a provider & model above, then start chatting.
55
- <br><span class="text-xs opacity-70">Type <code>/command</code> for REPL commands. Shift+Enter for newline.</span>
48
+ <!-- ── Main content: sidebar + chat ──────────────── -->
49
+ <div class="flex flex-1 overflow-hidden">
50
+
51
+ <!-- Sidebar -->
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">
54
+ <span class="font-semibold text-sm">Sessions</span>
55
+ <button id="btn-new-session" class="btn btn-xs btn-primary btn-outline" title="New session">+ New</button>
56
+ </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>
59
+ </div>
60
+ </aside>
61
+
62
+ <!-- Sidebar toggle (mobile) -->
63
+ <button id="sidebar-toggle" class="btn btn-xs btn-ghost absolute top-[3.75rem] left-0 z-10 rounded-l-none" title="Toggle sidebar">☰</button>
64
+
65
+ <!-- Chat Area -->
66
+ <main id="chat-area" class="flex-1 overflow-y-auto px-4 py-4">
67
+ <div id="messages" class="max-w-4xl mx-auto flex flex-col gap-3">
68
+ <!-- Welcome message -->
69
+ <div class="chat chat-start">
70
+ <div class="chat-bubble chat-bubble-primary">
71
+ Welcome to <strong>ai-cli Web UI</strong>! Select a provider & model above, then start chatting.
72
+ <br><span class="text-xs opacity-70">Type <code>/command</code> for REPL commands. Shift+Enter for newline. Type <code>@</code> to reference files.</span>
73
+ </div>
56
74
  </div>
57
75
  </div>
58
- </div>
59
- </main>
76
+ </main>
77
+
78
+ </div>
60
79
 
61
80
  <!-- ── Input Area ─────────────────────────────────── -->
62
81
  <footer class="bg-base-200 border-t border-base-content/10 px-4 py-3 flex-shrink-0">
@@ -110,14 +110,32 @@
110
110
  50% { opacity: 0; }
111
111
  }
112
112
 
113
- /* ── Tool call cards (base-colored with left border) ── */
113
+ /* ── Tool call cards (collapsible <details>) ────────── */
114
114
  .tool-card {
115
115
  background: oklch(var(--b2));
116
116
  border-radius: 0.5rem;
117
- padding: 0.75rem 1rem;
117
+ padding: 0.5rem 1rem;
118
118
  font-size: 0.85rem;
119
119
  border-left: 3px solid transparent;
120
120
  }
121
+ .tool-card summary {
122
+ list-style: none;
123
+ }
124
+ .tool-card summary::-webkit-details-marker { display: none; }
125
+ .tool-card summary::before {
126
+ content: '▶';
127
+ font-size: 0.65rem;
128
+ margin-right: 0.4rem;
129
+ transition: transform 0.15s;
130
+ display: inline-block;
131
+ opacity: 0.5;
132
+ }
133
+ .tool-card[open] summary::before {
134
+ transform: rotate(90deg);
135
+ }
136
+ .tool-card .tool-details-body {
137
+ padding-top: 0.25rem;
138
+ }
121
139
  .tool-border-safe { border-left-color: oklch(var(--in)); }
122
140
  .tool-border-write { border-left-color: oklch(var(--wa)); }
123
141
  .tool-border-destructive { border-left-color: oklch(var(--er)); }
@@ -221,7 +239,46 @@
221
239
  .status-connected { color: oklch(var(--su)); }
222
240
  .status-disconnected { color: oklch(var(--er)); }
223
241
 
242
+ /* ── Sidebar ───────────────────────────────────────── */
243
+ .sidebar .session-item {
244
+ padding: 0.5rem 0.6rem;
245
+ border-radius: 0.375rem;
246
+ cursor: pointer;
247
+ transition: background 0.15s;
248
+ border-left: 2px solid transparent;
249
+ }
250
+ .sidebar .session-item:hover {
251
+ background: oklch(var(--b3));
252
+ }
253
+ .sidebar .session-item.active {
254
+ background: oklch(var(--p) / 0.15);
255
+ border-left-color: oklch(var(--p));
256
+ }
257
+ .sidebar .session-item .session-title {
258
+ white-space: nowrap;
259
+ overflow: hidden;
260
+ text-overflow: ellipsis;
261
+ font-size: 0.85rem;
262
+ }
263
+ .sidebar .session-item .session-meta {
264
+ font-size: 0.7rem;
265
+ opacity: 0.5;
266
+ }
267
+
268
+ /* ── Image upload preview ──────────────────────────── */
269
+ #image-preview {
270
+ border-top: 1px dashed oklch(var(--bc) / 0.15);
271
+ padding-top: 0.5rem;
272
+ }
273
+ .image-thumb {
274
+ display: inline-block;
275
+ }
276
+
224
277
  /* ── Responsive ─────────────────────────────────────── */
278
+ @media (max-width: 768px) {
279
+ .sidebar { width: 0; padding: 0; border: none; }
280
+ .sidebar.sidebar-open { width: 16rem; position: absolute; z-index: 20; height: calc(100vh - 3.5rem); top: 3.5rem; }
281
+ }
225
282
  @media (max-width: 640px) {
226
283
  .navbar-start .select { width: 6rem; font-size: 0.75rem; }
227
284
  .msg-assistant { padding: 0.75rem; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.1.76",
3
+ "version": "0.1.77",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",