@vincentbanana/banana-gui 0.1.1 → 0.2.0

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/README.md CHANGED
@@ -22,13 +22,38 @@ banana-gui --provider deepseek --model deepseek-chat
22
22
 
23
23
  ## 特性
24
24
 
25
- - 左侧会话列表:新建 / 切换 / 删除,切换即续上下文
26
- - 流式回复、工具调用实时状态(自绘图标:文件、终端、搜索、编辑…)
27
- - 危险操作弹出确认框,附变更预览(写文件前给 diff)
25
+ - **和 CLI 同一套内核**:agent 循环、工具、权限、斜杠命令全部来自 `@vincentbanana/bananacli`,两边行为完全一致
26
+ - 左侧会话列表:新建 / 切换 / 重命名 / 导出(Markdown、JSON)/ 删除
27
+ - 输入 `/` 调出命令面板(`/help` 看全部),输入 `@` 补全项目文件
28
+ - 流式回复、工具调用实时状态,点工具行可展开完整输出
29
+ - 危险操作弹窗确认,写文件前给 diff
28
30
  - 一键撤销上一轮文件改动
31
+ - 右侧项目文件面板:浏览目录、预览文件、一键插入引用
29
32
  - 粘贴或拖拽图片,随消息一起发送
30
- - 模型、权限、工作区、系统提示词在设置面板里改,改完立刻生效
31
- - 深浅色跟随系统,纯黑白,无渐变无彩色
33
+ - 模型、权限、API Key、工作区、系统提示词在设置里改,改完立刻生效
34
+ - 权限设置写清了每种模式的含义和风险,新手也不会懵
35
+ - 三态主题:跟随系统 / 浅色 / 深色,纯黑白无渐变
36
+ - 没配 API Key 也能打开界面,照着引导填完即可用
37
+
38
+ ## 桌面版
39
+
40
+ 仓库里有 Electron 主进程,可以打包成独立应用:
41
+
42
+ ```bash
43
+ npm install # 装 electron 与 electron-builder
44
+ npm run desktop # 本地直接跑桌面版
45
+ npm run dist:win # 出 Windows 安装包(也支持 dist:mac / dist:linux)
46
+ ```
47
+
48
+ npm 安装的 `banana-gui` 仍是浏览器版,零原生依赖,任何装了 Node 20+ 的系统都能跑。
49
+
50
+ ## 配置
51
+
52
+ 首次启动会提示配置向导。GUI 与 CLI 共用 `~/.banana/config.json`:
53
+
54
+ - 也可以直接在界面的设置面板里填 API Key、切换模型和权限
55
+ - 环境变量优先级更高,例如 `DEEPSEEK_API_KEY`
56
+ - API Key 只写在你自己的机器上,不会进代码仓库
32
57
 
33
58
  ## 安全
34
59
 
@@ -0,0 +1,113 @@
1
+ // Banana 桌面版主进程:拉起本地 GUI 服务,再用窗口加载它。
2
+ // 界面资源仍来自本包 ui/,agent 内核来自 @vincentbanana/bananacli。
3
+ const { app, BrowserWindow, Menu, shell, dialog } = require('electron');
4
+ const { spawn } = require('node:child_process');
5
+ const { createRequire } = require('node:module');
6
+
7
+ const require_ = createRequire(__filename);
8
+ let child = null;
9
+ let win = null;
10
+
11
+ function startServer() {
12
+ const entry = require_.resolve('@vincentbanana/bananacli');
13
+ // 端口交给 CLI 随机分配,避免多开时互相抢占
14
+ child = spawn(process.execPath, [entry, '--ui', '--no-open'], {
15
+ stdio: ['ignore', 'pipe', 'pipe'],
16
+ env: process.env,
17
+ });
18
+ return new Promise((resolve, reject) => {
19
+ let buffer = '';
20
+ const onData = (chunk) => {
21
+ buffer += chunk.toString('utf8');
22
+ const match = buffer.match(/http:\/\/127\.0\.0\.1:\d+\/t\/[a-f0-9]+\//);
23
+ if (match) {
24
+ child.stdout.off('data', onData);
25
+ resolve(match[0]);
26
+ }
27
+ };
28
+ child.stdout.on('data', onData);
29
+ child.stderr.on('data', (chunk) => process.stderr.write(`[banana] ${chunk}`));
30
+ child.on('error', reject);
31
+ child.on('exit', (code) => {
32
+ if (!win) return;
33
+ dialog.showErrorBox('Banana 已退出', `本地服务意外结束(退出码 ${code})。`);
34
+ app.quit();
35
+ });
36
+ setTimeout(() => reject(new Error('启动超时:本地服务没有在 30 秒内就绪')), 30_000);
37
+ });
38
+ }
39
+
40
+ async function createWindow() {
41
+ const url = await startServer();
42
+ win = new BrowserWindow({
43
+ width: 1180,
44
+ height: 820,
45
+ minWidth: 720,
46
+ minHeight: 520,
47
+ backgroundColor: '#ffffff',
48
+ title: 'Banana',
49
+ autoHideMenuBar: true,
50
+ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
51
+ });
52
+ await win.loadURL(url);
53
+ win.on('closed', () => {
54
+ win = null;
55
+ });
56
+ // 外部链接交给系统浏览器,窗口只负责本地界面
57
+ win.webContents.setWindowOpenHandler(({ url: target }) => {
58
+ shell.openExternal(target);
59
+ return { action: 'deny' };
60
+ });
61
+ }
62
+
63
+ const menuTemplate = [
64
+ ...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []),
65
+ {
66
+ label: '文件',
67
+ submenu: [
68
+ {
69
+ label: '新建对话',
70
+ accelerator: 'CmdOrCtrl+N',
71
+ click: () => win?.webContents.executeJavaScript("document.getElementById('newChat')?.click()"),
72
+ },
73
+ { type: 'separator' },
74
+ process.platform === 'darwin' ? { role: 'close' } : { role: 'quit', label: '退出' },
75
+ ],
76
+ },
77
+ { role: 'editMenu', label: '编辑' },
78
+ {
79
+ label: '视图',
80
+ submenu: [
81
+ {
82
+ label: '项目文件',
83
+ accelerator: 'CmdOrCtrl+Shift+F',
84
+ click: () => win?.webContents.executeJavaScript("document.getElementById('filesBtn')?.click()"),
85
+ },
86
+ { type: 'separator' },
87
+ { role: 'reload', label: '重新加载' },
88
+ { role: 'toggleDevTools', label: '开发者工具' },
89
+ { role: 'togglefullscreen', label: '全屏' },
90
+ ],
91
+ },
92
+ { role: 'windowMenu', label: '窗口' },
93
+ ];
94
+
95
+ app.whenReady().then(() => {
96
+ Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate));
97
+ createWindow().catch((error) => {
98
+ dialog.showErrorBox('Banana 启动失败', String(error?.message ?? error));
99
+ app.quit();
100
+ });
101
+ app.on('activate', () => {
102
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
103
+ });
104
+ });
105
+
106
+ app.on('window-all-closed', () => {
107
+ if (process.platform !== 'darwin') app.quit();
108
+ });
109
+
110
+ app.on('before-quit', () => {
111
+ child?.kill();
112
+ child = null;
113
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentbanana/banana-gui",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Banana 的图形界面:本地服务 + 浏览器。黑白主题,自绘图标,无第三方 UI 依赖",
5
5
  "type": "module",
6
6
  "engines": {
@@ -12,14 +12,52 @@
12
12
  "files": [
13
13
  "bin",
14
14
  "ui",
15
+ "desktop",
15
16
  "README.md"
16
17
  ],
17
18
  "scripts": {
18
19
  "sync": "node ../scripts/sync-gui.mjs",
19
- "test": "node test/gui-package.test.mjs"
20
+ "test": "node test/gui-package.test.mjs",
21
+ "desktop": "electron desktop/main.cjs",
22
+ "pack": "electron-builder --dir",
23
+ "dist": "electron-builder",
24
+ "dist:win": "electron-builder --win",
25
+ "dist:mac": "electron-builder --mac",
26
+ "dist:linux": "electron-builder --linux"
27
+ },
28
+ "build": {
29
+ "appId": "dev.banana.gui",
30
+ "productName": "Banana",
31
+ "files": [
32
+ "desktop/**/*",
33
+ "ui/**/*",
34
+ "bin/**/*",
35
+ "package.json"
36
+ ],
37
+ "mac": {
38
+ "category": "public.app-category.developer-tools",
39
+ "target": [
40
+ "dmg"
41
+ ]
42
+ },
43
+ "win": {
44
+ "target": [
45
+ "nsis"
46
+ ]
47
+ },
48
+ "linux": {
49
+ "target": [
50
+ "AppImage"
51
+ ],
52
+ "category": "Development"
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "electron": "^33.0.0",
57
+ "electron-builder": "^25.0.0"
20
58
  },
21
59
  "dependencies": {
22
- "@vincentbanana/bananacli": "^0.2.0"
60
+ "@vincentbanana/bananacli": "^0.3.0"
23
61
  },
24
62
  "keywords": [
25
63
  "ai",
package/ui/app.js CHANGED
@@ -24,6 +24,13 @@ const ICONS = {
24
24
  folder: '<path d="M4 6.5h5.5L12 9h8v10.5H4z"/>',
25
25
  message: '<path d="M4 5.5h16v11H9.5L4 20z"/>',
26
26
  listCheck: '<path d="M4 7h10M4 12h10M4 17h6M17 15.5l1.5 1.5L22 13"/>',
27
+ dots: '<circle cx="6" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="18" cy="12" r="1.4"/>',
28
+ panel: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M14 5v14"/>',
29
+ chevron: '<path d="M9 6l6 6-6 6"/>',
30
+ download: '<path d="M12 4v10M8 11l4 4 4-4M5 19h14"/>',
31
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4"/>',
32
+ moon: '<path d="M20 14.5A8 8 0 019.5 4a8 8 0 1010.5 10.5z"/>',
33
+ contrast: '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/><path d="M12 4a8 8 0 010 16" fill="currentColor" stroke="none"/>',
27
34
  };
28
35
 
29
36
  const TOOL_ICON = {
@@ -131,21 +138,117 @@ function renderSidebar() {
131
138
  const node = document.createElement('div');
132
139
  node.className = `session-item${active}`;
133
140
  node.innerHTML =
134
- `<span class="s-del" data-del="${item.id}">${svg('trash')}</span>` +
141
+ `<span class="s-actions">` +
142
+ `<span class="s-act" data-menu="${item.id}" title="更多">${svg('dots')}</span>` +
143
+ `<span class="s-act" data-del="${item.id}" title="删除">${svg('trash')}</span>` +
144
+ `</span>` +
135
145
  `<span class="s-title">${escapeHtml(item.title || '新对话')}</span>` +
136
146
  `<span class="s-meta">${item.turns} 轮 · ${relativeTime(item.updatedAt)}</span>`;
147
+
137
148
  node.addEventListener('click', (event) => {
138
- if (event.target.closest('[data-del]')) return;
139
- post('session', { action: 'switch', ref: item.id });
149
+ if (event.target.closest('[data-del], [data-menu]')) return;
150
+ post('session', { action: 'switch', ref: item.id }).then(() => refresh(true));
140
151
  });
152
+
141
153
  node.querySelector('[data-del]').addEventListener('click', (event) => {
142
154
  event.stopPropagation();
143
- post('session', { action: 'delete', ref: item.id });
155
+ post('session', { action: 'delete', ref: item.id }).then(() => refresh(true));
156
+ });
157
+
158
+ node.querySelector('[data-menu]').addEventListener('click', (event) => {
159
+ event.stopPropagation();
160
+ openSessionMenu(item, node);
144
161
  });
145
162
  list.appendChild(node);
146
163
  }
147
164
  }
148
165
 
166
+ /** 会话操作菜单:切换 / 重命名 / 导出 Markdown / 导出 JSON */
167
+ function openSessionMenu(item, anchor) {
168
+ document.querySelector('.popover')?.remove();
169
+ const rect = anchor.getBoundingClientRect();
170
+ const pop = document.createElement('div');
171
+ pop.className = 'popover';
172
+ pop.innerHTML = `
173
+ <button data-act="switch">切换到这一轮</button>
174
+ <button data-act="rename">重命名</button>
175
+ <button data-act="md">导出 Markdown</button>
176
+ <button data-act="json">导出 JSON</button>`;
177
+ document.body.appendChild(pop);
178
+ // 先量再定位:贴着按钮右边缘,并且始终留在视口内
179
+ pop.style.visibility = 'hidden';
180
+ pop.style.left = '0px';
181
+ pop.style.top = '0px';
182
+ const width = pop.offsetWidth;
183
+ const height = pop.offsetHeight;
184
+ const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
185
+ const top = rect.bottom + height > window.innerHeight - 8 ? Math.max(8, rect.top - height - 4) : rect.bottom + 4;
186
+ pop.style.left = `${left}px`;
187
+ pop.style.top = `${top}px`;
188
+ pop.style.visibility = '';
189
+
190
+ const close = () => {
191
+ pop.remove();
192
+ document.removeEventListener('click', close, true);
193
+ };
194
+ document.addEventListener('click', close, true);
195
+
196
+ pop.addEventListener('click', async (event) => {
197
+ const action = event.target.closest('[data-act]')?.dataset.act;
198
+ if (!action) return;
199
+ event.stopPropagation();
200
+ close();
201
+ if (action === 'switch') {
202
+ await post('session', { action: 'switch', ref: item.id });
203
+ await refresh(true);
204
+ } else if (action === 'rename') {
205
+ const title = prompt('新的标题', item.title || '');
206
+ if (title && title.trim()) {
207
+ await post('session', { action: 'switch', ref: item.id });
208
+ await post('session', { action: 'rename', title: title.trim() });
209
+ await refresh(false);
210
+ toast('已重命名');
211
+ }
212
+ } else {
213
+ await post('session', { action: 'switch', ref: item.id });
214
+ await refresh(true);
215
+ downloadSession(action === 'json' ? 'json' : 'md', item.id);
216
+ }
217
+ });
218
+ }
219
+
220
+ function sessionToMarkdown(messages, title) {
221
+ const lines = [`# ${title || '对话'}`, '', `导出时间:${new Date().toLocaleString()}`, ''];
222
+ for (const message of messages) {
223
+ if (message.role === 'tool') continue;
224
+ lines.push(message.role === 'user' ? '## 你' : '## Banana', '');
225
+ lines.push(message.content || '(空)');
226
+ if (message.toolCalls?.length) {
227
+ lines.push('', '工具调用:');
228
+ for (const call of message.toolCalls) {
229
+ lines.push(`- \`${call.name}\` ${JSON.stringify(call.input ?? {}).slice(0, 200)}`);
230
+ }
231
+ }
232
+ lines.push('');
233
+ }
234
+ return lines.join('\n');
235
+ }
236
+
237
+ function downloadSession(format, id) {
238
+ const session = state.info.session;
239
+ const blob =
240
+ format === 'json'
241
+ ? JSON.stringify(session, null, 2)
242
+ : sessionToMarkdown(session.messages, session.title);
243
+ const blobUrl = URL.createObjectURL(new Blob([blob], { type: format === 'json' ? 'application/json' : 'text/markdown' }));
244
+ const link = document.createElement('a');
245
+ link.href = blobUrl;
246
+ link.download = `${(session.title || id).replace(/[\\/:*?"<>|]/g, '_').slice(0, 40)}.${format}`;
247
+ link.click();
248
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
249
+ toast(`已导出 ${format === 'json' ? 'JSON' : 'Markdown'}`);
250
+ }
251
+
149
252
  function toolGroup(calls, results) {
150
253
  const wrap = document.createElement('div');
151
254
  wrap.className = 'tool-group';
@@ -191,14 +294,36 @@ function renderMessage(message) {
191
294
  function renderEmpty() {
192
295
  const wrap = document.createElement('div');
193
296
  wrap.className = 'empty';
297
+ // 第一次用、或者还没配 key 时,给一条能照着做的引导
298
+ const provider = state.info?.provider;
299
+ const current = state.info?.providers?.find((item) => item.current);
300
+ const hasKey = Boolean(current?.keyReady);
301
+ const banner = hasKey
302
+ ? ''
303
+ : `<div class="empty-warn">
304
+ <strong>还差一步:填上 API Key</strong>
305
+ <span>没有 key 就没法对话。点下面的按钮,填进去立刻就能用(key 只存在你自己的电脑上)。</span>
306
+ <button class="btn primary" id="emptySetup">现在去填</button>
307
+ </div>`;
194
308
  wrap.innerHTML =
195
- '<h1>要做什么</h1><p>读写文件、跑命令、查资料,都可以。默认每次都要你确认。</p>' +
309
+ '<h1>要做什么</h1>' +
310
+ '<p>读写文件、跑命令、查资料,都可以。默认每次都要你确认。</p>' +
311
+ banner +
196
312
  '<div class="suggest">' +
197
- ['这个仓库是做什么的', '跑一下测试并修复失败', '给我一个重构方案,先别改代码', '把 README 翻译成英文'].map((text) => `<button>${text}</button>`).join('') +
198
- '</div>';
313
+ ['这个仓库是做什么的', '跑一下测试并修复失败', '给我一个重构方案,先别改代码', '把 README 翻译成英文']
314
+ .map((text) => `<button>${escapeHtml(text)}</button>`)
315
+ .join('') +
316
+ '</div>' +
317
+ `<div class="empty-tips">
318
+ <span><b>/</b> 调命令(/help 看全部)</span>
319
+ <span><b>@</b> 引用项目里的文件</span>
320
+ <span><b>${escapeHtml(provider?.name ?? '')}</b> 当前模型</span>
321
+ </div>`;
199
322
  wrap.querySelectorAll('.suggest button').forEach((btn) => {
200
323
  btn.addEventListener('click', () => { $('input').value = btn.textContent; $('input').focus(); grow(); });
201
324
  });
325
+ const setup = wrap.querySelector('#emptySetup');
326
+ if (setup) setup.addEventListener('click', openSettings);
202
327
  return wrap;
203
328
  }
204
329
 
@@ -206,6 +331,11 @@ function renderThread() {
206
331
  THREAD.innerHTML = '';
207
332
  const messages = state.info?.session?.messages ?? [];
208
333
  if (messages.length === 0) THREAD.appendChild(renderEmpty());
334
+ else {
335
+ const divider = document.createElement('div');
336
+ divider.className = 'thread-divider';
337
+ THREAD.appendChild(divider);
338
+ }
209
339
  for (const message of messages) {
210
340
  const node = renderMessage(message);
211
341
  if (node) THREAD.appendChild(node);
@@ -334,6 +464,13 @@ async function send() {
334
464
  if (!text || state.busy) return;
335
465
  input.value = '';
336
466
  grow();
467
+ closePalette();
468
+ // 斜杠开头的是命令,不走对话
469
+ if (text.startsWith('/')) {
470
+ await runCommand(text);
471
+ grow();
472
+ return;
473
+ }
337
474
 
338
475
  if (state.info.session.messages.length === 0 && state.info.session.title === '新对话') {
339
476
  state.info.session.title = text.slice(0, 40);
@@ -371,6 +508,7 @@ async function send() {
371
508
 
372
509
  let buffer = '';
373
510
  let pending = 0;
511
+ let streamEnded = false;
374
512
  const paint = () => {
375
513
  pending = 0;
376
514
  body.textContent = buffer;
@@ -430,13 +568,27 @@ async function send() {
430
568
  if (row) {
431
569
  row.querySelector('.t-state').innerHTML = event.ok ? svg('check') : svg('x');
432
570
  row.classList.toggle('is-error', !event.ok);
571
+ if (event.detail) {
572
+ row.classList.add('is-clickable');
573
+ row.title = '点击展开输出';
574
+ row.addEventListener('click', () => toggleToolOutput(row, event.detail, !event.ok));
575
+ }
433
576
  }
434
577
  } else if (event.type === 'approval') {
435
578
  const allowed = await askApproval(event);
436
579
  await post('approve', { id: event.id, allowed });
580
+ } else if (event.type === 'error') {
581
+ // 服务端提前收尾(没配 key、请求出错等):给出能照做的下一步
582
+ body.classList.remove('caret');
583
+ body.innerHTML = markdown(`**${escapeHtml(event.message ?? '出错了')}**`);
584
+ if (event.hint) metaLine('提示', event.hint);
585
+ if (event.code === 'no_api_key') openSettings();
586
+ streamEnded = true;
437
587
  } else if (event.type === 'done') {
438
588
  body.classList.remove('caret');
439
- if (event.error) body.innerHTML = markdown(`请求失败:${event.error}`);
589
+ streamEnded = true;
590
+ if (event.error) body.innerHTML = markdown(`**请求失败** ${escapeHtml(event.error)}`);
591
+ else if (!buffer.trim()) body.innerHTML = markdown('这一轮没有返回内容。');
440
592
  else body.innerHTML = markdown(buffer);
441
593
  metaLine('步数', event.steps);
442
594
  metaLine('用时', (event.ms / 1000).toFixed(1) + 's');
@@ -455,9 +607,17 @@ async function send() {
455
607
  }
456
608
  }
457
609
  }
610
+ // 流结束了却没收到 done/error:多半是被中断,别让界面一直转圈
611
+ if (!streamEnded) {
612
+ body.classList.remove('caret');
613
+ if (body.textContent === '' && toolWrap.childElementCount === 0) {
614
+ body.innerHTML = markdown('这一轮没有返回内容。可以重试,或点「停止」后换个说法。');
615
+ }
616
+ await refresh();
617
+ }
458
618
  } catch (error) {
459
619
  body.classList.remove('caret');
460
- body.innerHTML = markdown(`**中断** ${error.message}`);
620
+ body.innerHTML = markdown(`**中断** ${escapeHtml(error.message)}`);
461
621
  await refresh();
462
622
  } finally {
463
623
  setBusy(false);
@@ -465,6 +625,201 @@ async function send() {
465
625
  }
466
626
  }
467
627
 
628
+ // ---------------- 主题 ----------------
629
+ // 三态:跟随系统 / 浅色 / 深色,选择记在本地
630
+ const THEMES = ['auto', 'light', 'dark'];
631
+ const THEME_LABEL = { auto: '跟随系统', light: '浅色', dark: '深色' };
632
+ const THEME_ICON = { auto: 'contrast', light: 'sun', dark: 'moon' };
633
+
634
+ function applyTheme(theme) {
635
+ if (theme === 'auto') delete document.documentElement.dataset.theme;
636
+ else document.documentElement.dataset.theme = theme;
637
+ try {
638
+ localStorage.setItem('banana-theme', theme);
639
+ } catch {}
640
+ const button = $('themeBtn');
641
+ if (button) {
642
+ button.innerHTML = svg(THEME_ICON[theme]);
643
+ button.title = `主题:${THEME_LABEL[theme]}(点击切换)`;
644
+ }
645
+ }
646
+
647
+ function loadTheme() {
648
+ let saved = 'auto';
649
+ try {
650
+ saved = localStorage.getItem('banana-theme') ?? 'auto';
651
+ } catch {}
652
+ if (!THEMES.includes(saved)) saved = 'auto';
653
+ applyTheme(saved);
654
+ }
655
+
656
+ function cycleTheme() {
657
+ const current = (() => {
658
+ try {
659
+ return localStorage.getItem('banana-theme') ?? 'auto';
660
+ } catch {
661
+ return 'auto';
662
+ }
663
+ })();
664
+ const next = THEMES[(THEMES.indexOf(current) + 1) % THEMES.length];
665
+ applyTheme(next);
666
+ toast(`主题:${THEME_LABEL[next]}`);
667
+ }
668
+
669
+ // ---------------- 补全面板:/ 命令与 @ 文件共用一套 ----------------
670
+ let commandList = [];
671
+
672
+ function renderCommandPalette(query) {
673
+ const box = $('commandPalette');
674
+ const term = query.replace(/^\//, '').toLowerCase();
675
+ const matches = commandList
676
+ .filter((item) => !term || item.name.startsWith(term) || item.aliases.some((alias) => alias.startsWith(term)))
677
+ .slice(0, 8);
678
+ state.commandMatches = matches.map((item) => ({ kind: 'command', item, insert: item.usage.startsWith('/') ? item.usage + ' ' : '/' + item.name + ' ' }));
679
+ state.commandIndex = 0;
680
+ paintPalette();
681
+ }
682
+
683
+ function paintPalette() {
684
+ const box = $('commandPalette');
685
+ box.innerHTML = '';
686
+ const rows = state.commandMatches ?? [];
687
+ if (!rows.length) {
688
+ box.innerHTML = '<div class="cmd-empty">没有匹配项</div>';
689
+ return;
690
+ }
691
+ rows.forEach((entry, index) => {
692
+ const row = document.createElement('button');
693
+ row.className = `cmd-item${index === state.commandIndex ? ' is-on' : ''}`;
694
+ row.type = 'button';
695
+ if (entry.kind === 'command') {
696
+ row.innerHTML =
697
+ `<span class="cmd-name">/${escapeHtml(entry.item.name)}</span>` +
698
+ `<span class="cmd-summary">${escapeHtml(entry.item.summary)}</span>` +
699
+ `<span class="cmd-usage">${escapeHtml(entry.item.usage)}</span>`;
700
+ } else {
701
+ row.innerHTML =
702
+ `<span class="cmd-name">${escapeHtml(entry.item.split('/').pop())}</span>` +
703
+ `<span class="cmd-summary">${escapeHtml(entry.item)}</span>` +
704
+ '<span class="cmd-usage">文件</span>';
705
+ }
706
+ row.addEventListener('mousedown', (event) => {
707
+ event.preventDefault();
708
+ applyCompletion(entry);
709
+ });
710
+ box.appendChild(row);
711
+ });
712
+ }
713
+
714
+ /** 用候选替换输入框里对应的片段。 */
715
+ function applyCompletion(entry) {
716
+ const input = $('input');
717
+ const value = input.value;
718
+ const replace = state.paletteReplace ?? '';
719
+ if (replace && value.endsWith(replace)) {
720
+ input.value = value.slice(0, value.length - replace.length) + entry.insert;
721
+ } else {
722
+ input.value = `${value.trimEnd()} ${entry.insert}`;
723
+ }
724
+ closePalette();
725
+ input.focus();
726
+ grow();
727
+ }
728
+
729
+ /** 命令面板:本地过滤,无需请求。 */
730
+ function openPalette(query) {
731
+ const box = $('commandPalette');
732
+ box.hidden = false;
733
+ box.dataset.kind = 'command';
734
+ state.paletteReplace = '';
735
+ renderCommandPalette(query);
736
+ }
737
+
738
+ function closePalette() {
739
+ $('commandPalette').hidden = true;
740
+ state.commandMatches = [];
741
+ state.paletteReplace = '';
742
+ state.pendingFiles = [];
743
+ }
744
+
745
+ function movePalette(step) {
746
+ const box = $('commandPalette');
747
+ if (box.hidden) return;
748
+ const rows = [...box.querySelectorAll('.cmd-item')];
749
+ if (!rows.length) return;
750
+ state.commandIndex = (state.commandIndex + step + rows.length) % rows.length;
751
+ rows.forEach((row, index) => row.classList.toggle('is-on', index === state.commandIndex));
752
+ rows[state.commandIndex].scrollIntoView({ block: 'nearest' });
753
+ }
754
+
755
+ /** @ 补全:走服务端,和 CLI 用同一份 completeFileReference。 */
756
+ let completeTimer = null;
757
+ async function requestFileComplete(value) {
758
+ clearTimeout(completeTimer);
759
+ completeTimer = setTimeout(async () => {
760
+ try {
761
+ const data = await get(`complete?line=${encodeURIComponent(value)}`);
762
+ if (data.kind !== 'file' || !data.candidates.length) {
763
+ if (!$('commandPalette').hidden && $('commandPalette').dataset.kind === 'file') closePalette();
764
+ return;
765
+ }
766
+ // 补全请求返回时用户可能已经继续输入了,丢弃过期结果
767
+ if ($('input').value !== value) return;
768
+ state.pendingFiles = data.candidates.map((path) => ({ kind: 'file', item: path, insert: path + ' ' }));
769
+ $('commandPalette').hidden = false;
770
+ $('commandPalette').dataset.kind = 'file';
771
+ state.paletteReplace = data.replace;
772
+ state.commandMatches = state.pendingFiles;
773
+ state.commandIndex = 0;
774
+ paintPalette();
775
+ } catch {
776
+ /* 补全失败不影响输入 */
777
+ }
778
+ }, 90);
779
+ }
780
+
781
+ /** 执行命令:走服务端,和终端里同一份逻辑、同一份命令表。 */
782
+ async function runCommand(text) {
783
+ closePalette();
784
+ const userMsg = document.createElement('article');
785
+ userMsg.className = 'msg user cmd';
786
+ userMsg.innerHTML = '<div class="msg-role">你</div>';
787
+ const userBody = document.createElement('div');
788
+ userBody.className = 'msg-body';
789
+ userBody.textContent = text;
790
+ userMsg.appendChild(userBody);
791
+ THREAD.appendChild(userMsg);
792
+
793
+ const botMsg = document.createElement('article');
794
+ botMsg.className = 'msg assistant';
795
+ const role = document.createElement('div');
796
+ role.className = 'msg-role';
797
+ role.textContent = '命令输出';
798
+ const out = document.createElement('div');
799
+ out.className = 'msg-body cmd-out';
800
+ botMsg.append(role, out);
801
+ THREAD.appendChild(botMsg);
802
+ out.textContent = '执行中…';
803
+ scrollToEnd();
804
+
805
+ try {
806
+ const result = await post('command', { input: text });
807
+ out.textContent = (result.output || '').replace(/\u001b\[\d+J/g, '') || (result.ok ? '(没有输出)' : '');
808
+ out.classList.toggle('is-error', !result.ok);
809
+ if (result.error) out.textContent += (out.textContent ? '\n' : '') + result.error;
810
+ if (result.info) state.info = result.info;
811
+ if (result.sessions) state.sessions = result.sessions;
812
+ // \u001b[clear 之类的终端指令:清空当前视图
813
+ if ((result.output || '').includes('\u001b[clear]')) renderThread();
814
+ renderHeader();
815
+ renderSidebar();
816
+ } catch (error) {
817
+ out.textContent = error.message;
818
+ out.classList.add('is-error');
819
+ }
820
+ scrollToEnd();
821
+ }
822
+
468
823
  // ---------------- 附件 ----------------
469
824
  function renderAttachments() {
470
825
  const box = $('attachments');
@@ -536,7 +891,7 @@ function renderSettings() {
536
891
  const keyPlaceholder = active?.keySaved
537
892
  ? '已保存(留空表示不修改)'
538
893
  : active?.keyEnv
539
- ? `默认读取环境变量 ${active.keyEnv}`
894
+ ? `默认读取环境变量 ${active.keyEnv}(当前${active.keyReady ? '已设置' : '未设置'})`
540
895
  : '粘贴 API key';
541
896
  keyField.innerHTML =
542
897
  '<label>API Key</label>' +
@@ -562,38 +917,88 @@ function renderSettings() {
562
917
  const permField = document.createElement('div');
563
918
  permField.className = 'field';
564
919
  permField.innerHTML = '<label>权限</label>';
565
- const permNames = { shell: '执行命令', write: '写文件', network: '联网' };
920
+ // 权限是小白最容易懵的地方,所以每种模式都写清楚"改了会发生什么"
921
+ const PERM_INFO = {
922
+ shell: {
923
+ name: '执行命令',
924
+ hint: 'Banana 可以在你的电脑上运行命令(如 npm test、git status)。',
925
+ modes: {
926
+ ask: '每次执行前弹窗问你 —— 最安全,推荐新手用这个',
927
+ allow: '直接执行,不打扰你 —— 方便但风险自负',
928
+ deny: '完全禁止 —— Banana 只能读文件,不能动你的系统',
929
+ },
930
+ },
931
+ write: {
932
+ name: '写入文件',
933
+ hint: '修改或新建文件的能力。每次改动都可以用左下角「撤销」回滚。',
934
+ modes: {
935
+ ask: '每次改文件前给你看改动内容,你确认才写',
936
+ allow: '直接改,不再逐个确认',
937
+ deny: '禁止任何写入 —— 只读模式',
938
+ },
939
+ },
940
+ network: {
941
+ name: '联网',
942
+ hint: '允许 Banana 访问外部网络(搜索、抓网页)。',
943
+ modes: {
944
+ ask: '每次联网都问你',
945
+ allow: '允许联网(默认)',
946
+ deny: '完全断网,只能用本地信息',
947
+ },
948
+ },
949
+ };
566
950
  for (const key of ['shell', 'write', 'network']) {
951
+ const spec = PERM_INFO[key];
567
952
  const row = document.createElement('div');
568
953
  row.className = 'perm-row';
569
954
  row.dataset.key = key;
570
- row.innerHTML =
571
- `<span class="perm-name">${permNames[key]}</span>` +
572
- '<div class="seg">' +
573
- ['ask', 'allow', 'deny']
574
- .map((mode) => {
575
- const label = mode === 'ask' ? '每次问' : mode === 'allow' ? '直接执行' : '禁止';
576
- return `<button data-mode="${mode}" class="${info.permissions[key] === mode ? 'is-on' : ''}">${label}</button>`;
577
- })
578
- .join('') +
579
- '</div>';
580
- row.querySelector('.seg').addEventListener('click', async (event) => {
955
+ const seg = document.createElement('div');
956
+ seg.className = 'seg';
957
+ seg.innerHTML = ['ask', 'allow', 'deny']
958
+ .map((mode) => {
959
+ const label = mode === 'ask' ? '每次问' : mode === 'allow' ? '直接执行' : '禁止';
960
+ return `<button data-mode="${mode}" class="${info.permissions[key] === mode ? 'is-on' : ''}">${label}</button>`;
961
+ })
962
+ .join('');
963
+ seg.addEventListener('click', async (event) => {
581
964
  const button = event.target.closest('[data-mode]');
582
965
  if (!button) return;
583
- const row = button.closest('.perm-row');
584
- const key = row.dataset.key;
966
+ const mode = button.dataset.mode;
585
967
  // 立即反馈,失败再回滚并提示,避免看起来"点不动"
586
- row.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item === button));
968
+ seg.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item === button));
969
+ const detail = row.querySelector('.perm-detail');
970
+ if (detail) detail.textContent = spec.modes[mode];
587
971
  try {
588
- await post('action', { action: 'permission', name: key, value: button.dataset.mode });
972
+ await post('action', { action: 'permission', name: key, value: mode });
589
973
  await refresh(false);
974
+ toast(`「${spec.name}」已设为:${mode === 'ask' ? '每次问' : mode === 'allow' ? '直接执行' : '禁止'}`);
590
975
  } catch (error) {
591
- row.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item.dataset.mode !== button.dataset.mode));
976
+ seg.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item.dataset.mode !== mode));
977
+ if (detail) detail.textContent = spec.modes[info.permissions[key]];
592
978
  toast('保存失败:' + error.message);
593
979
  }
594
980
  });
981
+
982
+ const block = document.createElement('div');
983
+ block.className = 'perm-block';
984
+ const head = document.createElement('div');
985
+ head.className = 'perm-head';
986
+ head.innerHTML = `<span class="perm-name">${escapeHtml(spec.name)}</span>`;
987
+ head.appendChild(seg);
988
+ const desc = document.createElement('div');
989
+ desc.className = 'hint';
990
+ desc.textContent = spec.hint;
991
+ const detail = document.createElement('div');
992
+ detail.className = 'perm-detail';
993
+ detail.textContent = spec.modes[info.permissions[key]];
994
+ block.append(head, desc, detail);
995
+ row.appendChild(block);
595
996
  permField.appendChild(row);
596
997
  }
998
+ const note = document.createElement('div');
999
+ note.className = 'hint perm-note';
1000
+ note.textContent = '改动立刻生效,并保存在配置里(CLI 和 GUI 共用同一份设置)。不确定就保持「每次问」。';
1001
+ permField.appendChild(note);
597
1002
  body.appendChild(permField);
598
1003
 
599
1004
  const toolsField = document.createElement('div');
@@ -622,6 +1027,91 @@ function renderSettings() {
622
1027
  body.appendChild(sysField);
623
1028
  }
624
1029
 
1030
+ // ---------------- 文件面板 ----------------
1031
+ const FILE_ICON = { dir: 'folder', file: 'file' };
1032
+
1033
+ async function loadTree(path = '.', container = $('filesTree')) {
1034
+ container.innerHTML = '<div class="hint" style="padding:8px 10px">读取中…</div>';
1035
+ try {
1036
+ const { entries } = await get(`files?path=${encodeURIComponent(path)}`);
1037
+ container.innerHTML = '';
1038
+ if (!entries.length) {
1039
+ container.innerHTML = '<div class="hint" style="padding:8px 10px">(空目录)</div>';
1040
+ return;
1041
+ }
1042
+ for (const entry of entries) {
1043
+ const row = document.createElement('button');
1044
+ row.className = 'file-row';
1045
+ row.innerHTML =
1046
+ `<span class="tool-caret">${entry.type === 'dir' ? svg('chevron') : ''}</span>` +
1047
+ `<span>${svg(FILE_ICON[entry.type])}</span>` +
1048
+ `<span class="f-name">${escapeHtml(entry.name)}</span>`;
1049
+ row.addEventListener('click', async () => {
1050
+ if (entry.type === 'dir') {
1051
+ const holder = row.nextElementSibling;
1052
+ if (holder?.classList.contains('file-children')) {
1053
+ holder.remove();
1054
+ row.classList.remove('is-open');
1055
+ return;
1056
+ }
1057
+ row.classList.add('is-open');
1058
+ const box = document.createElement('div');
1059
+ box.className = 'file-children';
1060
+ row.after(box);
1061
+ await loadTree(entry.path, box);
1062
+ } else {
1063
+ await openFile(entry.path);
1064
+ }
1065
+ });
1066
+ container.appendChild(row);
1067
+ }
1068
+ } catch (error) {
1069
+ container.innerHTML = `<div class="hint" style="padding:8px 10px">${escapeHtml(error.message)}</div>`;
1070
+ }
1071
+ }
1072
+
1073
+ async function openFile(path) {
1074
+ const box = $('filesPreview');
1075
+ const body = $('previewBody');
1076
+ box.hidden = false;
1077
+ $('previewName').textContent = path;
1078
+ body.textContent = '读取中…';
1079
+ try {
1080
+ const data = await get(`file?path=${encodeURIComponent(path)}`);
1081
+ $('insertPath').dataset.path = path;
1082
+ if (data.binary) body.textContent = `二进制文件(${data.bytes} 字节),无法预览。`;
1083
+ else if (data.tooLarge) body.textContent = `文件较大(${data.bytes} 字节),未加载。`;
1084
+ else body.textContent = data.content ?? '';
1085
+ document.querySelectorAll('.file-row.is-active').forEach((node) => node.classList.remove('is-active'));
1086
+ } catch (error) {
1087
+ body.textContent = error.message;
1088
+ }
1089
+ }
1090
+
1091
+ async function toggleFiles() {
1092
+ const panel = $('filesPanel');
1093
+ panel.hidden = !panel.hidden;
1094
+ if (!panel.hidden && !$('filesTree').childElementCount) {
1095
+ $('filesTitle').textContent = `项目文件 · ${state.info?.workspace ?? ''}`;
1096
+ await loadTree('.');
1097
+ }
1098
+ }
1099
+
1100
+ // ---------------- 工具输出详情 ----------------
1101
+ function toggleToolOutput(row, detail, isError) {
1102
+ const existing = row.nextElementSibling;
1103
+ if (existing?.classList.contains('tool-output')) {
1104
+ existing.remove();
1105
+ row.classList.remove('is-open');
1106
+ return;
1107
+ }
1108
+ const pre = document.createElement('div');
1109
+ pre.className = `tool-output${isError ? ' is-error' : ''}`;
1110
+ pre.textContent = detail || '(无输出)';
1111
+ row.after(pre);
1112
+ row.classList.add('is-open');
1113
+ }
1114
+
625
1115
  // ---------------- 启动 ----------------
626
1116
  function openSettings() {
627
1117
  renderSettings();
@@ -640,7 +1130,10 @@ function bind() {
640
1130
  $('abortBtn').innerHTML = svg('stop');
641
1131
  $('toggleSidebar').innerHTML = svg('list');
642
1132
  $('closeSettings').innerHTML = svg('x');
1133
+ $('filesBtn').innerHTML = svg('panel');
1134
+ $('closeFiles').innerHTML = svg('x');
643
1135
  hydrate();
1136
+ loadTheme();
644
1137
 
645
1138
  $('newChat').addEventListener('click', () => post('session', { action: 'new' }).then(() => refresh(true)));
646
1139
  $('abortBtn').addEventListener('click', async () => { controller?.abort(); await post('abort'); });
@@ -654,6 +1147,18 @@ function bind() {
654
1147
  if (event.target === $('settingsSheet')) closeSettings();
655
1148
  });
656
1149
  $('toggleSidebar').addEventListener('click', () => $('sidebar').classList.toggle('open'));
1150
+ $('filesBtn').addEventListener('click', toggleFiles);
1151
+ $('themeBtn').addEventListener('click', cycleTheme);
1152
+ $('closeFiles').addEventListener('click', () => { $('filesPanel').hidden = true; });
1153
+ $('insertPath').addEventListener('click', () => {
1154
+ const path = $('insertPath').dataset.path;
1155
+ if (!path) return;
1156
+ const box = $('input');
1157
+ box.value = box.value.trim() ? `${box.value.trim()} @${path} ` : `@${path} `;
1158
+ box.focus();
1159
+ box.setSelectionRange(box.value.length, box.value.length);
1160
+ toast('已插入文件引用');
1161
+ });
657
1162
  $('approvalNo').addEventListener('click', () => closeApproval(false));
658
1163
  $('approvalYes').addEventListener('click', () => closeApproval(true));
659
1164
 
@@ -663,8 +1168,36 @@ function bind() {
663
1168
  });
664
1169
 
665
1170
  $('composer').addEventListener('submit', (event) => { event.preventDefault(); send(); });
666
- $('input').addEventListener('input', grow);
1171
+ $('input').addEventListener('input', () => {
1172
+ grow();
1173
+ const value = $('input').value;
1174
+ // / 开头 → 命令面板;结尾是 @路径 → 文件补全(服务端用 CLI 同一份逻辑)
1175
+ if (/^\/[a-zA-Z?]*$/.test(value) && commandList.length) openPalette(value);
1176
+ else if (/(^|\s)@[^\s]*$/.test(value)) requestFileComplete(value);
1177
+ else closePalette();
1178
+ });
667
1179
  $('input').addEventListener('keydown', (event) => {
1180
+ if (!$('commandPalette').hidden) {
1181
+ if (event.key === 'ArrowDown') { event.preventDefault(); movePalette(1); return; }
1182
+ if (event.key === 'ArrowUp') { event.preventDefault(); movePalette(-1); return; }
1183
+ if (event.key === 'Tab') {
1184
+ event.preventDefault();
1185
+ const picked = state.commandMatches?.[state.commandIndex ?? 0];
1186
+ if (picked) applyCompletion(picked);
1187
+ return;
1188
+ }
1189
+ // 文件补全面板里回车直接发送更顺手;命令面板里回车优先补全
1190
+ if (event.key === 'Enter' && !event.shiftKey && $('commandPalette').dataset.kind === 'command' && state.commandMatches?.length) {
1191
+ event.preventDefault();
1192
+ const picked = state.commandMatches[state.commandIndex ?? 0];
1193
+ if (picked) {
1194
+ applyCompletion(picked);
1195
+ renderCommandPalette(picked.item.name ?? '');
1196
+ return;
1197
+ }
1198
+ }
1199
+ if (event.key === 'Escape') { event.preventDefault(); closePalette(); return; }
1200
+ }
668
1201
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); send(); }
669
1202
  });
670
1203
 
@@ -672,6 +1205,7 @@ function bind() {
672
1205
  if (event.key === 'Escape') {
673
1206
  if (!$('approvalModal').hidden) closeApproval(false);
674
1207
  else if (!$('settingsSheet').hidden) closeSettings();
1208
+ else if (!$('filesPanel').hidden) $('filesPanel').hidden = true;
675
1209
  else if ($('sidebar').classList.contains('open')) $('sidebar').classList.remove('open');
676
1210
  }
677
1211
  });
@@ -688,6 +1222,12 @@ function bind() {
688
1222
 
689
1223
  async function boot() {
690
1224
  bind();
1225
+ // 命令表来自服务端(与 CLI 同一份),拉不到也不影响对话
1226
+ try {
1227
+ commandList = (await get('commands')).commands ?? [];
1228
+ } catch {
1229
+ commandList = [];
1230
+ }
691
1231
  await refresh(true);
692
1232
  grow();
693
1233
  $('input').focus();
package/ui/index.html CHANGED
@@ -42,6 +42,8 @@
42
42
  <button class="chip-btn" id="modelBtn" title="模型与权限">
43
43
  <span data-icon="chip"></span><span id="modelLabel">—</span>
44
44
  </button>
45
+ <button class="icon-btn" id="filesBtn" title="项目文件" aria-label="项目文件"></button>
46
+ <button class="icon-btn" id="themeBtn" title="切换主题" aria-label="切换主题"></button>
45
47
  <button class="icon-btn" id="abortBtn" title="停止" aria-label="停止" hidden></button>
46
48
  </div>
47
49
  </header>
@@ -51,7 +53,8 @@
51
53
  <div class="composer-wrap">
52
54
  <div class="attachments" id="attachments" hidden></div>
53
55
  <form class="composer" id="composer">
54
- <textarea id="input" rows="1" placeholder="说点什么,Enter 发送,Shift+Enter 换行"></textarea>
56
+ <div class="command-palette" id="commandPalette" hidden></div>
57
+ <textarea id="input" rows="1" placeholder="说点什么;输入 / 调命令,@ 引文件,Enter 发送,Shift+Enter 换行"></textarea>
55
58
  <button class="send-btn" id="sendBtn" type="submit" title="发送" aria-label="发送"></button>
56
59
  </form>
57
60
  <div class="composer-hint" id="composerHint"></div>
@@ -69,6 +72,21 @@
69
72
  </div>
70
73
  </div>
71
74
 
75
+ <aside class="files-panel" id="filesPanel" hidden>
76
+ <header class="files-head">
77
+ <span id="filesTitle">项目文件</span>
78
+ <button class="icon-btn" id="closeFiles" aria-label="关闭文件面板"></button>
79
+ </header>
80
+ <div class="files-tree" id="filesTree"></div>
81
+ <div class="files-preview" id="filesPreview" hidden>
82
+ <div class="files-preview-head">
83
+ <span id="previewName"></span>
84
+ <button class="btn tiny" id="insertPath">插入到输入框</button>
85
+ </div>
86
+ <pre id="previewBody"></pre>
87
+ </div>
88
+ </aside>
89
+
72
90
  <div class="modal" id="approvalModal" hidden>
73
91
  <div class="modal-card">
74
92
  <div class="modal-head">
package/ui/style.css CHANGED
@@ -19,8 +19,9 @@
19
19
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
20
20
  }
21
21
 
22
+ /* 深色主题:默认跟随系统,也可以手动锁 light / dark */
22
23
  @media (prefers-color-scheme: dark) {
23
- :root {
24
+ :root:not([data-theme='light']) {
24
25
  --bg: #0e0e10;
25
26
  --panel: #131316;
26
27
  --panel-2: #1a1a1e;
@@ -35,6 +36,20 @@
35
36
  }
36
37
  }
37
38
 
39
+ :root[data-theme='dark'] {
40
+ --bg: #0e0e10;
41
+ --panel: #131316;
42
+ --panel-2: #1a1a1e;
43
+ --line: #26262b;
44
+ --line-strong: #33333a;
45
+ --text: #f2f2f0;
46
+ --text-2: #a1a1a8;
47
+ --text-3: #74747c;
48
+ --inverse: #f2f2f0;
49
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
50
+ --shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55);
51
+ }
52
+
38
53
  * { box-sizing: border-box; }
39
54
 
40
55
  html, body {
@@ -486,6 +501,10 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
486
501
  .sheet[hidden] { display: none; }
487
502
  .sheet.is-closing { animation: fadeOut 160ms var(--ease-in) both; }
488
503
  @keyframes fadeOut { to { opacity: 0; } }
504
+ @keyframes pop {
505
+ from { opacity: 0; transform: translateY(4px) scale(0.99); }
506
+ to { opacity: 1; transform: none; }
507
+ }
489
508
  .sheet-panel {
490
509
  width: min(420px, 92vw);
491
510
  height: 100%;
@@ -534,11 +553,246 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
534
553
  .seg button:active { transform: scale(0.98); }
535
554
  .seg button.is-on { background: var(--inverse); color: var(--bg); }
536
555
 
537
- .perm-row { display: flex; align-items: center; gap: 10px; }
538
- .perm-name { width: 62px; flex: none; font-size: 13px; color: var(--text-2); }
556
+ .perm-row { display: block; }
557
+ .perm-block { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
558
+ .perm-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
559
+ .perm-name { font-size: 13px; color: var(--text); font-weight: 500; }
560
+ .perm-head .seg { flex: none; width: 190px; }
561
+ .perm-detail {
562
+ margin-top: 6px;
563
+ padding-top: 6px;
564
+ border-top: 1px dashed var(--line);
565
+ font-size: 12px;
566
+ color: var(--text-2);
567
+ line-height: 1.5;
568
+ }
569
+ .perm-note { margin-top: 4px; }
570
+
571
+ /* ---------- 空状态引导 ---------- */
572
+ .empty-warn {
573
+ display: flex;
574
+ flex-direction: column;
575
+ align-items: flex-start;
576
+ gap: 8px;
577
+ max-width: 520px;
578
+ margin: 4px auto 22px;
579
+ padding: 16px 18px;
580
+ border: 1px solid var(--line-strong);
581
+ border-radius: var(--radius);
582
+ background: var(--panel);
583
+ text-align: left;
584
+ }
585
+ .empty-warn strong { font-size: 14.5px; font-weight: 600; }
586
+ .empty-warn span { font-size: 13px; color: var(--text-2); line-height: 1.6; }
587
+ .empty-warn .btn { margin-top: 4px; }
588
+ .empty-tips {
589
+ display: flex;
590
+ flex-wrap: wrap;
591
+ justify-content: center;
592
+ gap: 6px 18px;
593
+ margin-top: 22px;
594
+ font-size: 12px;
595
+ color: var(--text-3);
596
+ }
597
+ .empty-tips b { font-family: var(--mono); font-weight: 600; color: var(--text-2); }
598
+ .thread-divider {
599
+ max-width: 780px;
600
+ margin: 6px auto 18px;
601
+ padding: 0 24px;
602
+ display: flex;
603
+ align-items: center;
604
+ gap: 12px;
605
+ color: var(--text-3);
606
+ font-size: 11.5px;
607
+ }
608
+ .thread-divider::before,
609
+ .thread-divider::after {
610
+ content: '';
611
+ flex: 1;
612
+ height: 1px;
613
+ background: var(--line);
614
+ }
615
+ .thread-divider:empty::before,
616
+ .thread-divider:empty::after { display: none; }
617
+
618
+ /* ---------- 命令面板 ---------- */
619
+ .command-palette {
620
+ position: absolute;
621
+ left: 12px;
622
+ right: 12px;
623
+ bottom: calc(100% + 8px);
624
+ z-index: 40;
625
+ padding: 5px;
626
+ border: 1px solid var(--line);
627
+ border-radius: 11px;
628
+ background: var(--bg);
629
+ box-shadow: var(--shadow-lg);
630
+ animation: pop 170ms var(--ease) both;
631
+ max-height: 320px;
632
+ overflow: auto;
633
+ }
634
+ .command-palette[hidden] { display: none; }
635
+ .cmd-item {
636
+ display: grid;
637
+ grid-template-columns: minmax(70px, 108px) 1fr auto;
638
+ align-items: baseline;
639
+ gap: 10px;
640
+ width: 100%;
641
+ padding: 7px 9px;
642
+ border: 0;
643
+ border-radius: 7px;
644
+ background: transparent;
645
+ text-align: left;
646
+ cursor: pointer;
647
+ transition: background 120ms var(--ease);
648
+ }
649
+ .cmd-item:hover, .cmd-item.is-on { background: var(--panel-2); }
650
+ .cmd-name { font-family: var(--mono); font-size: 12.5px; color: var(--text); }
651
+ .cmd-summary { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
652
+ .cmd-usage { font-family: var(--mono); font-size: 11.5px; color: var(--text-3); }
653
+ .cmd-empty { padding: 10px; font-size: 12.5px; color: var(--text-3); }
654
+
655
+ /* 命令输出用等宽字体,和对话正文区分开 */
656
+ .msg.cmd .msg-body { font-family: var(--mono); font-size: 12.6px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-2); }
657
+ .cmd-out.is-error { color: var(--text); }
658
+ .msg.cmd .msg-role { color: var(--text-3); }
539
659
 
540
660
  .hint { font-size: 12px; color: var(--text-3); line-height: 1.5; }
541
661
 
662
+ /* ---------- 工具详情 ---------- */
663
+ .tool-row { cursor: default; }
664
+ .tool-row.is-open .t-detail { color: var(--text-2); }
665
+ .tool-caret { transition: transform 200ms var(--ease); }
666
+ .tool-row.is-open .tool-caret { transform: rotate(90deg); }
667
+ .tool-output {
668
+ margin: 2px 0 8px;
669
+ padding: 10px 12px;
670
+ max-height: 320px;
671
+ overflow: auto;
672
+ border: 1px solid var(--line);
673
+ border-radius: 9px;
674
+ background: var(--panel);
675
+ font-family: var(--mono);
676
+ font-size: 12.2px;
677
+ line-height: 1.55;
678
+ white-space: pre-wrap;
679
+ overflow-wrap: anywhere;
680
+ animation: fade 200ms var(--ease) both;
681
+ }
682
+ .tool-output.is-error { color: var(--text); border-color: var(--line-strong); }
683
+
684
+ /* ---------- 文件面板 ---------- */
685
+ .files-panel {
686
+ position: fixed;
687
+ top: 0;
688
+ right: 0;
689
+ bottom: 0;
690
+ width: min(420px, 92vw);
691
+ background: var(--bg);
692
+ border-left: 1px solid var(--line);
693
+ box-shadow: var(--shadow-lg);
694
+ display: flex;
695
+ flex-direction: column;
696
+ z-index: 30;
697
+ animation: slide 260ms var(--ease) both;
698
+ }
699
+ .files-panel[hidden] { display: none; }
700
+ .files-head {
701
+ display: flex;
702
+ align-items: center;
703
+ justify-content: space-between;
704
+ height: 54px;
705
+ padding: 0 10px 0 16px;
706
+ border-bottom: 1px solid var(--line);
707
+ font-size: 13px;
708
+ color: var(--text-2);
709
+ }
710
+ .files-tree { flex: 1; min-height: 0; overflow: auto; padding: 8px; }
711
+ .file-row {
712
+ display: flex;
713
+ align-items: center;
714
+ gap: 6px;
715
+ width: 100%;
716
+ padding: 5px 8px;
717
+ border: 0;
718
+ border-radius: 7px;
719
+ background: transparent;
720
+ color: var(--text-2);
721
+ font-size: 13px;
722
+ text-align: left;
723
+ cursor: pointer;
724
+ transition: background 140ms var(--ease), color 140ms var(--ease);
725
+ }
726
+ .file-row:hover { background: var(--panel-2); color: var(--text); }
727
+ .file-row.is-active { background: var(--panel-2); color: var(--text); }
728
+ .tool-caret { flex: none; display: inline-grid; place-items: center; width: 13px; height: 13px; color: var(--text-3); }
729
+ .file-row.is-open .tool-caret { transform: rotate(90deg); }
730
+ .file-children { margin-left: 13px; padding-left: 7px; border-left: 1px solid var(--line); }
731
+ .tool-row.is-clickable { cursor: pointer; }
732
+ .tool-row.is-clickable:hover .t-detail { color: var(--text); }
733
+ .tool-row.is-clickable .t-state { opacity: 1; }
734
+ .file-row svg { width: 13px; height: 13px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
735
+ .file-row .f-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
736
+ .files-preview { border-top: 1px solid var(--line); max-height: 46%; display: flex; flex-direction: column; }
737
+ .files-preview-head {
738
+ display: flex;
739
+ align-items: center;
740
+ justify-content: space-between;
741
+ gap: 8px;
742
+ padding: 8px 10px 8px 14px;
743
+ font-size: 12px;
744
+ color: var(--text-3);
745
+ font-family: var(--mono);
746
+ }
747
+ .files-preview pre {
748
+ margin: 0;
749
+ padding: 0 14px 14px;
750
+ overflow: auto;
751
+ font-family: var(--mono);
752
+ font-size: 12.2px;
753
+ line-height: 1.55;
754
+ color: var(--text-2);
755
+ white-space: pre-wrap;
756
+ overflow-wrap: anywhere;
757
+ }
758
+ .btn.tiny { height: 24px; padding: 0 9px; font-size: 12px; border-radius: 7px; }
759
+
760
+ /* ---------- popover ---------- */
761
+ .popover {
762
+ position: fixed;
763
+ z-index: 60;
764
+ min-width: 172px;
765
+ padding: 5px;
766
+ border: 1px solid var(--line);
767
+ border-radius: 10px;
768
+ background: var(--bg);
769
+ box-shadow: var(--shadow-lg);
770
+ animation: pop 160ms var(--ease) both;
771
+ }
772
+ .popover button {
773
+ display: block;
774
+ width: 100%;
775
+ text-align: left;
776
+ padding: 7px 10px;
777
+ border: 0;
778
+ border-radius: 7px;
779
+ background: transparent;
780
+ color: var(--text-2);
781
+ font-size: 13px;
782
+ cursor: pointer;
783
+ transition: background 130ms var(--ease), color 130ms var(--ease);
784
+ }
785
+ .popover button:hover { background: var(--panel-2); color: var(--text); }
786
+
787
+ /* ---------- 侧边栏操作 ---------- */
788
+ .session-item { position: relative; padding-right: 52px; }
789
+ .s-actions { position: absolute; right: 6px; top: 6px; display: flex; gap: 2px; opacity: 0; transition: opacity 140ms var(--ease); }
790
+ .session-item:hover .s-actions, .session-item.is-active .s-actions { opacity: 1; }
791
+ .s-act { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 5px; color: var(--text-3); transition: background 130ms var(--ease), color 130ms var(--ease); }
792
+ .s-act:hover { background: var(--panel-2); color: var(--text); }
793
+ .s-act svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
794
+ .session-item.is-active .s-title { padding-right: 0; }
795
+
542
796
  @media (prefers-reduced-motion: reduce) {
543
797
  *, *::before, *::after {
544
798
  animation-duration: 1ms !important;