@vincentbanana/bananacli 0.4.8 → 0.4.9

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/dist/ui/app.js CHANGED
@@ -31,6 +31,15 @@ const ICONS = {
31
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
32
  moon: '<path d="M20 14.5A8 8 0 019.5 4a8 8 0 1010.5 10.5z"/>',
33
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"/>',
34
+ plug: '<path d="M9 3v5M15 3v5M7 8h10v3a5 5 0 0 1-10 0zM12 16v5"/>',
35
+ command: '<path d="M9 6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3z"/>',
36
+ wrap: '<path d="M4 6h16M4 12h12a3 3 0 1 1 0 6h-3M4 18h4"/>',
37
+ expand: '<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"/>',
38
+ copy: '<rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
39
+ info: '<circle cx="12" cy="12" r="8"/><path d="M12 11v5M12 8h.01"/>',
40
+ minimize: '<path d="M5 12h14"/>',
41
+ maximize: '<rect x="6" y="6" width="12" height="12" rx="1"/>',
42
+ restore: '<rect x="5" y="8" width="9" height="9" rx="1"/><path d="M9 5h9v9"/>',
34
43
  };
35
44
 
36
45
  const TOOL_ICON = {
@@ -152,7 +161,20 @@ function highlightPreview(element, path, code) {
152
161
 
153
162
 
154
163
  // ---------------- 状态 ----------------
155
- const state = { info: null, sessions: [], attachments: [], busy: false, pendingApproval: null };
164
+ const state = {
165
+ info: null,
166
+ sessions: [],
167
+ attachments: [],
168
+ busy: false,
169
+ pendingApproval: null,
170
+ settingsCategory: 'model',
171
+ settingsScroll: 0,
172
+ plugins: null,
173
+ preview: null,
174
+ overlay: null,
175
+ ccIndex: 0,
176
+ ccItems: [],
177
+ };
156
178
 
157
179
  const THREAD = $('thread');
158
180
 
@@ -510,6 +532,132 @@ async function refreshSettings() {
510
532
  if (!$('settingsSheet').hidden) renderSettings();
511
533
  }
512
534
 
535
+ // ---------------- 覆盖层与通用交互 ----------------
536
+ const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
537
+
538
+ function hideAnimated(element, className = 'is-closing', ms = 180) {
539
+ if (!element || element.hidden) return Promise.resolve();
540
+ element.classList.add(className);
541
+ return sleep(ms).then(() => {
542
+ // 动画期间如果又被打开,就不要把面板藏起来
543
+ if (!element.classList.contains(className)) return;
544
+ element.hidden = true;
545
+ element.classList.remove(className);
546
+ });
547
+ }
548
+
549
+ function syncOverlayState() {
550
+ const open =
551
+ !$('commandCenter').hidden ||
552
+ !$('codeViewer').hidden ||
553
+ !$('settingsSheet').hidden ||
554
+ !$('filesPanel').hidden ||
555
+ !$('terminalPanel').hidden ||
556
+ !$('approvalModal').hidden;
557
+ document.body.classList.toggle('has-overlay', open);
558
+ }
559
+
560
+ function closeTopOverlay() {
561
+ if (!$('selectionTools').hidden) return hideSelectionTools();
562
+ if (!$('commandCenter').hidden) return closeCommandCenter();
563
+ if (!$('codeViewer').hidden) return closeViewer();
564
+ if (!$('settingsSheet').hidden) return closeSettings();
565
+ if (!$('filesPanel').hidden) return closeFiles();
566
+ if (!$('terminalPanel').hidden) return termClose();
567
+ if (!$('approvalModal').hidden) return closeApproval(false);
568
+ return false;
569
+ }
570
+
571
+ async function copyText(text, message = '已复制') {
572
+ try {
573
+ await navigator.clipboard.writeText(text);
574
+ } catch {
575
+ const area = document.createElement('textarea');
576
+ area.value = text;
577
+ area.style.cssText = 'position:fixed;left:-9999px;top:0';
578
+ document.body.appendChild(area);
579
+ area.select();
580
+ document.execCommand('copy');
581
+ area.remove();
582
+ }
583
+ toast(message);
584
+ }
585
+
586
+ function insertComposer(text) {
587
+ const input = $('input');
588
+ input.value = text;
589
+ grow();
590
+ input.focus();
591
+ input.setSelectionRange(input.value.length, input.value.length);
592
+ }
593
+
594
+ function askAboutText(text, path, kind = 'code') {
595
+ const trimmed = text.trim();
596
+ if (!trimmed) return;
597
+ const clipped = trimmed.length > 12_000 ? `${trimmed.slice(0, 12_000)}\n…(已截断)` : trimmed;
598
+ const body =
599
+ kind === 'terminal'
600
+ ? `这段终端输出看起来有问题,帮我分析原因,并给出可以直接执行的修复步骤:\n\n\`\`\`\n${clipped}\n\`\`\``
601
+ : `请解释并检查这段代码${path ? `(${path})` : ''},指出潜在问题,必要时给出修改方案:\n\n\`\`\`\n${clipped}\n\`\`\``;
602
+ insertComposer(body);
603
+ toast(kind === 'terminal' ? '已带入终端输出,回车发送' : '已带入选中代码,回车发送');
604
+ hideSelectionTools();
605
+ }
606
+
607
+ function hideSelectionTools() {
608
+ const box = $('selectionTools');
609
+ box.hidden = true;
610
+ box.innerHTML = '';
611
+ syncOverlayState();
612
+ }
613
+
614
+ function showSelectionTools(x, y, buttons) {
615
+ const box = $('selectionTools');
616
+ box.innerHTML = '';
617
+ for (const item of buttons) {
618
+ const button = document.createElement('button');
619
+ button.textContent = item.label;
620
+ button.addEventListener('mousedown', (event) => event.preventDefault());
621
+ button.addEventListener('click', item.run);
622
+ box.appendChild(button);
623
+ }
624
+ box.hidden = false;
625
+ box.style.visibility = 'hidden';
626
+ box.style.left = '0px';
627
+ box.style.top = '0px';
628
+ const width = box.offsetWidth;
629
+ const height = box.offsetHeight;
630
+ box.style.left = `${Math.max(8, Math.min(x - width / 2, window.innerWidth - width - 8))}px`;
631
+ box.style.top = `${Math.max(8, Math.min(y - height - 10, window.innerHeight - height - 8))}px`;
632
+ box.style.visibility = '';
633
+ syncOverlayState();
634
+ }
635
+
636
+ function languageFor(path) {
637
+ const ext = String(path ?? '').split('.').pop()?.toLowerCase() ?? '';
638
+ return LANG_BY_EXT[ext] ?? '';
639
+ }
640
+
641
+ /** 代码容器:左侧行号 + 右侧高亮代码,整体共用一个滚动区域。 */
642
+ function renderCode(container, code, path) {
643
+ const lines = String(code ?? '').split('\n');
644
+ const gutter = lines.map((_, index) => index + 1).join('\n');
645
+ const lang = languageFor(path);
646
+ container.innerHTML =
647
+ `<div class="code-scroll"><pre class="code-gutter">${gutter}</pre>` +
648
+ `<pre class="code-pre"><code${lang ? ` class="language-${escapeHtml(lang)}"` : ''}></code></pre></div>`;
649
+ const node = container.querySelector('code');
650
+ node.textContent = String(code ?? '');
651
+ if (window.hljs && lang && String(code ?? '').length <= 300_000) {
652
+ try {
653
+ window.hljs.highlightElement(node);
654
+ } catch {
655
+ /* 单个文件失败保持纯文本 */
656
+ }
657
+ }
658
+ }
659
+
660
+
513
661
  // ---------------- 聊天流 ----------------
514
662
  let controller = null;
515
663
 
@@ -721,6 +869,11 @@ const THEME_LABEL = { auto: '跟随系统', light: '浅色', dark: '深色' };
721
869
  const THEME_ICON = { auto: 'contrast', light: 'sun', dark: 'moon' };
722
870
 
723
871
  function applyTheme(theme) {
872
+ // 主题切换时短暂开启颜色过渡,避免整页硬切
873
+ const root = document.documentElement;
874
+ root.classList.add('theme-anim');
875
+ clearTimeout(applyTheme.timer);
876
+ applyTheme.timer = setTimeout(() => root.classList.remove('theme-anim'), 300);
724
877
  if (theme === 'auto') delete document.documentElement.dataset.theme;
725
878
  else document.documentElement.dataset.theme = theme;
726
879
  try {
@@ -941,231 +1094,620 @@ async function attach(file) {
941
1094
  toast('已附加 ' + file.name);
942
1095
  }
943
1096
 
944
- // ---------------- 设置面板 ----------------
1097
+ // ---------------- 控制中心 ----------------
1098
+ const SETTINGS_NAV = [
1099
+ { id: 'model', label: '模型', icon: 'chip' },
1100
+ { id: 'appearance', label: '外观', icon: 'sun' },
1101
+ { id: 'terminal', label: '终端', icon: 'terminal' },
1102
+ { id: 'workspace', label: '工作区', icon: 'folder' },
1103
+ { id: 'plugins', label: '插件', icon: 'plug' },
1104
+ { id: 'about', label: '关于', icon: 'info' },
1105
+ ];
1106
+
1107
+ function openSettings(category) {
1108
+ if (category) state.settingsCategory = category;
1109
+ state.settingsScroll = 0;
1110
+ renderSettings();
1111
+ const sheet = $('settingsSheet');
1112
+ sheet.classList.remove('is-closing');
1113
+ sheet.hidden = false;
1114
+ document.body.classList.add('has-overlay');
1115
+ requestAnimationFrame(() => {
1116
+ $('settingsBody').scrollTop = 0;
1117
+ $('settingsSheet').querySelector('.settings-panel')?.focus?.();
1118
+ });
1119
+ }
1120
+
1121
+ function closeSettings() {
1122
+ const sheet = $('settingsSheet');
1123
+ if (sheet.hidden) return;
1124
+ state.settingsScroll = $('settingsBody').scrollTop;
1125
+ void hideAnimated(sheet, 'is-closing', 170).then(syncOverlayState);
1126
+ $('input')?.focus();
1127
+ }
1128
+
1129
+ function settingsCard() {
1130
+ const card = document.createElement('div');
1131
+ card.className = 'set-card';
1132
+ return card;
1133
+ }
1134
+
1135
+ function settingsRow(title, desc, control) {
1136
+ const row = document.createElement('div');
1137
+ row.className = 'set-row';
1138
+ const copy = document.createElement('div');
1139
+ copy.className = 'set-copy';
1140
+ copy.innerHTML = `<strong>${escapeHtml(title)}</strong>${desc ? `<span>${escapeHtml(desc)}</span>` : ''}`;
1141
+ row.appendChild(copy);
1142
+ if (control) {
1143
+ const holder = document.createElement('div');
1144
+ holder.className = 'set-control';
1145
+ holder.appendChild(control);
1146
+ row.appendChild(holder);
1147
+ }
1148
+ return row;
1149
+ }
1150
+
1151
+ function settingsButton(label, onClick, primary = false) {
1152
+ const button = document.createElement('button');
1153
+ button.className = `btn tiny${primary ? ' primary' : ''}`;
1154
+ button.textContent = label;
1155
+ button.addEventListener('click', onClick);
1156
+ return button;
1157
+ }
1158
+
1159
+ function settingsInput(value, placeholder, type = 'text') {
1160
+ const input = document.createElement('input');
1161
+ input.type = type;
1162
+ input.value = value ?? '';
1163
+ input.placeholder = placeholder ?? '';
1164
+ input.spellcheck = false;
1165
+ return input;
1166
+ }
1167
+
1168
+ function settingsSwitch(on, onChange) {
1169
+ const button = document.createElement('button');
1170
+ button.className = `switch${on ? ' is-on' : ''}`;
1171
+ button.type = 'button';
1172
+ button.setAttribute('role', 'switch');
1173
+ button.setAttribute('aria-checked', String(Boolean(on)));
1174
+ button.addEventListener('click', () => {
1175
+ const next = !button.classList.contains('is-on');
1176
+ button.classList.toggle('is-on', next);
1177
+ button.setAttribute('aria-checked', String(next));
1178
+ onChange(next);
1179
+ });
1180
+ return button;
1181
+ }
1182
+
1183
+ function settingsSeg(options, current, onChange) {
1184
+ const seg = document.createElement('div');
1185
+ seg.className = 'seg';
1186
+ for (const option of options) {
1187
+ const button = document.createElement('button');
1188
+ button.type = 'button';
1189
+ button.dataset.value = option.value;
1190
+ button.textContent = option.label;
1191
+ button.classList.toggle('is-on', option.value === current);
1192
+ button.addEventListener('click', () => {
1193
+ if (button.classList.contains('is-on')) return;
1194
+ seg.querySelectorAll('button').forEach((item) => item.classList.toggle('is-on', item === button));
1195
+ onChange(option.value);
1196
+ });
1197
+ seg.appendChild(button);
1198
+ }
1199
+ return seg;
1200
+ }
1201
+
1202
+ function settingsSection(title, sub) {
1203
+ const section = document.createElement('section');
1204
+ section.className = 'settings-section';
1205
+ section.innerHTML = `<h3 class="settings-title">${escapeHtml(title)}</h3>${sub ? `<p class="settings-sub">${escapeHtml(sub)}</p>` : ''}`;
1206
+ return section;
1207
+ }
1208
+
1209
+ async function saveSetting(action, payload) {
1210
+ try {
1211
+ const result = await post('action', { action, ...payload });
1212
+ if (result?.info) state.info = result.info;
1213
+ return result;
1214
+ } catch (error) {
1215
+ toast('保存失败:' + (error?.message ?? error));
1216
+ throw error;
1217
+ }
1218
+ }
1219
+
945
1220
  function renderSettings() {
946
1221
  const info = state.info;
1222
+ if (!info) return;
947
1223
  const body = $('settingsBody');
1224
+ const nav = $('settingsNav');
1225
+ const scroll = body.scrollTop || state.settingsScroll || 0;
1226
+ nav.innerHTML = '';
1227
+ for (const item of SETTINGS_NAV) {
1228
+ const button = document.createElement('button');
1229
+ button.innerHTML = `${svg(item.icon)}<span>${item.label}</span>`;
1230
+ button.classList.toggle('is-on', item.id === state.settingsCategory);
1231
+ button.addEventListener('click', () => {
1232
+ if (state.settingsCategory === item.id) return;
1233
+ state.settingsCategory = item.id;
1234
+ state.settingsScroll = 0;
1235
+ renderSettings();
1236
+ body.scrollTop = 0;
1237
+ });
1238
+ nav.appendChild(button);
1239
+ }
1240
+
948
1241
  body.innerHTML = '';
1242
+ const category = state.settingsCategory;
1243
+ if (category === 'model') renderModelSettings(body);
1244
+ else if (category === 'appearance') renderAppearanceSettings(body);
1245
+ else if (category === 'terminal') renderTerminalSettings(body);
1246
+ else if (category === 'workspace') renderWorkspaceSettings(body);
1247
+ else if (category === 'plugins') renderPluginSettings(body);
1248
+ else renderAboutSettings(body);
1249
+ requestAnimationFrame(() => { body.scrollTop = scroll; });
1250
+ }
949
1251
 
950
- const providerField = document.createElement('div');
951
- providerField.className = 'field';
952
- providerField.innerHTML =
953
- '<label>Provider</label><select>' +
954
- info.providers.map((item) => `<option value="${item.name}" ${item.current ? 'selected' : ''}>${item.name} — ${item.model}</option>`).join('') +
955
- '</select>';
956
- providerField.querySelector('select').addEventListener('change', async (event) => {
957
- await post('action', { action: 'provider', name: event.target.value });
958
- await refreshSettings();
1252
+ function renderModelSettings(body) {
1253
+ const info = state.info;
1254
+ const active = info.providers.find((item) => item.current);
1255
+ const section = settingsSection('模型', '选择 provider、模型和密钥。CLI 与 GUI 共用同一份配置。');
1256
+
1257
+ const card = settingsCard();
1258
+ const providerSelect = document.createElement('select');
1259
+ providerSelect.innerHTML = info.providers
1260
+ .map((item) => `<option value="${escapeHtml(item.name)}" ${item.current ? 'selected' : ''}>${escapeHtml(item.name)} · ${escapeHtml(item.model)}</option>`)
1261
+ .join('');
1262
+ providerSelect.addEventListener('change', async () => {
1263
+ await saveSetting('provider', { name: providerSelect.value });
1264
+ await refresh(false);
1265
+ renderSettings();
1266
+ toast('已切换到 ' + providerSelect.value);
959
1267
  });
960
- body.appendChild(providerField);
961
-
962
- const modelField = document.createElement('div');
963
- modelField.className = 'field';
964
- modelField.innerHTML = `<label>模型</label><div class="row"><input value="${escapeHtml(info.provider.model)}" /><button class="btn">保存</button></div>`;
965
- modelField.querySelector('button').addEventListener('click', async () => {
966
- const value = modelField.querySelector('input').value.trim();
967
- if (!value) return;
968
- await post('action', { action: 'model', value });
969
- await refreshSettings();
1268
+ card.appendChild(settingsRow('Provider', '切换后新消息使用这个 provider。', providerSelect));
1269
+
1270
+ const modelInput = settingsInput(info.provider.model, '模型名,例如 deepseek-chat');
1271
+ const saveModel = settingsButton('保存', async () => {
1272
+ const value = modelInput.value.trim();
1273
+ if (!value) return toast('请填写模型名');
1274
+ await saveSetting('model', { value });
1275
+ await refresh(false);
1276
+ renderSettings();
970
1277
  toast('模型已更新');
971
- });
972
- modelField.querySelector('input').addEventListener('keydown', (event) => {
973
- if (event.key === 'Enter') modelField.querySelector('button').click();
974
- });
975
- body.appendChild(modelField);
976
-
977
- // API key:留空表示不修改;提示可以用环境变量代替
978
- const active = info.providers.find((item) => item.current);
979
- const keyField = document.createElement('div');
980
- keyField.className = 'field';
981
- const keyPlaceholder = active?.keySaved
982
- ? '已保存(留空表示不修改)'
983
- : active?.keyEnv
984
- ? `默认读取环境变量 ${active.keyEnv}(当前${active.keyReady ? '已设置' : '未设置'})`
985
- : '粘贴 API key';
986
- keyField.innerHTML =
987
- '<label>API Key</label>' +
988
- `<div class="row"><input type="password" placeholder="${keyPlaceholder}" /><button class="btn">保存</button></div>` +
989
- `<div class="hint">${active?.keyEnv ? `默认读取环境变量 ${active.keyEnv};` : ''}更安全的做法是只设环境变量,配置文件里只留变量名。</div>`;
990
- keyField.querySelector('button').addEventListener('click', async () => {
991
- const input = keyField.querySelector('input');
992
- const value = input.value.trim();
993
- if (!value) {
994
- toast('留空表示不修改');
995
- return;
996
- }
997
- await post('action', { action: 'apiKey', value });
998
- input.value = '';
999
- await refreshSettings();
1278
+ }, true);
1279
+ modelInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') saveModel.click(); });
1280
+ const modelBox = document.createElement('div');
1281
+ modelBox.style.display = 'flex';
1282
+ modelBox.style.gap = '6px';
1283
+ modelBox.append(modelInput, saveModel);
1284
+ card.appendChild(settingsRow('模型', '保存后立即生效。', modelBox));
1285
+
1286
+ const keyInput = settingsInput('', active?.keySaved ? '已保存,留空表示不修改' : '粘贴 API Key', 'password');
1287
+ const saveKey = settingsButton('保存', async () => {
1288
+ const value = keyInput.value.trim();
1289
+ if (!value) return toast('留空表示不修改');
1290
+ await saveSetting('apiKey', { value });
1291
+ keyInput.value = '';
1292
+ await refresh(false);
1293
+ renderSettings();
1000
1294
  toast('API Key 已保存');
1295
+ }, true);
1296
+ keyInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') saveKey.click(); });
1297
+ const keyBox = document.createElement('div');
1298
+ keyBox.style.display = 'flex';
1299
+ keyBox.style.gap = '6px';
1300
+ keyBox.append(keyInput, saveKey);
1301
+ const keyHint = active?.keyEnv
1302
+ ? `也可以只设置环境变量 ${active.keyEnv},当前${active.keyReady ? '已就绪' : '未设置'}。`
1303
+ : '更安全的做法是使用环境变量,不要把 key 写进仓库。';
1304
+ card.appendChild(settingsRow('API Key', keyHint, keyBox));
1305
+ card.appendChild(
1306
+ settingsRow(
1307
+ '显示思考过程',
1308
+ '推理模型会展示思考内容;关闭后只保留最终回答。',
1309
+ settingsSwitch(info.showReasoning !== false, async (on) => {
1310
+ await saveSetting('showReasoning', { value: String(on) });
1311
+ await refresh(false);
1312
+ toast(on ? '已显示思考过程' : '已隐藏思考过程');
1313
+ }),
1314
+ ),
1315
+ );
1316
+ section.appendChild(card);
1317
+
1318
+ const promptCard = settingsCard();
1319
+ const stack = document.createElement('div');
1320
+ stack.className = 'set-stack';
1321
+ stack.innerHTML = '<div class="set-copy"><strong>系统提示词</strong><span>给模型的长期指令,会影响所有对话。</span></div>';
1322
+ const area = document.createElement('textarea');
1323
+ area.value = info.systemPrompt ?? '';
1324
+ area.placeholder = '例如:回答尽量简洁,优先给可执行命令。';
1325
+ stack.appendChild(area);
1326
+ const actions = document.createElement('div');
1327
+ actions.className = 'settings-actions';
1328
+ actions.append(
1329
+ settingsButton('恢复默认', async () => {
1330
+ area.value = '';
1331
+ await saveSetting('system', { value: '' });
1332
+ await refresh(false);
1333
+ renderSettings();
1334
+ toast('已清空,使用内置系统提示词');
1335
+ }),
1336
+ settingsButton('保存提示词', async () => {
1337
+ await saveSetting('system', { value: area.value });
1338
+ await refresh(false);
1339
+ toast('系统提示词已保存');
1340
+ }, true),
1341
+ );
1342
+ stack.appendChild(actions);
1343
+ promptCard.appendChild(stack);
1344
+ section.appendChild(promptCard);
1345
+ body.appendChild(section);
1346
+ }
1347
+
1348
+ function renderAppearanceSettings(body) {
1349
+ const saved = (() => {
1350
+ try {
1351
+ return localStorage.getItem('banana-theme') ?? 'auto';
1352
+ } catch {
1353
+ return 'auto';
1354
+ }
1355
+ })();
1356
+ const section = settingsSection('外观', '黑白主题,柔和对比。切换会同步终端和代码高亮。');
1357
+ const card = settingsCard();
1358
+ card.appendChild(
1359
+ settingsRow(
1360
+ '主题',
1361
+ '跟随系统最省心;也可以固定浅色或深色。',
1362
+ settingsSeg(
1363
+ [
1364
+ { value: 'auto', label: '跟随系统' },
1365
+ { value: 'light', label: '浅色' },
1366
+ { value: 'dark', label: '深色' },
1367
+ ],
1368
+ saved,
1369
+ (value) => {
1370
+ applyTheme(value);
1371
+ toast('主题:' + (value === 'auto' ? '跟随系统' : value === 'dark' ? '深色' : '浅色'));
1372
+ },
1373
+ ),
1374
+ ),
1375
+ );
1376
+ section.appendChild(card);
1377
+
1378
+ const motion = settingsCard();
1379
+ motion.appendChild(
1380
+ settingsRow('减少动效', '关闭大部分弹簧和入场动画,适合晕动或低性能设备。', settingsSwitch(
1381
+ document.documentElement.dataset.reduceMotion === '1',
1382
+ (on) => {
1383
+ if (on) document.documentElement.dataset.reduceMotion = '1';
1384
+ else delete document.documentElement.dataset.reduceMotion;
1385
+ localStorage.setItem('banana.reduceMotion', on ? '1' : '0');
1386
+ toast(on ? '已减少动效' : '已恢复弹力动画');
1387
+ },
1388
+ )),
1389
+ );
1390
+ section.appendChild(motion);
1391
+ body.appendChild(section);
1392
+ }
1393
+
1394
+ function renderTerminalSettings(body) {
1395
+ const section = settingsSection('终端', '真实 PTY,支持 PowerShell、cmd、Git Bash 和 WSL。关面板不会杀进程。');
1396
+ const card = settingsCard();
1397
+
1398
+ card.appendChild(
1399
+ settingsRow(
1400
+ '字号',
1401
+ '只影响终端显示,不改变 shell 行为。',
1402
+ settingsSeg(
1403
+ [12, 13, 14, 15, 16].map((size) => ({ value: String(size), label: `${size}px` })),
1404
+ String(term.fontSize),
1405
+ (value) => {
1406
+ term.fontSize = Number(value) || 13;
1407
+ localStorage.setItem(TERM_STORAGE.fontSize, String(term.fontSize));
1408
+ termRestyle();
1409
+ },
1410
+ ),
1411
+ ),
1412
+ );
1413
+
1414
+ const shellSelect = document.createElement('select');
1415
+ const preferred = localStorage.getItem(TERM_STORAGE.shell) || term.defaultShell || 'powershell';
1416
+ shellSelect.innerHTML = (term.shells.length ? term.shells : [{ id: 'powershell', name: 'PowerShell' }])
1417
+ .map((shell) => `<option value="${escapeHtml(shell.id)}" ${shell.id === preferred ? 'selected' : ''}>${escapeHtml(shell.name)}</option>`)
1418
+ .join('');
1419
+ shellSelect.addEventListener('change', () => {
1420
+ localStorage.setItem(TERM_STORAGE.shell, shellSelect.value);
1421
+ const picker = $('termShell');
1422
+ if (picker) picker.value = shellSelect.value;
1423
+ toast('新标签将使用 ' + (term.shells.find((item) => item.id === shellSelect.value)?.name ?? shellSelect.value));
1001
1424
  });
1002
- keyField.querySelector('input').addEventListener('keydown', (event) => {
1003
- if (event.key === 'Enter') keyField.querySelector('button').click();
1004
- });
1005
- body.appendChild(keyField);
1006
-
1007
- // 工作区:换目录后所有文件工具和命令都跟着换,所以放显眼位置
1008
- const workspaceField = document.createElement('div');
1009
- workspaceField.className = 'field';
1010
- workspaceField.innerHTML =
1011
- '<label>工作区</label>' +
1012
- `<div class="row"><input value="${escapeHtml(info.workspace ?? '')}" spellcheck="false" /><button class="btn">切换</button></div>` +
1013
- '<div class="hint">Banana 只能读写这个目录下的文件,命令也在这里执行。绝对路径,例如 D:\\project。</div>';
1014
- workspaceField.querySelector('button').addEventListener('click', async () => {
1015
- const input = workspaceField.querySelector('input');
1425
+ card.appendChild(settingsRow('默认 Shell', '新建终端标签时使用。', shellSelect));
1426
+
1427
+ card.appendChild(
1428
+ settingsRow(
1429
+ '停靠位置',
1430
+ '底部会盖住输入框;也可以靠左、靠右或浮动。',
1431
+ settingsSeg(
1432
+ [
1433
+ { value: 'bottom', label: '底部' },
1434
+ { value: 'left', label: '左侧' },
1435
+ { value: 'right', label: '右侧' },
1436
+ { value: 'float', label: '浮动' },
1437
+ ],
1438
+ term.dock,
1439
+ (value) => {
1440
+ termApplyDock(value);
1441
+ if (!term.open) termOpen();
1442
+ setTimeout(() => termActiveTab()?.terminal.focus(), 220);
1443
+ renderSettings();
1444
+ },
1445
+ ),
1446
+ ),
1447
+ );
1448
+
1449
+ const openBtn = settingsButton(term.open ? '隐藏终端' : '打开终端', () => {
1450
+ closeSettings();
1451
+ if (term.open) termClose();
1452
+ else termOpen();
1453
+ }, true);
1454
+ card.appendChild(settingsRow('终端面板', '快捷键 Ctrl+`,Esc 也可以收起。', openBtn));
1455
+ section.appendChild(card);
1456
+
1457
+ const help = settingsSection('常用快捷键', '');
1458
+ const helpCard = settingsCard();
1459
+ for (const [keys, text] of [
1460
+ ['Ctrl+`', '打开 / 收起终端'],
1461
+ ['Ctrl+K', '全局命令中心'],
1462
+ ['Esc', '关闭最上层面板'],
1463
+ ['Enter / Shift+Enter', '发送 / 换行'],
1464
+ ['@', '引用项目文件'],
1465
+ ['/', '打开命令面板'],
1466
+ ]) {
1467
+ const row = settingsRow(keys, text);
1468
+ row.querySelector('.set-copy strong').style.fontFamily = 'var(--mono)';
1469
+ helpCard.appendChild(row);
1470
+ }
1471
+ help.appendChild(helpCard);
1472
+ body.append(section, help);
1473
+ }
1474
+
1475
+ function renderWorkspaceSettings(body) {
1476
+ const info = state.info;
1477
+ const section = settingsSection('工作区', '文件工具和命令都只能在这个目录下执行。');
1478
+ const card = settingsCard();
1479
+ const input = settingsInput(info.workspace ?? '', '绝对路径,例如 D:\\project');
1480
+ const switchBtn = settingsButton('切换', async () => {
1016
1481
  const value = input.value.trim();
1017
- if (!value) {
1018
- toast('请填写目录路径');
1019
- return;
1020
- }
1482
+ if (!value) return toast('请填写目录路径');
1021
1483
  try {
1022
- await post('action', { action: 'workspace', value });
1023
- await refreshSettings();
1484
+ await saveSetting('workspace', { value });
1485
+ await refresh(false);
1024
1486
  renderHeader();
1487
+ renderSettings();
1488
+ $('filesTree').innerHTML = '';
1025
1489
  toast('工作区已切换到 ' + value);
1026
1490
  } catch (error) {
1027
1491
  toast('切换失败:' + (error?.message ?? error));
1028
1492
  }
1029
- });
1030
- workspaceField.querySelector('input').addEventListener('keydown', (event) => {
1031
- if (event.key === 'Enter') workspaceField.querySelector('button').click();
1032
- });
1033
- body.appendChild(workspaceField);
1034
-
1035
- const terminalField = document.createElement('div');
1036
- terminalField.className = 'field';
1037
- terminalField.innerHTML =
1038
- '<label>终端</label><div class="row"><button class="btn">在界面里打开终端</button>' +
1039
- '<select class="tiny-select" aria-label="终端字号">' +
1040
- [12, 13, 14, 15, 16].map((size) => `<option value="${size}" ${size === term.fontSize ? 'selected' : ''}>${size}px</option>`).join('') +
1041
- '</select></div>' +
1042
- '<div class="hint">真实 shell(cmd / PowerShell / Git Bash / WSL),支持颜色和交互;停靠位置可在终端右上角切换,Ctrl+` 开关。</div>';
1043
- terminalField.querySelector('button').addEventListener('click', () => {
1044
- closeSettings();
1045
- termOpen();
1046
- });
1047
- terminalField.querySelector('select').addEventListener('change', (event) => {
1048
- term.fontSize = Number(event.target.value) || 13;
1049
- localStorage.setItem(TERM_STORAGE.fontSize, String(term.fontSize));
1050
- termRestyle();
1051
- });
1052
- body.appendChild(terminalField);
1053
-
1054
- const permField = document.createElement('div');
1055
- permField.className = 'field';
1056
- permField.innerHTML = '<label>权限</label>';
1057
- // 权限是小白最容易懵的地方,所以每种模式都写清楚"改了会发生什么"
1493
+ }, true);
1494
+ input.addEventListener('keydown', (event) => { if (event.key === 'Enter') switchBtn.click(); });
1495
+ const box = document.createElement('div');
1496
+ box.style.display = 'flex';
1497
+ box.style.gap = '6px';
1498
+ box.append(input, switchBtn);
1499
+ card.appendChild(settingsRow('当前工作区', '切换后插件也会重新加载项目目录。', box));
1500
+ section.appendChild(card);
1501
+
1502
+ const permSection = settingsSection('权限', '不确定就保持「每次问」。改动会立刻保存,CLI 和 GUI 共用。');
1503
+ const permCard = settingsCard();
1058
1504
  const PERM_INFO = {
1059
- shell: {
1060
- name: '执行命令',
1061
- hint: 'Banana 可以在你的电脑上运行命令(如 npm test、git status)。',
1062
- modes: {
1063
- ask: '每次执行前弹窗问你 —— 最安全,推荐新手用这个',
1064
- allow: '直接执行,不打扰你 —— 方便但风险自负',
1065
- deny: '完全禁止 —— Banana 只能读文件,不能动你的系统',
1066
- },
1067
- },
1068
- write: {
1069
- name: '写入文件',
1070
- hint: '修改或新建文件的能力。每次改动都可以用左下角「撤销」回滚。',
1071
- modes: {
1072
- ask: '每次改文件前给你看改动内容,你确认才写',
1073
- allow: '直接改,不再逐个确认',
1074
- deny: '禁止任何写入 —— 只读模式',
1075
- },
1076
- },
1077
- network: {
1078
- name: '联网',
1079
- hint: '允许 Banana 访问外部网络(搜索、抓网页)。',
1080
- modes: {
1081
- ask: '每次联网都问你',
1082
- allow: '允许联网(默认)',
1083
- deny: '完全断网,只能用本地信息',
1084
- },
1085
- },
1505
+ shell: { name: '执行命令', hint: '运行 npm test、git status 等命令。' },
1506
+ write: { name: '写入文件', hint: '新建或修改文件,可用左下角撤销回滚。' },
1507
+ network: { name: '联网', hint: '搜索和抓取网页。' },
1086
1508
  };
1087
1509
  for (const key of ['shell', 'write', 'network']) {
1088
1510
  const spec = PERM_INFO[key];
1089
- const row = document.createElement('div');
1090
- row.className = 'perm-row';
1091
- row.dataset.key = key;
1092
- const seg = document.createElement('div');
1093
- seg.className = 'seg';
1094
- seg.innerHTML = ['ask', 'allow', 'deny']
1095
- .map((mode) => {
1096
- const label = mode === 'ask' ? '每次问' : mode === 'allow' ? '直接执行' : '禁止';
1097
- return `<button data-mode="${mode}" class="${info.permissions[key] === mode ? 'is-on' : ''}">${label}</button>`;
1098
- })
1099
- .join('');
1100
- seg.addEventListener('click', async (event) => {
1101
- const button = event.target.closest('[data-mode]');
1102
- if (!button) return;
1103
- const mode = button.dataset.mode;
1104
- // 立即反馈,失败再回滚并提示,避免看起来"点不动"
1105
- seg.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item === button));
1106
- const detail = row.querySelector('.perm-detail');
1107
- if (detail) detail.textContent = spec.modes[mode];
1511
+ permCard.appendChild(
1512
+ settingsRow(
1513
+ spec.name,
1514
+ spec.hint,
1515
+ settingsSeg(
1516
+ [
1517
+ { value: 'ask', label: '每次问' },
1518
+ { value: 'allow', label: '允许' },
1519
+ { value: 'deny', label: '禁止' },
1520
+ ],
1521
+ info.permissions[key],
1522
+ async (mode) => {
1523
+ await saveSetting('permission', { name: key, value: mode });
1524
+ await refresh(false);
1525
+ toast(`「${spec.name}」已设为:${mode === 'ask' ? '每次问' : mode === 'allow' ? '允许' : '禁止'}`);
1526
+ },
1527
+ ),
1528
+ ),
1529
+ );
1530
+ }
1531
+ permSection.appendChild(permCard);
1532
+ body.append(section, permSection);
1533
+ }
1534
+
1535
+ function pluginBadge(text) {
1536
+ const badge = document.createElement('span');
1537
+ badge.className = 'plugin-badge';
1538
+ badge.textContent = text;
1539
+ return badge;
1540
+ }
1541
+
1542
+ function renderPluginSettings(body) {
1543
+ const section = settingsSection('插件工坊', '插件就是 .mjs 文件。可以生成模板、查看错误、试运行和启用停用。');
1544
+
1545
+ const actions = document.createElement('div');
1546
+ actions.className = 'settings-actions';
1547
+ actions.style.justifyContent = 'flex-start';
1548
+ actions.style.marginBottom = '12px';
1549
+ actions.append(
1550
+ settingsButton('重新加载', async () => {
1108
1551
  try {
1109
- await post('action', { action: 'permission', name: key, value: mode });
1552
+ state.plugins = await post('plugins', { action: 'reload' });
1110
1553
  await refresh(false);
1111
- toast(`「${spec.name}」已设为:${mode === 'ask' ? '每次问' : mode === 'allow' ? '直接执行' : '禁止'}`);
1554
+ renderSettings();
1555
+ toast('插件已重新加载');
1112
1556
  } catch (error) {
1113
- seg.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item.dataset.mode !== mode));
1114
- if (detail) detail.textContent = spec.modes[info.permissions[key]];
1115
- toast('保存失败:' + error.message);
1557
+ toast('重载失败:' + (error?.message ?? error));
1116
1558
  }
1117
- });
1559
+ }, true),
1560
+ settingsButton('新建模板', async () => {
1561
+ try {
1562
+ const result = await post('plugins', { action: 'create' });
1563
+ state.plugins = result;
1564
+ await refresh(false);
1565
+ renderSettings();
1566
+ toast('已创建 ' + result.file);
1567
+ } catch (error) {
1568
+ toast('创建失败:' + (error?.message ?? error));
1569
+ }
1570
+ }),
1571
+ settingsButton('打开全局目录', () => {
1572
+ post('plugins', { action: 'open', scope: 'global' }).catch((error) => toast('打开失败:' + error.message));
1573
+ }),
1574
+ settingsButton('打开项目目录', () => {
1575
+ post('plugins', { action: 'open', scope: 'project' }).catch((error) => toast('打开失败:' + error.message));
1576
+ }),
1577
+ );
1578
+ section.appendChild(actions);
1579
+
1580
+ const list = document.createElement('div');
1581
+ list.innerHTML = '<div class="plugin-empty">读取插件中…</div>';
1582
+ section.appendChild(list);
1583
+ body.appendChild(section);
1584
+
1585
+ void (async () => {
1586
+ let data;
1587
+ try {
1588
+ data = await get('plugins');
1589
+ state.plugins = data;
1590
+ } catch (error) {
1591
+ list.innerHTML = `<div class="plugin-empty">${escapeHtml(error.message)}</div>`;
1592
+ return;
1593
+ }
1594
+ if (state.settingsCategory !== 'plugins') return;
1595
+ list.innerHTML = '';
1596
+ if (!data.plugins?.length) {
1597
+ const empty = document.createElement('div');
1598
+ empty.className = 'plugin-empty';
1599
+ empty.textContent = '还没有插件。点「新建模板」生成第一个,或把 .mjs 放进插件目录。';
1600
+ list.appendChild(empty);
1601
+ }
1602
+ for (const plugin of data.plugins ?? []) {
1603
+ list.appendChild(renderPluginCard(plugin));
1604
+ }
1605
+ const dirs = document.createElement('div');
1606
+ dirs.className = 'hint';
1607
+ dirs.style.marginTop = '12px';
1608
+ dirs.textContent = '全局:' + (data.dirs?.find((dir) => dir.id === 'global')?.path ?? '') + ' 项目:' + (data.dirs?.find((dir) => dir.id === 'project')?.path ?? '');
1609
+ list.appendChild(dirs);
1610
+ })();
1611
+ }
1118
1612
 
1119
- const block = document.createElement('div');
1120
- block.className = 'perm-block';
1121
- const head = document.createElement('div');
1122
- head.className = 'perm-head';
1123
- head.innerHTML = `<span class="perm-name">${escapeHtml(spec.name)}</span>`;
1124
- head.appendChild(seg);
1125
- const desc = document.createElement('div');
1126
- desc.className = 'hint';
1127
- desc.textContent = spec.hint;
1128
- const detail = document.createElement('div');
1129
- detail.className = 'perm-detail';
1130
- detail.textContent = spec.modes[info.permissions[key]];
1131
- block.append(head, desc, detail);
1132
- row.appendChild(block);
1133
- permField.appendChild(row);
1613
+ function renderPluginCard(plugin) {
1614
+ const card = document.createElement('div');
1615
+ card.className = `plugin-card${plugin.enabled ? '' : ' is-off'}`;
1616
+ const main = document.createElement('div');
1617
+ main.className = 'plugin-main';
1618
+ const name = document.createElement('div');
1619
+ name.className = 'plugin-name';
1620
+ name.textContent = plugin.name || plugin.file.split(/[\\/]/).pop();
1621
+ name.appendChild(pluginBadge(plugin.ok ? (plugin.disabled ? '已停用' : '已加载') : '加载失败'));
1622
+ if (plugin.approval && plugin.approval !== 'never') name.appendChild(pluginBadge(plugin.approval));
1623
+ main.appendChild(name);
1624
+ const desc = document.createElement('div');
1625
+ desc.className = 'plugin-desc';
1626
+ desc.textContent = plugin.description || (plugin.ok ? '没有填写说明。' : '插件文件存在错误,模型暂时无法调用。');
1627
+ main.appendChild(desc);
1628
+ const file = document.createElement('div');
1629
+ file.className = 'plugin-file';
1630
+ file.textContent = plugin.file;
1631
+ main.appendChild(file);
1632
+ if (plugin.error) {
1633
+ const error = document.createElement('div');
1634
+ error.className = 'plugin-error';
1635
+ error.textContent = plugin.error;
1636
+ main.appendChild(error);
1134
1637
  }
1135
- const note = document.createElement('div');
1136
- note.className = 'hint perm-note';
1137
- note.textContent = '改动立刻生效,并保存在配置里(CLI 和 GUI 共用同一份设置)。不确定就保持「每次问」。';
1138
- permField.appendChild(note);
1139
- body.appendChild(permField);
1140
-
1141
- const toolsField = document.createElement('div');
1142
- toolsField.className = 'field';
1143
- toolsField.innerHTML =
1144
- '<label>工具</label><div class="hint">' +
1145
- info.tools.map((tool) => `${escapeHtml(tool.name)} <span style="opacity:.6">${tool.permission}</span>`).join('、') +
1146
- '</div>';
1147
- body.appendChild(toolsField);
1148
-
1149
- const sysField = document.createElement('div');
1150
- sysField.className = 'field';
1151
- sysField.innerHTML = '<label>系统提示词</label>';
1152
- const textarea = document.createElement('textarea');
1153
- textarea.rows = 6;
1154
- textarea.style.cssText = 'border:1px solid var(--line);border-radius:9px;padding:8px 10px;font-size:13px;resize:vertical';
1155
- textarea.value = state.systemPrompt ?? '';
1156
- sysField.append(textarea);
1157
- const saveRow = document.createElement('div');
1158
- saveRow.innerHTML = '<button class="btn">保存提示词</button>';
1159
- saveRow.querySelector('button').addEventListener('click', async () => {
1160
- await post('action', { action: 'system', value: textarea.value });
1161
- toast('已保存');
1162
- });
1163
- sysField.append(saveRow);
1164
- body.appendChild(sysField);
1638
+
1639
+ const acts = document.createElement('div');
1640
+ acts.className = 'plugin-actions';
1641
+ if (plugin.ok) {
1642
+ acts.appendChild(
1643
+ settingsSwitch(plugin.enabled, async (on) => {
1644
+ try {
1645
+ const result = await post('plugins', { action: 'toggle', name: plugin.name, disabled: !on });
1646
+ state.plugins = result;
1647
+ await refresh(false);
1648
+ renderSettings();
1649
+ toast(on ? `已启用 ${plugin.name}` : `已停用 ${plugin.name}`);
1650
+ } catch (error) {
1651
+ toast('操作失败:' + (error?.message ?? error));
1652
+ renderSettings();
1653
+ }
1654
+ }),
1655
+ );
1656
+ const test = settingsButton('试运行', () => {
1657
+ const existing = card.querySelector('.plugin-test');
1658
+ if (existing) {
1659
+ existing.remove();
1660
+ return;
1661
+ }
1662
+ const box = document.createElement('div');
1663
+ box.className = 'plugin-test';
1664
+ box.style.cssText = 'margin-top:10px;display:flex;flex-direction:column;gap:6px';
1665
+ const area = document.createElement('textarea');
1666
+ area.className = 'plugin-json';
1667
+ area.value = '{\n "text": "hello"\n}';
1668
+ const run = settingsButton('运行', async () => {
1669
+ output.textContent = '运行中…';
1670
+ try {
1671
+ const result = await post('plugins', { action: 'test', file: plugin.file, input: area.value });
1672
+ output.textContent = result.result ?? '(无输出)';
1673
+ } catch (error) {
1674
+ output.textContent = '失败:' + (error?.message ?? error);
1675
+ }
1676
+ }, true);
1677
+ const output = document.createElement('pre');
1678
+ output.className = 'plugin-error';
1679
+ output.textContent = '输入 JSON,然后点运行。';
1680
+ box.append(area, run, output);
1681
+ main.appendChild(box);
1682
+ });
1683
+ acts.appendChild(test);
1684
+ }
1685
+ card.append(main, acts);
1686
+ return card;
1687
+ }
1688
+
1689
+ function renderAboutSettings(body) {
1690
+ const info = state.info;
1691
+ const section = settingsSection('关于 Banana', '一个把 CLI 内核完整搬进图形界面的本地 agent。');
1692
+ const card = settingsCard();
1693
+ card.appendChild(settingsRow('版本', 'CLI 与 GUI 使用同一套 agent 循环和工具。', pluginBadge('v' + (info.version ?? '0.6.0'))));
1694
+ card.appendChild(settingsRow('工作区', info.workspace ?? '', null));
1695
+ card.appendChild(settingsRow('工具数量', `${info.tools?.length ?? 0} 个`, null));
1696
+ card.appendChild(settingsRow('平台', info.platform === 'win32' ? 'Windows' : info.platform === 'darwin' ? 'macOS' : 'Linux', null));
1697
+ section.appendChild(card);
1698
+
1699
+ const tip = settingsCard();
1700
+ tip.appendChild(settingsRow('命令中心', '按 Ctrl+K 搜索会话、文件、动作和设置。', settingsButton('打开', () => { closeSettings(); openCommandCenter(); })));
1701
+ tip.appendChild(settingsRow('项目文件', '代码支持行号、全屏查看和选中后问 AI。', settingsButton('打开', () => { closeSettings(); openFiles(); })));
1702
+ section.appendChild(tip);
1703
+ body.appendChild(section);
1165
1704
  }
1166
1705
 
1167
1706
  // ---------------- 文件面板 ----------------
1707
+
1708
+
1168
1709
  const FILE_ICON = { dir: 'folder', file: 'file' };
1710
+ const FILE_STORAGE = { width: 'banana.files.width', tree: 'banana.files.tree', wrap: 'banana.files.wrap' };
1169
1711
 
1170
1712
  async function loadTree(path = '.', container = $('filesTree')) {
1171
1713
  container.innerHTML = '<div class="hint" style="padding:8px 10px">读取中…</div>';
@@ -1179,6 +1721,7 @@ async function loadTree(path = '.', container = $('filesTree')) {
1179
1721
  entries.forEach((entry, index) => {
1180
1722
  const row = document.createElement('button');
1181
1723
  row.className = 'file-row';
1724
+ row.dataset.path = entry.path;
1182
1725
  row.style.setProperty('--i', String(Math.min(index, 14)));
1183
1726
  row.innerHTML =
1184
1727
  `<span class="tool-caret">${entry.type === 'dir' ? svg('chevron') : ''}</span>` +
@@ -1208,33 +1751,141 @@ async function loadTree(path = '.', container = $('filesTree')) {
1208
1751
  }
1209
1752
  }
1210
1753
 
1754
+ function applyFilesLayout() {
1755
+ const panel = $('filesPanel');
1756
+ const width = Number(localStorage.getItem(FILE_STORAGE.width));
1757
+ if (width) panel.style.setProperty('--files-width', `${Math.max(340, Math.min(width, window.innerWidth * 0.92))}px`);
1758
+ const tree = Number(localStorage.getItem(FILE_STORAGE.tree));
1759
+ if (tree) panel.style.setProperty('--tree-height', `${tree}px`);
1760
+ const wrap = localStorage.getItem(FILE_STORAGE.wrap) === '1';
1761
+ $('previewBody').classList.toggle('is-wrap', wrap);
1762
+ $('viewerBody').classList.toggle('is-wrap', wrap);
1763
+ $('previewWrap').classList.toggle('is-on', wrap);
1764
+ $('viewerWrap').classList.toggle('is-on', wrap);
1765
+ }
1766
+
1767
+ function openFiles() {
1768
+ const panel = $('filesPanel');
1769
+ panel.classList.remove('is-closing');
1770
+ panel.hidden = false;
1771
+ applyFilesLayout();
1772
+ document.body.classList.add('has-overlay');
1773
+ $('filesTitle').textContent = `项目文件 · ${state.info?.workspace ?? ''}`;
1774
+ if (!$('filesTree').childElementCount) void loadTree('.');
1775
+ }
1776
+
1777
+ function closeFiles() {
1778
+ const panel = $('filesPanel');
1779
+ if (panel.hidden) return;
1780
+ void hideAnimated(panel, 'is-closing', 170).then(() => {
1781
+ syncOverlayState();
1782
+ $('input')?.focus();
1783
+ });
1784
+ }
1785
+
1786
+ function toggleFiles() {
1787
+ if ($('filesPanel').hidden) openFiles();
1788
+ else closeFiles();
1789
+ }
1790
+
1791
+ function closePreview() {
1792
+ const panel = $('filesPanel');
1793
+ const box = $('filesPreview');
1794
+ if (box.hidden) return;
1795
+ panel.classList.remove('previewing');
1796
+ box.hidden = true;
1797
+ state.preview = null;
1798
+ }
1799
+
1211
1800
  async function openFile(path) {
1212
1801
  const box = $('filesPreview');
1213
1802
  const body = $('previewBody');
1803
+ $('filesPanel').classList.add('previewing');
1214
1804
  box.hidden = false;
1215
1805
  $('previewName').textContent = path;
1216
- body.textContent = '读取中…';
1806
+ $('insertPath').dataset.path = path;
1807
+ body.innerHTML = '<div class="hint" style="padding:14px">读取中…</div>';
1808
+ document.querySelectorAll('.file-row.is-active').forEach((node) => node.classList.remove('is-active'));
1809
+ document.querySelectorAll('.file-row').forEach((node) => {
1810
+ if (node.dataset.path === path) node.classList.add('is-active');
1811
+ });
1217
1812
  try {
1218
1813
  const data = await get(`file?path=${encodeURIComponent(path)}`);
1219
- $('insertPath').dataset.path = path;
1220
- if (data.binary) body.textContent = `二进制文件(${data.bytes} 字节),无法预览。`;
1221
- else if (data.tooLarge) body.textContent = `文件较大(${data.bytes} 字节),未加载。`;
1222
- else highlightPreview(body, path, data.content ?? '');
1223
- document.querySelectorAll('.file-row.is-active').forEach((node) => node.classList.remove('is-active'));
1814
+ state.preview = { path, code: data.content ?? '', binary: Boolean(data.binary), tooLarge: Boolean(data.tooLarge) };
1815
+ if (data.binary) {
1816
+ body.innerHTML = `<div class="hint" style="padding:14px">二进制文件(${data.bytes} 字节),无法预览。</div>`;
1817
+ } else if (data.tooLarge) {
1818
+ body.innerHTML = `<div class="hint" style="padding:14px">文件较大(${data.bytes} 字节),未加载。</div>`;
1819
+ } else {
1820
+ renderCode(body, data.content ?? '', path);
1821
+ }
1224
1822
  } catch (error) {
1225
- body.textContent = error.message;
1823
+ body.innerHTML = `<div class="hint" style="padding:14px">${escapeHtml(error.message)}</div>`;
1226
1824
  }
1227
1825
  }
1228
1826
 
1229
- async function toggleFiles() {
1827
+ function openViewer() {
1828
+ const preview = state.preview;
1829
+ if (!preview?.code) return toast('没有可查看的代码');
1830
+ $('viewerName').textContent = preview.path;
1831
+ renderCode($('viewerBody'), preview.code, preview.path);
1832
+ applyFilesLayout();
1833
+ $('codeViewer').classList.remove('is-closing');
1834
+ $('codeViewer').hidden = false;
1835
+ document.body.classList.add('has-overlay');
1836
+ }
1837
+
1838
+ function closeViewer() {
1839
+ const viewer = $('codeViewer');
1840
+ if (viewer.hidden) return;
1841
+ void hideAnimated(viewer, 'is-closing', 160).then(syncOverlayState);
1842
+ }
1843
+
1844
+ function toggleWrap() {
1845
+ const on = !$('previewBody').classList.contains('is-wrap');
1846
+ $('previewBody').classList.toggle('is-wrap', on);
1847
+ $('viewerBody').classList.toggle('is-wrap', on);
1848
+ $('previewWrap').classList.toggle('is-on', on);
1849
+ $('viewerWrap').classList.toggle('is-on', on);
1850
+ localStorage.setItem(FILE_STORAGE.wrap, on ? '1' : '0');
1851
+ }
1852
+
1853
+ function initFileResize() {
1230
1854
  const panel = $('filesPanel');
1231
- panel.hidden = !panel.hidden;
1232
- if (!panel.hidden && !$('filesTree').childElementCount) {
1233
- $('filesTitle').textContent = `项目文件 · ${state.info?.workspace ?? ''}`;
1234
- await loadTree('.');
1235
- }
1855
+ $('filesGrip').addEventListener('pointerdown', (event) => {
1856
+ event.preventDefault();
1857
+ const move = (moveEvent) => {
1858
+ const width = Math.max(320, Math.min(window.innerWidth - 80, window.innerWidth - moveEvent.clientX));
1859
+ panel.style.setProperty('--files-width', `${width}px`);
1860
+ };
1861
+ const up = () => {
1862
+ window.removeEventListener('pointermove', move);
1863
+ window.removeEventListener('pointerup', up);
1864
+ localStorage.setItem(FILE_STORAGE.width, String(Math.round(panel.getBoundingClientRect().width)));
1865
+ };
1866
+ window.addEventListener('pointermove', move);
1867
+ window.addEventListener('pointerup', up);
1868
+ });
1869
+
1870
+ $('filesDivider').addEventListener('pointerdown', (event) => {
1871
+ event.preventDefault();
1872
+ const split = $('filesSplit');
1873
+ const move = (moveEvent) => {
1874
+ const rect = split.getBoundingClientRect();
1875
+ const height = Math.max(76, Math.min(rect.height - 140, moveEvent.clientY - rect.top));
1876
+ panel.style.setProperty('--tree-height', `${height}px`);
1877
+ };
1878
+ const up = () => {
1879
+ window.removeEventListener('pointermove', move);
1880
+ window.removeEventListener('pointerup', up);
1881
+ localStorage.setItem(FILE_STORAGE.tree, String(Math.round($('filesTree').getBoundingClientRect().height)));
1882
+ };
1883
+ window.addEventListener('pointermove', move);
1884
+ window.addEventListener('pointerup', up);
1885
+ });
1236
1886
  }
1237
1887
 
1888
+
1238
1889
  // ---------------- 工具输出详情 ----------------
1239
1890
  function toggleToolOutput(row, detail, isError) {
1240
1891
  const existing = row.nextElementSibling;
@@ -1251,17 +1902,6 @@ function toggleToolOutput(row, detail, isError) {
1251
1902
  }
1252
1903
 
1253
1904
  // ---------------- 启动 ----------------
1254
- function openSettings() {
1255
- renderSettings();
1256
- $('settingsSheet').hidden = false;
1257
- }
1258
-
1259
- function closeSettings() {
1260
- $('settingsSheet').hidden = true;
1261
- // 收掉动画后再清空内容,避免关闭瞬间出现内容跳动
1262
- setTimeout(() => { if ($('settingsSheet').hidden) $('settingsBody').innerHTML = ''; }, 240);
1263
- }
1264
-
1265
1905
  // ---------------- 内嵌终端(xterm + PTY) ----------------
1266
1906
  const TERM_FONT = '"Cascadia Mono", "JetBrains Mono", "SF Mono", Consolas, "Microsoft YaHei", monospace';
1267
1907
  const TERM_STORAGE = {
@@ -1337,9 +1977,8 @@ function termPersistSize() {
1337
1977
  function termApplyDock(dock, persist = true) {
1338
1978
  term.dock = dock;
1339
1979
  const panel = $('terminalPanel');
1340
- const workspace = $('workspace');
1980
+ const main = $('main');
1341
1981
  panel.dataset.dock = dock;
1342
- workspace.dataset.dock = dock;
1343
1982
  panel.style.removeProperty('left');
1344
1983
  panel.style.removeProperty('top');
1345
1984
  panel.style.removeProperty('right');
@@ -1347,17 +1986,23 @@ function termApplyDock(dock, persist = true) {
1347
1986
  panel.style.width = '';
1348
1987
  panel.style.height = '';
1349
1988
  if (dock === 'bottom' || dock === 'top') {
1989
+ const max = Math.max(180, main.clientHeight - 150);
1990
+ term.size = Math.min(Math.max(180, term.size), max);
1350
1991
  panel.style.height = `${term.size}px`;
1351
1992
  } else if (dock === 'left' || dock === 'right') {
1993
+ const max = Math.max(300, main.clientWidth - 320);
1994
+ term.size = Math.min(Math.max(260, term.size), max);
1352
1995
  panel.style.width = `${term.size}px`;
1353
1996
  } else {
1354
- panel.style.width = `${Math.min(680, Math.max(360, Math.round(workspace.clientWidth * 0.62)))}px`;
1355
- panel.style.height = `${Math.min(420, Math.max(220, Math.round(workspace.clientHeight * 0.55)))}px`;
1356
- const saved = termReadPos();
1357
- if (saved) {
1358
- panel.style.left = `${saved.x}px`;
1359
- panel.style.top = `${saved.y}px`;
1360
- }
1997
+ const width = Math.min(720, Math.max(380, Math.round(main.clientWidth * 0.64)));
1998
+ const height = Math.min(460, Math.max(240, Math.round(main.clientHeight * 0.56)));
1999
+ panel.style.width = `${width}px`;
2000
+ panel.style.height = `${height}px`;
2001
+ const saved = termReadPos() ?? { x: Math.max(24, main.clientWidth - width - 40), y: Math.max(20, main.clientHeight - height - 40) };
2002
+ const left = Math.max(8, Math.min(saved.x, main.clientWidth - width - 8));
2003
+ const top = Math.max(8, Math.min(saved.y, main.clientHeight - height - 8));
2004
+ panel.style.left = `${left}px`;
2005
+ panel.style.top = `${top}px`;
1361
2006
  }
1362
2007
  if (persist) termPersistSize();
1363
2008
  requestAnimationFrame(() => {
@@ -1543,13 +2188,15 @@ function termCreateTab(shellId) {
1543
2188
  terminal.onResize(() => tab.sendResize());
1544
2189
  termRenderTabs();
1545
2190
  termActivate(tab.id);
2191
+ // 先连 WebSocket(不依赖页面可见性),再等布局完成 fit
2192
+ termConnect(tab);
1546
2193
  requestAnimationFrame(() => {
1547
2194
  try {
1548
2195
  fit.fit();
2196
+ tab.sendResize();
1549
2197
  } catch {
1550
2198
  /* 忽略 */
1551
2199
  }
1552
- termConnect(tab);
1553
2200
  });
1554
2201
  return tab;
1555
2202
  }
@@ -1590,18 +2237,22 @@ function termOpen() {
1590
2237
  if (!term.open) {
1591
2238
  term.open = true;
1592
2239
  panel.hidden = false;
2240
+ document.body.classList.add('has-overlay');
1593
2241
  requestAnimationFrame(() => panel.classList.add('show'));
1594
2242
  }
1595
2243
  const ensure = () => {
1596
2244
  if (term.tabs.length === 0) termCreateTab($('termShell')?.value || term.defaultShell);
1597
2245
  else termActivate(term.activeId);
2246
+ setTimeout(() => termActiveTab()?.terminal.focus(), 180);
1598
2247
  };
1599
2248
  if (term.loadedShells) ensure();
1600
2249
  else termLoadShells().then(ensure);
1601
2250
  termUpdateCwd(state.info?.workspace ?? '');
2251
+ syncOverlayState();
1602
2252
  }
1603
2253
 
1604
2254
  function termClose() {
2255
+ if (!term.open) return;
1605
2256
  term.open = false;
1606
2257
  const panel = $('terminalPanel');
1607
2258
  panel.classList.remove('show');
@@ -1610,6 +2261,8 @@ function termClose() {
1610
2261
  if (term.open) return;
1611
2262
  panel.hidden = true;
1612
2263
  panel.classList.remove('closing');
2264
+ syncOverlayState();
2265
+ $('input')?.focus();
1613
2266
  }, 180);
1614
2267
  }
1615
2268
 
@@ -1639,8 +2292,10 @@ function bindTerminal() {
1639
2292
  $('termClear').innerHTML = svg('trash');
1640
2293
  $('termClose').innerHTML = svg('x');
1641
2294
  $('termDock').innerHTML = svg('panel');
2295
+ $('termAnalyze').innerHTML = svg('search');
1642
2296
  $('termBtn').addEventListener('click', termToggle);
1643
2297
  $('termClose').addEventListener('click', termClose);
2298
+ $('termAnalyze').addEventListener('click', analyzeTerminal);
1644
2299
  $('termAdd').addEventListener('click', () => {
1645
2300
  termCreateTab($('termShell')?.value || term.defaultShell);
1646
2301
  });
@@ -1656,8 +2311,8 @@ function bindTerminal() {
1656
2311
  const grip = $('termGrip');
1657
2312
  grip.addEventListener('pointerdown', (event) => {
1658
2313
  event.preventDefault();
1659
- const workspace = $('workspace');
1660
- const rect = workspace.getBoundingClientRect();
2314
+ const main = $('main');
2315
+ const rect = main.getBoundingClientRect();
1661
2316
  const move = (moveEvent) => {
1662
2317
  let size;
1663
2318
  if (term.dock === 'bottom') size = rect.bottom - moveEvent.clientY;
@@ -1682,8 +2337,8 @@ function bindTerminal() {
1682
2337
  panel.addEventListener('pointerdown', (event) => {
1683
2338
  if (term.dock !== 'float') return;
1684
2339
  if (event.target.closest('button, select, .term-tab, .term-grip')) return;
1685
- const workspace = $('workspace');
1686
- const rect = workspace.getBoundingClientRect();
2340
+ const main = $('main');
2341
+ const rect = main.getBoundingClientRect();
1687
2342
  const panelRect = panel.getBoundingClientRect();
1688
2343
  const offsetX = event.clientX - panelRect.left;
1689
2344
  const offsetY = event.clientY - panelRect.top;
@@ -1714,7 +2369,7 @@ function bindTerminal() {
1714
2369
  const observer = new ResizeObserver(() => {
1715
2370
  if (term.open) termActiveTab()?.fit.fit();
1716
2371
  });
1717
- observer.observe($('workspace'));
2372
+ observer.observe($('main'));
1718
2373
 
1719
2374
  document.addEventListener('banana:theme', () => {
1720
2375
  if (term.tabs.length) termRestyle();
@@ -1722,6 +2377,323 @@ function bindTerminal() {
1722
2377
  termUpdateCwd(state.info?.workspace ?? '');
1723
2378
  }
1724
2379
 
2380
+ // ---------------- 命令中心(Ctrl+K) ----------------
2381
+ const CC_GROUPS = ['会话', '动作', '设置', '命令', '文件'];
2382
+
2383
+ function ccActions() {
2384
+ return [
2385
+ { title: '新建对话', sub: '开始一个干净的会话', icon: 'plus', run: () => $('newChat').click() },
2386
+ { title: '打开项目文件', sub: '浏览、全屏查看、引用文件', icon: 'panel', run: openFiles },
2387
+ { title: '打开终端', sub: 'Ctrl+` 随时开关,底部覆盖不挤压对话', icon: 'terminal', run: termOpen },
2388
+ { title: '打开控制中心', sub: '模型、外观、终端、工作区、插件', icon: 'sliders', run: () => openSettings() },
2389
+ { title: '插件工坊', sub: '启用、停用、生成模板、试运行插件', icon: 'plug', run: () => openSettings('plugins') },
2390
+ { title: '切换主题', sub: '浅色 / 深色 / 跟随系统', icon: 'sun', run: cycleTheme },
2391
+ { title: '撤销上一轮改动', sub: '把文件恢复到上一轮对话之前', icon: 'undo', run: () => $('undoBtn').click() },
2392
+ ];
2393
+ }
2394
+
2395
+ function openCommandCenter() {
2396
+ const cc = $('commandCenter');
2397
+ if (!cc.hidden && !cc.classList.contains('is-closing')) {
2398
+ $('ccInput').select();
2399
+ return;
2400
+ }
2401
+ if (!$('settingsSheet').hidden) closeSettings();
2402
+ state.ccIndex = 0;
2403
+ state.ccFiles = [];
2404
+ $('ccInput').value = '';
2405
+ cc.classList.remove('is-closing');
2406
+ cc.hidden = false;
2407
+ renderCommandCenter('');
2408
+ syncOverlayState();
2409
+ requestAnimationFrame(() => {
2410
+ $('ccInput').focus();
2411
+ $('ccInput').select();
2412
+ });
2413
+ }
2414
+
2415
+ function closeCommandCenter() {
2416
+ const cc = $('commandCenter');
2417
+ if (cc.hidden) return;
2418
+ void hideAnimated(cc, 'is-closing', 140).then(syncOverlayState);
2419
+ $('input')?.focus();
2420
+ }
2421
+
2422
+ let ccFileTimer = 0;
2423
+ let ccFileSeq = 0;
2424
+ function scheduleCommandFiles(query) {
2425
+ clearTimeout(ccFileTimer);
2426
+ if (!query.trim()) return;
2427
+ const seq = ++ccFileSeq;
2428
+ ccFileTimer = setTimeout(async () => {
2429
+ try {
2430
+ const data = await get(`find?q=${encodeURIComponent(query.trim())}&limit=24`);
2431
+ if (seq !== ccFileSeq || $('commandCenter').hidden) return;
2432
+ state.ccFiles = data.files ?? [];
2433
+ renderCommandCenter($('ccInput').value);
2434
+ } catch {
2435
+ /* 文件搜索失败不打扰主流程 */
2436
+ }
2437
+ }, 150);
2438
+ }
2439
+
2440
+ function renderCommandCenter(query) {
2441
+ const q = query.trim().toLowerCase();
2442
+ const hit = (text) => !q || String(text ?? '').toLowerCase().includes(q);
2443
+ const items = [];
2444
+
2445
+ const sessions = state.sessions
2446
+ .filter((item) => hit(item.title) || hit('会话'))
2447
+ .slice(0, q ? 5 : 4)
2448
+ .map((item) => ({
2449
+ group: '会话',
2450
+ title: item.title || '新对话',
2451
+ sub: `${item.turns} 轮 · ${relativeTime(item.updatedAt)}`,
2452
+ icon: 'message',
2453
+ run: () => switchSession(item.id),
2454
+ }));
2455
+ items.push(...sessions);
2456
+
2457
+ for (const action of ccActions()) {
2458
+ if (hit(action.title) || hit(action.sub)) items.push({ ...action, group: '动作' });
2459
+ }
2460
+
2461
+ for (const nav of SETTINGS_NAV) {
2462
+ const title = `设置 · ${nav.label}`;
2463
+ if (hit(title)) {
2464
+ items.push({ group: '设置', title, sub: '打开控制中心对应的分类', icon: nav.icon, run: () => openSettings(nav.id) });
2465
+ }
2466
+ }
2467
+
2468
+ for (const command of commandList) {
2469
+ const title = `/${command.name}`;
2470
+ if (hit(title) || hit(command.summary)) {
2471
+ items.push({
2472
+ group: '命令',
2473
+ title,
2474
+ sub: command.summary,
2475
+ icon: 'command',
2476
+ run: () => {
2477
+ insertComposer(`/${command.name} `);
2478
+ $('input').focus();
2479
+ },
2480
+ });
2481
+ }
2482
+ }
2483
+
2484
+ for (const path of state.ccFiles ?? []) {
2485
+ const name = path.split('/').pop();
2486
+ items.push({
2487
+ group: '文件',
2488
+ title: name,
2489
+ sub: path,
2490
+ icon: 'file',
2491
+ run: () => {
2492
+ openFiles();
2493
+ void openFile(path);
2494
+ },
2495
+ });
2496
+ }
2497
+
2498
+ state.ccItems = items;
2499
+ if (!items.length) {
2500
+ $('ccResults').innerHTML = '<div class="cc-empty">没有匹配结果,换个关键词试试。</div>';
2501
+ state.ccIndex = 0;
2502
+ return;
2503
+ }
2504
+ state.ccIndex = Math.max(0, Math.min(state.ccIndex, items.length - 1));
2505
+ paintCommandCenter(items);
2506
+ }
2507
+
2508
+ function paintCommandCenter(items) {
2509
+ const box = $('ccResults');
2510
+ box.innerHTML = '';
2511
+ let group = '';
2512
+ items.forEach((item, index) => {
2513
+ if (item.group !== group) {
2514
+ group = item.group;
2515
+ const head = document.createElement('div');
2516
+ head.className = 'cc-group';
2517
+ head.textContent = group;
2518
+ box.appendChild(head);
2519
+ }
2520
+ const button = document.createElement('button');
2521
+ button.type = 'button';
2522
+ button.className = `cc-item${index === state.ccIndex ? ' is-on' : ''}`;
2523
+ button.innerHTML =
2524
+ `<span class="cc-item-icon">${svg(item.icon)}</span>` +
2525
+ `<span class="cc-item-main"><span class="cc-item-title">${escapeHtml(item.title)}</span>` +
2526
+ `${item.sub ? `<span class="cc-item-sub">${escapeHtml(item.sub)}</span>` : ''}</span>`;
2527
+ button.addEventListener('pointerenter', () => {
2528
+ if (state.ccIndex === index) return;
2529
+ state.ccIndex = index;
2530
+ box.querySelectorAll('.cc-item').forEach((node, i) => node.classList.toggle('is-on', i === index));
2531
+ });
2532
+ button.addEventListener('click', () => runCommandCenterItem(item));
2533
+ box.appendChild(button);
2534
+ });
2535
+ scrollCommandCenterActive();
2536
+ }
2537
+
2538
+ function scrollCommandCenterActive() {
2539
+ const box = $('ccResults');
2540
+ const active = box.querySelectorAll('.cc-item')[state.ccIndex];
2541
+ active?.scrollIntoView({ block: 'nearest' });
2542
+ }
2543
+
2544
+ function moveCommandCenter(delta) {
2545
+ if (!state.ccItems?.length) return;
2546
+ state.ccIndex = (state.ccIndex + delta + state.ccItems.length) % state.ccItems.length;
2547
+ const buttons = $('ccResults').querySelectorAll('.cc-item');
2548
+ buttons.forEach((node, index) => node.classList.toggle('is-on', index === state.ccIndex));
2549
+ scrollCommandCenterActive();
2550
+ }
2551
+
2552
+ function runCommandCenterItem(item) {
2553
+ if (!item) return;
2554
+ closeCommandCenter();
2555
+ setTimeout(() => item.run(), 60);
2556
+ }
2557
+
2558
+ // ---------------- 悬停提示 ----------------
2559
+ function initTooltips() {
2560
+ const tip = document.createElement('div');
2561
+ tip.className = 'tooltip';
2562
+ tip.hidden = true;
2563
+ document.body.appendChild(tip);
2564
+ let timer = 0;
2565
+ let target = null;
2566
+
2567
+ const place = () => {
2568
+ if (!target) return;
2569
+ const rect = target.getBoundingClientRect();
2570
+ const box = tip.getBoundingClientRect();
2571
+ const x = Math.max(8, Math.min(rect.left + rect.width / 2 - box.width / 2, window.innerWidth - box.width - 8));
2572
+ const below = rect.bottom + 9;
2573
+ const y = below + box.height > window.innerHeight - 8 ? Math.max(8, rect.top - box.height - 9) : below;
2574
+ tip.style.left = `${x}px`;
2575
+ tip.style.top = `${y}px`;
2576
+ };
2577
+
2578
+ const hide = () => {
2579
+ clearTimeout(timer);
2580
+ target = null;
2581
+ tip.classList.remove('is-on');
2582
+ setTimeout(() => {
2583
+ if (!tip.classList.contains('is-on')) tip.hidden = true;
2584
+ }, 120);
2585
+ };
2586
+
2587
+ document.addEventListener('pointerover', (event) => {
2588
+ const node = event.target.closest?.('[title], [data-tip]');
2589
+ if (!node) return;
2590
+ const title = node.getAttribute('title');
2591
+ if (title) {
2592
+ node.dataset.tip = title;
2593
+ node.removeAttribute('title');
2594
+ }
2595
+ const text = node.dataset.tip;
2596
+ if (!text) return;
2597
+ clearTimeout(timer);
2598
+ target = node;
2599
+ timer = setTimeout(() => {
2600
+ if (target !== node) return;
2601
+ tip.textContent = text;
2602
+ tip.hidden = false;
2603
+ tip.classList.remove('is-on');
2604
+ requestAnimationFrame(() => {
2605
+ place();
2606
+ tip.classList.add('is-on');
2607
+ });
2608
+ }, 380);
2609
+ });
2610
+
2611
+ document.addEventListener('pointerout', (event) => {
2612
+ if (!target) return;
2613
+ const related = event.relatedTarget;
2614
+ if (related && target.contains(related)) return;
2615
+ hide();
2616
+ });
2617
+
2618
+ document.addEventListener('pointerdown', hide, true);
2619
+ window.addEventListener('scroll', hide, true);
2620
+ window.addEventListener('blur', hide);
2621
+ }
2622
+
2623
+ // ---------------- 选中文本工具条 ----------------
2624
+ function initSelectionTools() {
2625
+ document.addEventListener('mouseup', (event) => {
2626
+ setTimeout(() => {
2627
+ const node = event.target instanceof Element ? event.target : null;
2628
+ if (node?.closest('#terminalPanel')) {
2629
+ const tab = termActiveTab();
2630
+ const text = tab?.terminal.getSelection?.() ?? '';
2631
+ if (text.trim()) {
2632
+ showSelectionTools(event.clientX, event.clientY, [
2633
+ { label: '分析输出', run: () => { termClose(); askAboutText(text, '', 'terminal'); } },
2634
+ { label: '复制', run: () => copyText(text) },
2635
+ ]);
2636
+ } else hideSelectionTools();
2637
+ return;
2638
+ }
2639
+ const host = node?.closest('.thread, .files-code, .viewer-body');
2640
+ const text = window.getSelection()?.toString() ?? '';
2641
+ if (host && text.trim().length > 1) {
2642
+ const path = host.classList.contains('files-code') || host.classList.contains('viewer-body') ? state.preview?.path : '';
2643
+ showSelectionTools(event.clientX, event.clientY, [
2644
+ { label: '问 AI', run: () => askAboutText(text, path) },
2645
+ { label: '复制', run: () => copyText(text) },
2646
+ { label: '插入输入框', run: () => { insertComposer(text.trim()); hideSelectionTools(); } },
2647
+ ]);
2648
+ } else hideSelectionTools();
2649
+ }, 20);
2650
+ });
2651
+ document.addEventListener('selectionchange', () => {
2652
+ if (!window.getSelection()?.toString().trim() && !$('selectionTools').hidden) hideSelectionTools();
2653
+ });
2654
+ }
2655
+
2656
+ // ---------------- 终端输出分析 ----------------
2657
+ function analyzeTerminal() {
2658
+ const tab = termActiveTab();
2659
+ if (!tab) return toast('还没有打开终端');
2660
+ let text = tab.terminal.getSelection?.() ?? '';
2661
+ if (!text.trim()) {
2662
+ const buffer = tab.terminal.buffer.active;
2663
+ const lines = [];
2664
+ const start = Math.max(0, buffer.length - 180);
2665
+ for (let i = start; i < buffer.length; i += 1) lines.push(buffer.getLine(i)?.translateToString(true) ?? '');
2666
+ text = lines.join('\n').trim();
2667
+ }
2668
+ if (!text.trim()) return toast('终端还没有输出');
2669
+ termClose();
2670
+ askAboutText(text, '', 'terminal');
2671
+ }
2672
+
2673
+ // ---------------- 桌面窗口控制 ----------------
2674
+ function initDesktop() {
2675
+ const api = window.bananaDesktop;
2676
+ if (!api) return;
2677
+ document.body.classList.add('desktop');
2678
+ if (!api.frameless) {
2679
+ // macOS 保留原生红绿灯,只启用拖动区域
2680
+ document.body.classList.add('mac-titlebar');
2681
+ return;
2682
+ }
2683
+ const controls = $('winControls');
2684
+ controls.hidden = false;
2685
+ $('winMin').innerHTML = svg('minimize');
2686
+ $('winMax').innerHTML = svg('maximize');
2687
+ $('winClose').innerHTML = svg('x');
2688
+ $('winMin').addEventListener('click', () => api.minimize());
2689
+ $('winMax').addEventListener('click', () => api.toggleMaximize());
2690
+ $('winClose').addEventListener('click', () => api.close());
2691
+ api.onMaximizeChange?.((maximized) => {
2692
+ $('winMax').innerHTML = svg(maximized ? 'restore' : 'maximize');
2693
+ $('winMax').title = maximized ? '还原' : '最大化';
2694
+ });
2695
+ }
2696
+
1725
2697
  function bind() {
1726
2698
  $('newChat').innerHTML = svg('plus');
1727
2699
  $('sendBtn').innerHTML = svg('send');
@@ -1730,7 +2702,11 @@ function bind() {
1730
2702
  $('closeSettings').innerHTML = svg('x');
1731
2703
  $('filesBtn').innerHTML = svg('panel');
1732
2704
  $('closeFiles').innerHTML = svg('x');
2705
+ $('pluginsBtn').innerHTML = svg('plug');
1733
2706
  bindTerminal();
2707
+ initTooltips();
2708
+ initSelectionTools();
2709
+ initDesktop();
1734
2710
  hydrate();
1735
2711
  loadTheme();
1736
2712
 
@@ -1749,8 +2725,8 @@ function bind() {
1749
2725
  }
1750
2726
  });
1751
2727
  $('abortBtn').addEventListener('click', async () => { controller?.abort(); await post('abort'); });
1752
- $('modelBtn').addEventListener('click', openSettings);
1753
- $('openSettings').addEventListener('click', openSettings);
2728
+ $('modelBtn').addEventListener('click', () => openSettings());
2729
+ $('openSettings').addEventListener('click', () => openSettings());
1754
2730
  $('closeSettings').addEventListener('click', closeSettings);
1755
2731
  $('settingsSheet').addEventListener('click', (event) => {
1756
2732
  if (event.target === $('settingsSheet')) closeSettings();
@@ -1761,7 +2737,20 @@ function bind() {
1761
2737
  $('toggleSidebar').addEventListener('click', () => $('sidebar').classList.toggle('open'));
1762
2738
  $('filesBtn').addEventListener('click', toggleFiles);
1763
2739
  $('themeBtn').addEventListener('click', cycleTheme);
1764
- $('closeFiles').addEventListener('click', () => { $('filesPanel').hidden = true; });
2740
+ $('closeFiles').addEventListener('click', closeFiles);
2741
+ $('pluginsBtn').addEventListener('click', () => openSettings('plugins'));
2742
+ $('previewWrap').addEventListener('click', toggleWrap);
2743
+ $('viewerWrap').addEventListener('click', toggleWrap);
2744
+ $('previewCopy').addEventListener('click', () => copyText(state.preview?.code ?? '', '已复制代码'));
2745
+ $('viewerCopy').addEventListener('click', () => copyText(state.preview?.code ?? '', '已复制代码'));
2746
+ $('previewCopyPath').addEventListener('click', () => copyText(state.preview?.path ?? '', '已复制路径'));
2747
+ $('viewerCopyPath').addEventListener('click', () => copyText(state.preview?.path ?? '', '已复制路径'));
2748
+ $('previewFull').addEventListener('click', openViewer);
2749
+ $('previewClose').addEventListener('click', closePreview);
2750
+ $('viewerClose').addEventListener('click', closeViewer);
2751
+ $('codeViewer').addEventListener('mousedown', (event) => {
2752
+ if (event.target === $('codeViewer')) closeViewer();
2753
+ });
1765
2754
  $('insertPath').addEventListener('click', () => {
1766
2755
  const path = $('insertPath').dataset.path;
1767
2756
  if (!path) return;
@@ -1813,12 +2802,32 @@ function bind() {
1813
2802
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); send(); }
1814
2803
  });
1815
2804
 
2805
+ $('ccInput').addEventListener('input', () => {
2806
+ state.ccIndex = 0;
2807
+ renderCommandCenter($('ccInput').value);
2808
+ scheduleCommandFiles($('ccInput').value);
2809
+ });
2810
+ $('ccInput').addEventListener('keydown', (event) => {
2811
+ if (event.key === 'ArrowDown') { event.preventDefault(); moveCommandCenter(1); }
2812
+ else if (event.key === 'ArrowUp') { event.preventDefault(); moveCommandCenter(-1); }
2813
+ else if (event.key === 'Enter' && !event.isComposing) { event.preventDefault(); runCommandCenterItem(state.ccItems?.[state.ccIndex]); }
2814
+ else if (event.key === 'Escape') { event.preventDefault(); closeCommandCenter(); }
2815
+ });
2816
+ $('commandCenter').addEventListener('mousedown', (event) => {
2817
+ if (event.target === $('commandCenter')) closeCommandCenter();
2818
+ });
2819
+
1816
2820
  document.addEventListener('keydown', (event) => {
2821
+ if ((event.ctrlKey || event.metaKey) && String(event.key).toLowerCase() === 'k') {
2822
+ event.preventDefault();
2823
+ if ($('commandCenter').hidden) openCommandCenter();
2824
+ else closeCommandCenter();
2825
+ return;
2826
+ }
1817
2827
  if (event.key === 'Escape') {
1818
- if (!$('approvalModal').hidden) closeApproval(false);
1819
- else if (!$('settingsSheet').hidden) closeSettings();
1820
- else if (!$('filesPanel').hidden) $('filesPanel').hidden = true;
1821
- else if ($('sidebar').classList.contains('open')) $('sidebar').classList.remove('open');
2828
+ if (!$('commandPalette').hidden) { closePalette(); return; }
2829
+ if (closeTopOverlay()) return;
2830
+ if ($('sidebar').classList.contains('open')) $('sidebar').classList.remove('open');
1822
2831
  }
1823
2832
  });
1824
2833