@vincentbanana/bananacli 0.4.7 → 0.4.8

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
@@ -64,12 +64,13 @@ function markdown(source) {
64
64
  while (i < lines.length) {
65
65
  const line = lines[i];
66
66
  if (/^```/.test(line)) {
67
- const lang = line.slice(3).trim();
67
+ const lang = line.slice(3).trim().replace(/[^\w+-]/g, '');
68
68
  const body = [];
69
69
  i += 1;
70
70
  while (i < lines.length && !/^```/.test(lines[i])) { body.push(lines[i]); i += 1; }
71
71
  i += 1;
72
- out.push(`<pre class="code"><code data-lang="${escapeHtml(lang)}">${escapeHtml(body.join('\n'))}</code></pre>`);
72
+ const codeClass = lang ? ` class="language-${escapeHtml(lang)}"` : '';
73
+ out.push(`<pre class="code" data-lang="${escapeHtml(lang)}"><code${codeClass}>${escapeHtml(body.join('\n'))}</code></pre>`);
73
74
  continue;
74
75
  }
75
76
  if (/^\s*$/.test(line)) { i += 1; continue; }
@@ -105,11 +106,67 @@ function markdown(source) {
105
106
  return out.join('');
106
107
  }
107
108
 
109
+ // ---------------- 语法高亮 ----------------
110
+ const LANG_BY_EXT = {
111
+ ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
112
+ js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
113
+ json: 'json', jsonc: 'json', md: 'markdown', markdown: 'markdown',
114
+ py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java', kt: 'kotlin',
115
+ c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
116
+ sh: 'bash', bash: 'bash', zsh: 'bash', ps1: 'powershell', psm1: 'powershell',
117
+ html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml', vue: 'xml',
118
+ css: 'css', scss: 'scss', less: 'less', sql: 'sql', yml: 'yaml', yaml: 'yaml',
119
+ toml: 'ini', ini: 'ini', diff: 'diff', patch: 'diff', dockerfile: 'dockerfile',
120
+ };
121
+
122
+ function highlightAll(root = document) {
123
+ if (!window.hljs) return;
124
+ for (const node of root.querySelectorAll('pre code')) {
125
+ if (node.dataset.highlighted === 'yes' || !node.textContent.trim()) continue;
126
+ try {
127
+ window.hljs.highlightElement(node);
128
+ } catch {
129
+ /* 单个代码块失败不影响整页 */
130
+ }
131
+ }
132
+ }
133
+
134
+ /** 文件预览:按扩展名挑语言;未知扩展名保持纯文本。 */
135
+ function highlightPreview(element, path, code) {
136
+ const ext = (path.split('.').pop() ?? '').toLowerCase();
137
+ const lang = LANG_BY_EXT[ext];
138
+ if (!window.hljs || !lang || code.length > 300_000) {
139
+ element.textContent = code;
140
+ return;
141
+ }
142
+ element.innerHTML = `<code class="language-${lang}"></code>`;
143
+ const node = element.firstElementChild;
144
+ node.textContent = code;
145
+ try {
146
+ window.hljs.highlightElement(node);
147
+ } catch {
148
+ element.textContent = code;
149
+ }
150
+ }
151
+
152
+
153
+
108
154
  // ---------------- 状态 ----------------
109
155
  const state = { info: null, sessions: [], attachments: [], busy: false, pendingApproval: null };
110
156
 
111
157
  const THREAD = $('thread');
112
158
 
159
+ // 历史消息 / 新回复渲染完成后统一高亮,避免流式过程中反复解析
160
+ let highlightFrame = 0;
161
+ const highlightObserver = new MutationObserver(() => {
162
+ if (highlightFrame) return;
163
+ highlightFrame = requestAnimationFrame(() => {
164
+ highlightFrame = 0;
165
+ highlightAll(THREAD);
166
+ });
167
+ });
168
+ highlightObserver.observe(THREAD, { childList: true, subtree: true });
169
+
113
170
  // ---------------- 渲染 ----------------
114
171
  function hydrate(root = document) {
115
172
  root.querySelectorAll('[data-icon]').forEach((node) => {
@@ -165,10 +222,11 @@ async function switchSession(ref) {
165
222
  function renderSidebar() {
166
223
  const list = $('sessionList');
167
224
  list.innerHTML = '';
168
- for (const item of state.sessions) {
225
+ state.sessions.forEach((item, index) => {
169
226
  const active = item.id === state.info?.session?.id ? ' is-active' : '';
170
227
  const node = document.createElement('div');
171
228
  node.className = `session-item${active}`;
229
+ node.style.setProperty('--i', String(Math.min(index, 12)));
172
230
  node.innerHTML =
173
231
  `<span class="s-actions">` +
174
232
  `<span class="s-act" data-menu="${item.id}" title="更多">${svg('dots')}</span>` +
@@ -192,7 +250,7 @@ function renderSidebar() {
192
250
  openSessionMenu(item, node);
193
251
  });
194
252
  list.appendChild(node);
195
- }
253
+ });
196
254
  }
197
255
 
198
256
  /** 会话操作菜单:切换 / 重命名 / 导出 Markdown / 导出 JSON */
@@ -673,6 +731,7 @@ function applyTheme(theme) {
673
731
  button.innerHTML = svg(THEME_ICON[theme]);
674
732
  button.title = `主题:${THEME_LABEL[theme]}(点击切换)`;
675
733
  }
734
+ document.dispatchEvent(new CustomEvent('banana:theme'));
676
735
  }
677
736
 
678
737
  function loadTheme() {
@@ -976,12 +1035,20 @@ function renderSettings() {
976
1035
  const terminalField = document.createElement('div');
977
1036
  terminalField.className = 'field';
978
1037
  terminalField.innerHTML =
979
- '<label>终端</label><div class="row"><button class="btn">在界面里打开终端</button></div>' +
980
- '<div class="hint">内嵌在对话下方,不弹外部窗口;直接在当前工作区执行命令,cd 会记住目录。</div>';
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>';
981
1043
  terminalField.querySelector('button').addEventListener('click', () => {
982
1044
  closeSettings();
983
1045
  termOpen();
984
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
+ });
985
1052
  body.appendChild(terminalField);
986
1053
 
987
1054
  const permField = document.createElement('div');
@@ -1109,9 +1176,10 @@ async function loadTree(path = '.', container = $('filesTree')) {
1109
1176
  container.innerHTML = '<div class="hint" style="padding:8px 10px">(空目录)</div>';
1110
1177
  return;
1111
1178
  }
1112
- for (const entry of entries) {
1179
+ entries.forEach((entry, index) => {
1113
1180
  const row = document.createElement('button');
1114
1181
  row.className = 'file-row';
1182
+ row.style.setProperty('--i', String(Math.min(index, 14)));
1115
1183
  row.innerHTML =
1116
1184
  `<span class="tool-caret">${entry.type === 'dir' ? svg('chevron') : ''}</span>` +
1117
1185
  `<span>${svg(FILE_ICON[entry.type])}</span>` +
@@ -1134,7 +1202,7 @@ async function loadTree(path = '.', container = $('filesTree')) {
1134
1202
  }
1135
1203
  });
1136
1204
  container.appendChild(row);
1137
- }
1205
+ });
1138
1206
  } catch (error) {
1139
1207
  container.innerHTML = `<div class="hint" style="padding:8px 10px">${escapeHtml(error.message)}</div>`;
1140
1208
  }
@@ -1151,7 +1219,7 @@ async function openFile(path) {
1151
1219
  $('insertPath').dataset.path = path;
1152
1220
  if (data.binary) body.textContent = `二进制文件(${data.bytes} 字节),无法预览。`;
1153
1221
  else if (data.tooLarge) body.textContent = `文件较大(${data.bytes} 字节),未加载。`;
1154
- else body.textContent = data.content ?? '';
1222
+ else highlightPreview(body, path, data.content ?? '');
1155
1223
  document.querySelectorAll('.file-row.is-active').forEach((node) => node.classList.remove('is-active'));
1156
1224
  } catch (error) {
1157
1225
  body.textContent = error.message;
@@ -1194,72 +1262,343 @@ function closeSettings() {
1194
1262
  setTimeout(() => { if ($('settingsSheet').hidden) $('settingsBody').innerHTML = ''; }, 240);
1195
1263
  }
1196
1264
 
1197
- // ---------------- 内嵌终端 ----------------
1198
- const term = { open: false, running: false, history: [], cursor: 0, controller: null, size: 0 };
1265
+ // ---------------- 内嵌终端(xterm + PTY) ----------------
1266
+ const TERM_FONT = '"Cascadia Mono", "JetBrains Mono", "SF Mono", Consolas, "Microsoft YaHei", monospace';
1267
+ const TERM_STORAGE = {
1268
+ dock: 'banana.term.dock',
1269
+ size: 'banana.term.size',
1270
+ pos: 'banana.term.pos',
1271
+ fontSize: 'banana.term.fontSize',
1272
+ shell: 'banana.term.shell',
1273
+ };
1199
1274
 
1200
- const ANSI_COLORS = {
1201
- 30: '#6b6b72', 31: '#b4453a', 32: '#2f7d50', 33: '#966d1f',
1202
- 34: '#3b63b0', 35: '#7b4d9e', 36: '#257f80', 37: '#55555c',
1203
- 90: '#8c8c93', 91: '#c9564a', 92: '#3f9a66', 93: '#b0852d',
1204
- 94: '#527cd1', 95: '#9a67c4', 96: '#2f9a9a', 97: '#16161a',
1275
+ const term = {
1276
+ open: false,
1277
+ dock: localStorage.getItem(TERM_STORAGE.dock) || 'bottom',
1278
+ size: Number(localStorage.getItem(TERM_STORAGE.size)) || 280,
1279
+ fontSize: Number(localStorage.getItem(TERM_STORAGE.fontSize)) || 13,
1280
+ shells: [],
1281
+ defaultShell: '',
1282
+ tabs: [],
1283
+ activeId: null,
1284
+ counter: 0,
1285
+ loadedShells: false,
1205
1286
  };
1206
1287
 
1207
- /** 把 ANSI 上色转成 span;其他控制序列直接丢掉,避免满屏乱码。 */
1208
- function ansiToHtml(input) {
1209
- const text = input.replace(/\x1b\][^\x07]*\x07/g, '').replace(/\x1b\[[0-9;?]*[A-LN-Za-ln-z]/g, '');
1210
- let out = '';
1211
- let last = 0;
1212
- let color = '';
1213
- let bold = false;
1214
- let dim = false;
1215
- const style = () =>
1216
- color || bold || dim
1217
- ? `color:${color || 'inherit'};font-weight:${bold ? 600 : 400};opacity:${dim ? 0.7 : 1}`
1218
- : '';
1219
- for (const match of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
1220
- out += `<span style="${style()}">${escapeHtml(text.slice(last, match.index))}</span>`;
1221
- last = match.index + match[0].length;
1222
- for (const code of (match[1] || '0').split(';').filter(Boolean).map(Number)) {
1223
- if (code === 0) { color = ''; bold = false; dim = false; }
1224
- else if (code === 1) bold = true;
1225
- else if (code === 2) dim = true;
1226
- else if (code === 22) { bold = false; dim = false; }
1227
- else if (code === 39) color = '';
1228
- else if (ANSI_COLORS[code]) color = ANSI_COLORS[code];
1288
+ function termIsDark() {
1289
+ const explicit = document.documentElement.dataset.theme;
1290
+ if (explicit === 'dark') return true;
1291
+ if (explicit === 'light') return false;
1292
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
1293
+ }
1294
+
1295
+ /** 柔和配色:不用纯红纯绿,降低饱和度,长时间看不刺眼。 */
1296
+ function termTheme() {
1297
+ const dark = termIsDark();
1298
+ return {
1299
+ background: dark ? '#101013' : '#fbfbfa',
1300
+ foreground: dark ? '#d9d9d4' : '#26262b',
1301
+ cursor: dark ? '#e8e8e2' : '#16161a',
1302
+ cursorAccent: dark ? '#101013' : '#fbfbfa',
1303
+ selectionBackground: dark ? 'rgba(120, 150, 220, 0.28)' : 'rgba(70, 110, 190, 0.18)',
1304
+ black: dark ? '#2a2a30' : '#3a3a40',
1305
+ red: dark ? '#d1786e' : '#b4453a',
1306
+ green: dark ? '#86b98d' : '#2f7d50',
1307
+ yellow: dark ? '#d2b06a' : '#8f6b1f',
1308
+ blue: dark ? '#8aa9dc' : '#3b63b0',
1309
+ magenta: dark ? '#b294cd' : '#7b4d9e',
1310
+ cyan: dark ? '#82bdbd' : '#257f80',
1311
+ white: dark ? '#c9c9c4' : '#55555c',
1312
+ brightBlack: dark ? '#6c6c75' : '#8c8c93',
1313
+ brightRed: dark ? '#e29a90' : '#c9564a',
1314
+ brightGreen: dark ? '#a3d2a8' : '#3f9a66',
1315
+ brightYellow: dark ? '#e6ca8c' : '#b0852d',
1316
+ brightBlue: dark ? '#a9c3ee' : '#527cd1',
1317
+ brightMagenta: dark ? '#cbb0e2' : '#9a67c4',
1318
+ brightCyan: dark ? '#a2d4d4' : '#2f9a9a',
1319
+ brightWhite: dark ? '#f2f2ee' : '#16161a',
1320
+ };
1321
+ }
1322
+
1323
+ function termActiveTab() {
1324
+ return term.tabs.find((tab) => tab.id === term.activeId) ?? null;
1325
+ }
1326
+
1327
+ function termUpdateCwd(cwd) {
1328
+ const label = $('termCwd');
1329
+ if (label && cwd) label.textContent = cwd;
1330
+ }
1331
+
1332
+ function termPersistSize() {
1333
+ localStorage.setItem(TERM_STORAGE.size, String(Math.round(term.size)));
1334
+ localStorage.setItem(TERM_STORAGE.dock, term.dock);
1335
+ }
1336
+
1337
+ function termApplyDock(dock, persist = true) {
1338
+ term.dock = dock;
1339
+ const panel = $('terminalPanel');
1340
+ const workspace = $('workspace');
1341
+ panel.dataset.dock = dock;
1342
+ workspace.dataset.dock = dock;
1343
+ panel.style.removeProperty('left');
1344
+ panel.style.removeProperty('top');
1345
+ panel.style.removeProperty('right');
1346
+ panel.style.removeProperty('bottom');
1347
+ panel.style.width = '';
1348
+ panel.style.height = '';
1349
+ if (dock === 'bottom' || dock === 'top') {
1350
+ panel.style.height = `${term.size}px`;
1351
+ } else if (dock === 'left' || dock === 'right') {
1352
+ panel.style.width = `${term.size}px`;
1353
+ } 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`;
1229
1360
  }
1230
1361
  }
1231
- out += `<span style="${style()}">${escapeHtml(text.slice(last))}</span>`;
1232
- return out;
1233
- }
1234
-
1235
- function termWrite(text, kind = 'out') {
1236
- if (!text) return;
1237
- const body = $('termBody');
1238
- const chunk = document.createElement('span');
1239
- chunk.className = 'term-chunk' + (kind === 'stderr' ? ' stderr' : kind === 'input' ? ' input' : '');
1240
- chunk.innerHTML = ansiToHtml(text);
1241
- body.appendChild(chunk);
1242
- term.size += text.length;
1243
- if (term.size > 240_000) {
1244
- body.textContent = '';
1245
- term.size = 0;
1246
- termWrite('输出太多,已清空旧内容。\n', 'input');
1362
+ if (persist) termPersistSize();
1363
+ requestAnimationFrame(() => {
1364
+ termActiveTab()?.fit.fit();
1365
+ });
1366
+ }
1367
+
1368
+ function termReadPos() {
1369
+ try {
1370
+ const raw = JSON.parse(localStorage.getItem(TERM_STORAGE.pos) || 'null');
1371
+ return raw && Number.isFinite(raw.x) && Number.isFinite(raw.y) ? raw : null;
1372
+ } catch {
1373
+ return null;
1374
+ }
1375
+ }
1376
+
1377
+ async function termLoadShells() {
1378
+ if (term.loadedShells) return;
1379
+ term.loadedShells = true;
1380
+ try {
1381
+ const data = await get('shells');
1382
+ term.shells = Array.isArray(data.shells) ? data.shells : [];
1383
+ term.defaultShell = data.default || term.shells[0]?.id || 'cmd';
1384
+ const select = $('termShell');
1385
+ const preferred = localStorage.getItem(TERM_STORAGE.shell);
1386
+ select.innerHTML = term.shells
1387
+ .map((shell) => `<option value="${escapeHtml(shell.id)}">${escapeHtml(shell.name)}</option>`)
1388
+ .join('');
1389
+ select.value = term.shells.some((shell) => shell.id === preferred) ? preferred : term.defaultShell;
1390
+ select.addEventListener('change', () => {
1391
+ localStorage.setItem(TERM_STORAGE.shell, select.value);
1392
+ termCreateTab(select.value);
1393
+ });
1394
+ } catch (error) {
1395
+ term.shells = [{ id: 'cmd', name: '终端' }];
1396
+ term.defaultShell = 'cmd';
1397
+ }
1398
+ }
1399
+
1400
+ function termRenderTabs() {
1401
+ const holder = $('termTabs');
1402
+ holder.innerHTML = '';
1403
+ for (const tab of term.tabs) {
1404
+ const node = document.createElement('div');
1405
+ node.className = 'term-tab' + (tab.id === term.activeId ? ' active' : '');
1406
+ node.innerHTML = `<span class="term-tab-name">${escapeHtml(tab.name)}</span><button class="term-tab-close" title="关闭标签" aria-label="关闭标签"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg></button>`;
1407
+ node.addEventListener('click', (event) => {
1408
+ if (event.target.closest('.term-tab-close')) {
1409
+ event.stopPropagation();
1410
+ termCloseTab(tab.id);
1411
+ return;
1412
+ }
1413
+ termActivate(tab.id);
1414
+ });
1415
+ holder.appendChild(node);
1416
+ }
1417
+ }
1418
+
1419
+ function termActivate(id) {
1420
+ term.activeId = id;
1421
+ for (const tab of term.tabs) {
1422
+ const active = tab.id === id;
1423
+ tab.pane.classList.toggle('active', active);
1424
+ if (active) {
1425
+ requestAnimationFrame(() => {
1426
+ try {
1427
+ tab.fit.fit();
1428
+ } catch {
1429
+ /* 面板还没显示 */
1430
+ }
1431
+ tab.terminal.focus();
1432
+ });
1433
+ }
1434
+ }
1435
+ termRenderTabs();
1436
+ }
1437
+
1438
+ function termConnect(tab) {
1439
+ const url = new URL(API + 'terminal/ws', location.href);
1440
+ url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
1441
+ url.searchParams.set('shell', tab.shell || term.defaultShell || 'cmd');
1442
+ url.searchParams.set('cwd', state.info?.workspace ?? '');
1443
+ url.searchParams.set('cols', String(tab.terminal.cols || 100));
1444
+ url.searchParams.set('rows', String(tab.terminal.rows || 28));
1445
+ if (tab.termId) url.searchParams.set('id', tab.termId);
1446
+
1447
+ let ws;
1448
+ try {
1449
+ ws = new WebSocket(url);
1450
+ } catch (error) {
1451
+ tab.terminal.write(`\r\n\x1b[31m连接终端失败:${error?.message ?? error}\x1b[0m\r\n`);
1452
+ return;
1453
+ }
1454
+ tab.ws = ws;
1455
+
1456
+ ws.addEventListener('open', () => {
1457
+ requestAnimationFrame(() => {
1458
+ try {
1459
+ tab.fit.fit();
1460
+ } catch {
1461
+ /* 忽略 */
1462
+ }
1463
+ tab.sendResize();
1464
+ });
1465
+ });
1466
+ ws.addEventListener('message', (event) => {
1467
+ let message;
1468
+ try {
1469
+ message = JSON.parse(event.data);
1470
+ } catch {
1471
+ return;
1472
+ }
1473
+ if (message.type === 'id' || message.type === 'ready') {
1474
+ if (message.id) tab.termId = message.id;
1475
+ if (message.cwd) termUpdateCwd(message.cwd);
1476
+ } else if (message.type === 'data') {
1477
+ tab.terminal.write(message.data);
1478
+ } else if (message.type === 'exit') {
1479
+ tab.terminal.write(`\r\n\x1b[90m[进程已退出,代码 ${message.code ?? 0}]\x1b[0m\r\n`);
1480
+ } else if (message.type === 'error') {
1481
+ tab.terminal.write(`\r\n\x1b[31m${message.message}\x1b[0m\r\n`);
1482
+ }
1483
+ });
1484
+ ws.addEventListener('close', () => {
1485
+ if (!tab.alive || !term.open) return;
1486
+ // 服务重启或网络抖动时自动重连;服务端会按 tab.termId 把会话接回来
1487
+ setTimeout(() => {
1488
+ if (tab.alive && term.open && (!tab.ws || tab.ws.readyState > 1)) termConnect(tab);
1489
+ }, 900);
1490
+ });
1491
+
1492
+ tab.sendResize = () => {
1493
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: tab.terminal.cols, rows: tab.terminal.rows }));
1494
+ };
1495
+ }
1496
+
1497
+ function termCreateTab(shellId) {
1498
+ if (!term.open) {
1499
+ termOpen();
1500
+ return null;
1501
+ }
1502
+ termLoadShells();
1503
+ const shell = term.shells.find((item) => item.id === shellId) ?? term.shells[0];
1504
+ const name = shell?.name ?? '终端';
1505
+ const pane = document.createElement('div');
1506
+ pane.className = 'term-pane';
1507
+ $('termBody').appendChild(pane);
1508
+
1509
+ const terminal = new window.Terminal({
1510
+ cursorBlink: true,
1511
+ cursorStyle: 'bar',
1512
+ fontFamily: TERM_FONT,
1513
+ fontSize: term.fontSize,
1514
+ lineHeight: 1.25,
1515
+ letterSpacing: 0,
1516
+ scrollback: 8000,
1517
+ allowTransparency: true,
1518
+ theme: termTheme(),
1519
+ rightClickSelectsWord: true,
1520
+ });
1521
+ const fit = new window.FitAddon.FitAddon();
1522
+ terminal.loadAddon(fit);
1523
+ terminal.open(pane);
1524
+
1525
+ const tab = {
1526
+ id: `tab${++term.counter}`,
1527
+ name,
1528
+ shell: shell?.id ?? '',
1529
+ terminal,
1530
+ fit,
1531
+ pane,
1532
+ ws: null,
1533
+ termId: null,
1534
+ alive: true,
1535
+ sendResize: () => {},
1536
+ };
1537
+ term.tabs.push(tab);
1538
+ term.activeId = tab.id;
1539
+ // 输入监听只注册一次;重连只换 WebSocket,不能重复绑定,否则一个按键发两遍
1540
+ terminal.onData((data) => {
1541
+ if (tab.ws?.readyState === 1) tab.ws.send(JSON.stringify({ type: 'input', data }));
1542
+ });
1543
+ terminal.onResize(() => tab.sendResize());
1544
+ termRenderTabs();
1545
+ termActivate(tab.id);
1546
+ requestAnimationFrame(() => {
1547
+ try {
1548
+ fit.fit();
1549
+ } catch {
1550
+ /* 忽略 */
1551
+ }
1552
+ termConnect(tab);
1553
+ });
1554
+ return tab;
1555
+ }
1556
+
1557
+ function termCloseTab(id) {
1558
+ const index = term.tabs.findIndex((tab) => tab.id === id);
1559
+ if (index < 0) return;
1560
+ const tab = term.tabs[index];
1561
+ tab.alive = false;
1562
+ try {
1563
+ if (tab.ws?.readyState === 1) tab.ws.send(JSON.stringify({ type: 'close' }));
1564
+ tab.ws?.close();
1565
+ } catch {
1566
+ /* 已经断开 */
1567
+ }
1568
+ try {
1569
+ tab.terminal.dispose();
1570
+ } catch {
1571
+ /* 忽略 */
1572
+ }
1573
+ tab.pane.remove();
1574
+ term.tabs.splice(index, 1);
1575
+ if (term.tabs.length === 0) {
1576
+ termClose();
1577
+ return;
1578
+ }
1579
+ if (term.activeId === id) {
1580
+ const next = term.tabs[Math.max(0, index - 1)] ?? term.tabs[0];
1581
+ termActivate(next.id);
1582
+ } else {
1583
+ termRenderTabs();
1247
1584
  }
1248
- body.scrollTop = body.scrollHeight;
1249
1585
  }
1250
1586
 
1251
1587
  function termOpen() {
1252
1588
  const panel = $('terminalPanel');
1589
+ termApplyDock(term.dock, false);
1253
1590
  if (!term.open) {
1254
1591
  term.open = true;
1255
1592
  panel.hidden = false;
1256
1593
  requestAnimationFrame(() => panel.classList.add('show'));
1257
- if (!term.size) {
1258
- termWrite('在当前工作区执行命令;cd 会记住目录,clear 清屏。\n', 'input');
1259
- }
1260
1594
  }
1261
- $('termCwd').textContent = state.info?.workspace ?? '';
1262
- $('termInput').focus();
1595
+ const ensure = () => {
1596
+ if (term.tabs.length === 0) termCreateTab($('termShell')?.value || term.defaultShell);
1597
+ else termActivate(term.activeId);
1598
+ };
1599
+ if (term.loadedShells) ensure();
1600
+ else termLoadShells().then(ensure);
1601
+ termUpdateCwd(state.info?.workspace ?? '');
1263
1602
  }
1264
1603
 
1265
1604
  function termClose() {
@@ -1271,7 +1610,7 @@ function termClose() {
1271
1610
  if (term.open) return;
1272
1611
  panel.hidden = true;
1273
1612
  panel.classList.remove('closing');
1274
- }, 160);
1613
+ }, 180);
1275
1614
  }
1276
1615
 
1277
1616
  function termToggle() {
@@ -1279,112 +1618,108 @@ function termToggle() {
1279
1618
  else termOpen();
1280
1619
  }
1281
1620
 
1282
- async function termRun(command) {
1283
- const cmd = command.trim();
1284
- if (!cmd || term.running) return;
1285
- if (/^(clear|cls)$/i.test(cmd)) {
1286
- $('termBody').textContent = '';
1287
- term.size = 0;
1288
- return;
1289
- }
1290
- term.running = true;
1291
- $('termStop').hidden = false;
1292
- $('termSend').disabled = true;
1293
- termWrite(`\n> ${cmd}\n`, 'input');
1294
-
1295
- const history = term.history;
1296
- if (history[history.length - 1] !== cmd) history.push(cmd);
1297
- term.cursor = history.length;
1298
-
1299
- term.controller = new AbortController();
1300
- try {
1301
- const response = await fetch(API + 'terminal', {
1302
- method: 'POST',
1303
- headers: { 'content-type': 'application/json' },
1304
- body: JSON.stringify({ command: cmd }),
1305
- signal: term.controller.signal,
1306
- });
1307
- if (!response.ok) {
1308
- const payload = await response.json().catch(() => ({}));
1309
- termWrite((payload.error ?? `终端启动失败(${response.status})`) + '\n', 'stderr');
1310
- return;
1311
- }
1312
- const reader = response.body.getReader();
1313
- const decoder = new TextDecoder();
1314
- let buffer = '';
1315
- for (;;) {
1316
- const { done, value } = await reader.read();
1317
- if (done) break;
1318
- buffer += decoder.decode(value, { stream: true });
1319
- let index;
1320
- while ((index = buffer.indexOf('\n\n')) >= 0) {
1321
- const raw = buffer.slice(0, index);
1322
- buffer = buffer.slice(index + 2);
1323
- const line = raw.startsWith('data: ') ? raw.slice(6) : raw;
1324
- if (!line) continue;
1325
- let event;
1326
- try { event = JSON.parse(line); } catch { continue; }
1327
- if (event.type === 'out') termWrite(event.data, event.stream);
1328
- else if (event.type === 'start' || event.type === 'exit') {
1329
- if (event.cwd) $('termCwd').textContent = event.cwd;
1330
- if (event.error) termWrite(event.error + '\n', 'stderr');
1331
- if (event.type === 'exit' && event.code) termWrite(`进程退出,代码 ${event.code}\n`, 'stderr');
1332
- }
1621
+ function termRestyle() {
1622
+ const theme = termTheme();
1623
+ for (const tab of term.tabs) {
1624
+ tab.terminal.options.theme = theme;
1625
+ tab.terminal.options.fontSize = term.fontSize;
1626
+ requestAnimationFrame(() => {
1627
+ try {
1628
+ tab.fit.fit();
1629
+ } catch {
1630
+ /* 忽略 */
1333
1631
  }
1334
- }
1335
- } catch (error) {
1336
- if (error?.name !== 'AbortError') termWrite(`终端错误:${error?.message ?? error}\n`, 'stderr');
1337
- else termWrite('已停止。\n', 'input');
1338
- } finally {
1339
- term.running = false;
1340
- term.controller = null;
1341
- $('termStop').hidden = true;
1342
- $('termSend').disabled = false;
1343
- if (term.open) $('termInput').focus();
1632
+ });
1344
1633
  }
1345
1634
  }
1346
1635
 
1347
1636
  function bindTerminal() {
1348
1637
  $('termBtn').innerHTML = svg('terminal');
1349
- $('termStop').innerHTML = svg('stop');
1638
+ $('termAdd').innerHTML = svg('plus');
1350
1639
  $('termClear').innerHTML = svg('trash');
1351
1640
  $('termClose').innerHTML = svg('x');
1641
+ $('termDock').innerHTML = svg('panel');
1352
1642
  $('termBtn').addEventListener('click', termToggle);
1353
1643
  $('termClose').addEventListener('click', termClose);
1354
- $('termClear').addEventListener('click', () => {
1355
- $('termBody').textContent = '';
1356
- term.size = 0;
1644
+ $('termAdd').addEventListener('click', () => {
1645
+ termCreateTab($('termShell')?.value || term.defaultShell);
1357
1646
  });
1358
- $('termStop').addEventListener('click', () => term.controller?.abort());
1359
- $('termForm').addEventListener('submit', (event) => {
1647
+ $('termClear').addEventListener('click', () => termActiveTab()?.terminal.clear());
1648
+ $('termDock').addEventListener('click', () => {
1649
+ const order = ['bottom', 'right', 'left', 'float'];
1650
+ const next = order[(order.indexOf(term.dock) + 1) % order.length];
1651
+ termApplyDock(next);
1652
+ toast(`终端停靠在${next === 'bottom' ? '底部' : next === 'top' ? '顶部' : next === 'right' ? '右侧' : next === 'left' ? '左侧' : '浮动'}`);
1653
+ });
1654
+
1655
+ // 拖动边缘调整大小(浮动态拖标题栏移动位置)
1656
+ const grip = $('termGrip');
1657
+ grip.addEventListener('pointerdown', (event) => {
1360
1658
  event.preventDefault();
1361
- const input = $('termInput');
1362
- const value = input.value;
1363
- input.value = '';
1364
- termRun(value);
1659
+ const workspace = $('workspace');
1660
+ const rect = workspace.getBoundingClientRect();
1661
+ const move = (moveEvent) => {
1662
+ let size;
1663
+ if (term.dock === 'bottom') size = rect.bottom - moveEvent.clientY;
1664
+ else if (term.dock === 'top') size = moveEvent.clientY - rect.top;
1665
+ else if (term.dock === 'right') size = rect.right - moveEvent.clientX;
1666
+ else size = moveEvent.clientX - rect.left;
1667
+ term.size = Math.min(Math.max(160, size), (term.dock === 'left' || term.dock === 'right' ? rect.width : rect.height) - 160);
1668
+ if (term.dock === 'bottom' || term.dock === 'top') $('terminalPanel').style.height = `${term.size}px`;
1669
+ else $('terminalPanel').style.width = `${term.size}px`;
1670
+ termActiveTab()?.fit.fit();
1671
+ };
1672
+ const up = () => {
1673
+ window.removeEventListener('pointermove', move);
1674
+ window.removeEventListener('pointerup', up);
1675
+ termPersistSize();
1676
+ };
1677
+ window.addEventListener('pointermove', move);
1678
+ window.addEventListener('pointerup', up);
1365
1679
  });
1366
- $('termInput').addEventListener('keydown', (event) => {
1367
- if (event.key === 'ArrowUp') {
1368
- event.preventDefault();
1369
- if (!term.history.length) return;
1370
- term.cursor = Math.max(0, term.cursor - 1);
1371
- $('termInput').value = term.history[term.cursor] ?? '';
1372
- } else if (event.key === 'ArrowDown') {
1373
- event.preventDefault();
1374
- term.cursor = Math.min(term.history.length, term.cursor + 1);
1375
- $('termInput').value = term.history[term.cursor] ?? '';
1376
- } else if (event.key === 'Escape') {
1377
- termClose();
1378
- }
1680
+
1681
+ const panel = $('terminalPanel');
1682
+ panel.addEventListener('pointerdown', (event) => {
1683
+ if (term.dock !== 'float') return;
1684
+ if (event.target.closest('button, select, .term-tab, .term-grip')) return;
1685
+ const workspace = $('workspace');
1686
+ const rect = workspace.getBoundingClientRect();
1687
+ const panelRect = panel.getBoundingClientRect();
1688
+ const offsetX = event.clientX - panelRect.left;
1689
+ const offsetY = event.clientY - panelRect.top;
1690
+ const move = (moveEvent) => {
1691
+ const x = Math.min(Math.max(0, moveEvent.clientX - rect.left - offsetX), rect.width - 240);
1692
+ const y = Math.min(Math.max(0, moveEvent.clientY - rect.top - offsetY), rect.height - 80);
1693
+ panel.style.left = `${x}px`;
1694
+ panel.style.top = `${y}px`;
1695
+ };
1696
+ const up = () => {
1697
+ window.removeEventListener('pointermove', move);
1698
+ window.removeEventListener('pointerup', up);
1699
+ const x = parseFloat(panel.style.left) || 0;
1700
+ const y = parseFloat(panel.style.top) || 0;
1701
+ localStorage.setItem(TERM_STORAGE.pos, JSON.stringify({ x, y }));
1702
+ };
1703
+ window.addEventListener('pointermove', move);
1704
+ window.addEventListener('pointerup', up);
1379
1705
  });
1706
+
1380
1707
  window.addEventListener('keydown', (event) => {
1381
1708
  if (event.ctrlKey && event.key === '`') {
1382
1709
  event.preventDefault();
1383
1710
  termToggle();
1384
1711
  }
1385
1712
  });
1386
- // 新建 / 切换会话时同步一下工作区显示
1387
- $('termCwd').textContent = state.info?.workspace ?? '';
1713
+
1714
+ const observer = new ResizeObserver(() => {
1715
+ if (term.open) termActiveTab()?.fit.fit();
1716
+ });
1717
+ observer.observe($('workspace'));
1718
+
1719
+ document.addEventListener('banana:theme', () => {
1720
+ if (term.tabs.length) termRestyle();
1721
+ });
1722
+ termUpdateCwd(state.info?.workspace ?? '');
1388
1723
  }
1389
1724
 
1390
1725
  function bind() {