@vincentbanana/bananacli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/app.js CHANGED
@@ -41,6 +41,7 @@ const ICONS = {
41
41
  maximize: '<rect x="6" y="6" width="12" height="12" rx="1"/>',
42
42
  restore: '<rect x="5" y="8" width="9" height="9" rx="1"/><path d="M9 5h9v9"/>',
43
43
  diff: '<path d="M6 4v16M18 4v16M3 8h6M15 16h6M3 16h6M15 8h6"/>',
44
+ refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 5v6h-6"/>',
44
45
  };
45
46
 
46
47
  const TOOL_ICON = {
@@ -175,7 +176,11 @@ const state = {
175
176
  overlay: null,
176
177
  ccIndex: 0,
177
178
  ccItems: [],
178
- workbench: { open: false, tab: 'terminal', width: 420 },
179
+ tabs: [],
180
+ activeTab: null,
181
+ review: { open: false, mode: 'changes', width: 520 },
182
+ sidebar: { open: true, width: 344 },
183
+ dock: { open: false, height: 280 },
179
184
  changes: null,
180
185
  changesFile: null,
181
186
  };
@@ -236,7 +241,8 @@ async function switchSession(ref) {
236
241
  renderSidebar();
237
242
  renderThread();
238
243
  $('filesTitle').textContent = `项目文件 · ${state.info?.workspace ?? ''}`;
239
- if (state.workbench.open && state.workbench.tab === 'changes') void loadChanges(true);
244
+ setActiveTab(state.info?.session?.id ?? null);
245
+ if (state.review.open && state.review.mode === 'changes') void loadChanges(true);
240
246
  closeSidebarIfOverlay();
241
247
  } catch (error) {
242
248
  toast('切换会话失败:' + (error?.message ?? error));
@@ -247,10 +253,153 @@ async function switchSession(ref) {
247
253
  }
248
254
  }
249
255
 
256
+ // ---------------- 会话标签条 ----------------
257
+ function tabTitleFor(id) {
258
+ if (state.info?.session?.id === id && state.info.session.title) return state.info.session.title;
259
+ return state.sessions.find((item) => item.id === id)?.title || '新对话';
260
+ }
261
+
262
+ function saveTabs() {
263
+ try {
264
+ localStorage.setItem(
265
+ SHELL_STORAGE.tabState,
266
+ JSON.stringify({ all: state.tabs, active: state.activeTab }),
267
+ );
268
+ } catch {}
269
+ }
270
+
271
+ function upsertTab(id, title) {
272
+ if (!id) return;
273
+ const existing = state.tabs.find((tab) => tab.id === id);
274
+ if (existing) {
275
+ if (title) existing.title = title;
276
+ return;
277
+ }
278
+ state.tabs.push({ id, title: title || tabTitleFor(id) });
279
+ }
280
+
281
+ function setActiveTab(id) {
282
+ state.activeTab = id;
283
+ if (id) upsertTab(id);
284
+ saveTabs();
285
+ renderTabs();
286
+ renderSidebar();
287
+ }
288
+
289
+ function renderTabs() {
290
+ const strip = $('tabStrip');
291
+ if (!strip) return;
292
+ strip.innerHTML = '';
293
+ for (const tab of state.tabs) {
294
+ const node = document.createElement('button');
295
+ node.type = 'button';
296
+ node.className = `tab${tab.id === state.activeTab ? ' is-active' : ''}`;
297
+ node.draggable = true;
298
+ node.dataset.id = tab.id;
299
+ const title = tabTitleFor(tab.id) || tab.title || '新对话';
300
+ node.innerHTML =
301
+ `<span class="tab-avatar">${escapeHtml(title.slice(0, 1))}</span>` +
302
+ `<span class="tab-title">${escapeHtml(title)}</span>` +
303
+ `<span class="tab-close" title="关闭标签"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg></span>`;
304
+ node.addEventListener('pointerdown', (event) => {
305
+ if (event.button !== 0 || event.target.closest('.tab-close')) return;
306
+ if (tab.id !== state.activeTab) {
307
+ event.preventDefault();
308
+ switchSession(tab.id);
309
+ }
310
+ });
311
+ node.addEventListener('click', (event) => {
312
+ if (event.target.closest('.tab-close')) {
313
+ event.stopPropagation();
314
+ closeTab(tab.id);
315
+ return;
316
+ }
317
+ if (event.detail === 0) switchSession(tab.id);
318
+ });
319
+ node.addEventListener('auxclick', (event) => {
320
+ if (event.button === 1) closeTab(tab.id);
321
+ });
322
+ node.addEventListener('dragstart', (event) => {
323
+ event.dataTransfer.setData('text/plain', tab.id);
324
+ node.classList.add('is-dragging');
325
+ });
326
+ node.addEventListener('dragend', () => node.classList.remove('is-dragging'));
327
+ node.addEventListener('dragover', (event) => event.preventDefault());
328
+ node.addEventListener('drop', (event) => {
329
+ event.preventDefault();
330
+ const source = event.dataTransfer.getData('text/plain');
331
+ const from = state.tabs.findIndex((item) => item.id === source);
332
+ const to = state.tabs.findIndex((item) => item.id === tab.id);
333
+ if (from < 0 || to < 0 || from === to) return;
334
+ const [picked] = state.tabs.splice(from, 1);
335
+ state.tabs.splice(to, 0, picked);
336
+ saveTabs();
337
+ renderTabs();
338
+ });
339
+ strip.appendChild(node);
340
+ }
341
+ }
342
+
343
+ function closeTab(id) {
344
+ const index = state.tabs.findIndex((tab) => tab.id === id);
345
+ if (index < 0) return;
346
+ state.tabs.splice(index, 1);
347
+ saveTabs();
348
+ if (state.activeTab !== id) {
349
+ renderTabs();
350
+ return;
351
+ }
352
+ if (state.tabs.length) {
353
+ const next = state.tabs[Math.max(0, index - 1)] ?? state.tabs[0];
354
+ switchSession(next.id);
355
+ } else {
356
+ state.activeTab = null;
357
+ renderTabs();
358
+ $('newChat').click();
359
+ }
360
+ }
361
+
362
+ function initTabs() {
363
+ let saved = null;
364
+ try {
365
+ saved = JSON.parse(localStorage.getItem(SHELL_STORAGE.tabState) || 'null');
366
+ } catch {}
367
+ if (saved && Array.isArray(saved.all)) {
368
+ const known = new Set(state.sessions.map((item) => item.id));
369
+ if (state.info?.session?.id) known.add(state.info.session.id);
370
+ state.tabs = saved.all.filter((tab) => tab && typeof tab.id === 'string' && known.has(tab.id));
371
+ state.activeTab = saved.active ?? null;
372
+ }
373
+ const current = state.info?.session?.id;
374
+ if (current) upsertTab(current, state.info.session.title);
375
+ if (!state.activeTab || !state.tabs.some((tab) => tab.id === state.activeTab)) {
376
+ state.activeTab = current ?? state.tabs[0]?.id ?? null;
377
+ }
378
+ renderTabs();
379
+ }
380
+
250
381
  function renderSidebar() {
251
382
  const list = $('sessionList');
252
383
  list.innerHTML = '';
384
+ let group = '';
385
+ const dayOf = (iso) => {
386
+ const date = new Date(iso);
387
+ const now = new Date();
388
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
389
+ const value = date.getTime();
390
+ if (value >= start) return '今天';
391
+ if (value >= start - 86_400_000) return '昨天';
392
+ return '更早';
393
+ };
253
394
  state.sessions.forEach((item, index) => {
395
+ const label = dayOf(item.updatedAt);
396
+ if (label !== group) {
397
+ group = label;
398
+ const head = document.createElement('div');
399
+ head.className = 'sb-group';
400
+ head.textContent = label;
401
+ list.appendChild(head);
402
+ }
254
403
  const active = item.id === state.info?.session?.id ? ' is-active' : '';
255
404
  const node = document.createElement('div');
256
405
  node.className = `session-item${active}`;
@@ -270,7 +419,11 @@ function renderSidebar() {
270
419
 
271
420
  node.querySelector('[data-del]').addEventListener('click', (event) => {
272
421
  event.stopPropagation();
273
- post('session', { action: 'delete', ref: item.id }).then(() => refresh(true));
422
+ post('session', { action: 'delete', ref: item.id }).then(() => {
423
+ state.tabs = state.tabs.filter((tab) => tab.id !== item.id);
424
+ saveTabs();
425
+ refresh(true);
426
+ });
274
427
  });
275
428
 
276
429
  node.querySelector('[data-menu]').addEventListener('click', (event) => {
@@ -425,36 +578,42 @@ function renderMessage(message) {
425
578
  function renderEmpty() {
426
579
  const wrap = document.createElement('div');
427
580
  wrap.className = 'empty';
428
- // 第一次用、或者还没配 key 时,给一条能照着做的引导
429
- const provider = state.info?.provider;
430
- const current = state.info?.providers?.find((item) => item.current);
431
- const hasKey = Boolean(current?.keyReady);
432
- const banner = hasKey
433
- ? ''
434
- : `<div class="empty-warn">
435
- <strong>还差一步:填上 API Key</strong>
436
- <span>没有 key 就没法对话。点下面的按钮,填进去立刻就能用(key 只存在你自己的电脑上)。</span>
437
- <button class="btn primary" id="emptySetup">现在去填</button>
438
- </div>`;
581
+ // 新会话:只保留 wordmark,操作提示放到 composer 下方,和 opencode 的首页一致
439
582
  wrap.innerHTML =
440
- '<h1>要做什么</h1>' +
441
- '<p>读写文件、跑命令、查资料,都可以。默认每次都要你确认。</p>' +
442
- banner +
443
- '<div class="suggest">' +
444
- ['这个仓库是做什么的', '跑一下测试并修复失败', '给我一个重构方案,先别改代码', '把 README 翻译成英文']
445
- .map((text) => `<button>${escapeHtml(text)}</button>`)
446
- .join('') +
447
- '</div>' +
448
- `<div class="empty-tips">
449
- <span><b>/</b> 调命令(/help 看全部)</span>
450
- <span><b>@</b> 引用项目里的文件</span>
451
- <span><b>${escapeHtml(provider?.name ?? '')}</b> 当前模型</span>
583
+ `<div class="home-wordmark" aria-hidden="true">
584
+ <span class="wm-text">Banana</span>
585
+ <svg class="wm-leaf" viewBox="0 0 72 72"><path d="M14 42c2-16 14-27 34-28 0 18-8 31-24 35"/><path d="M14 58c8-15 18-24 32-31"/><path d="M44 14c-3 7-4 13-3 20"/></svg>
452
586
  </div>`;
453
- wrap.querySelectorAll('.suggest button').forEach((btn) => {
454
- btn.addEventListener('click', () => { $('input').value = btn.textContent; $('input').focus(); grow(); });
455
- });
456
- const setup = wrap.querySelector('#emptySetup');
457
- if (setup) setup.addEventListener('click', openSettings);
587
+
588
+ const tips = $('homeTips');
589
+ if (tips) {
590
+ const provider = state.info?.provider;
591
+ const current = state.info?.providers?.find((item) => item.current);
592
+ const hasKey = Boolean(current?.keyReady);
593
+ tips.innerHTML =
594
+ (hasKey
595
+ ? ''
596
+ : `<div class="empty-warn">
597
+ <strong>还差一步:填上 API Key</strong>
598
+ <span>没有 key 就没法对话。点下面的按钮,填进去立刻就能用(key 只存在你自己的电脑上)。</span>
599
+ <button class="btn primary" id="emptySetup">现在去填</button>
600
+ </div>`) +
601
+ '<div class="suggest">' +
602
+ ['这个仓库是做什么的', '跑一下测试并修复失败', '给我一个重构方案,先别改代码', '把 README 翻译成英文']
603
+ .map((text) => `<button>${escapeHtml(text)}</button>`)
604
+ .join('') +
605
+ '</div>' +
606
+ `<div class="empty-tips">
607
+ <span><b>/</b> 调命令(/help 看全部)</span>
608
+ <span><b>@</b> 引用项目里的文件</span>
609
+ <span><b>${escapeHtml(provider?.name ?? '')}</b> 当前模型</span>
610
+ </div>`;
611
+ tips.querySelectorAll('.suggest button').forEach((btn) => {
612
+ btn.addEventListener('click', () => { $('input').value = btn.textContent; $('input').focus(); grow(); });
613
+ });
614
+ const setup = tips.querySelector('#emptySetup');
615
+ if (setup) setup.addEventListener('click', () => openSettings());
616
+ }
458
617
  return wrap;
459
618
  }
460
619
 
@@ -472,6 +631,7 @@ function renderThread() {
472
631
  if (node) THREAD.appendChild(node);
473
632
  }
474
633
  renderTurnMeta();
634
+ updateHomeMode();
475
635
  // 切换会话时瞬间定位到底部;平滑滚动只用于新消息
476
636
  scrollToEnd(false);
477
637
  }
@@ -542,8 +702,13 @@ async function refresh(path) {
542
702
  state.info = info;
543
703
  const { sessions } = await get('sessions');
544
704
  state.sessions = sessions;
705
+ if (state.info?.session?.id && state.tabs.length) {
706
+ upsertTab(state.info.session.id, state.info.session.title);
707
+ state.activeTab = state.info.session.id;
708
+ }
545
709
  renderHeader();
546
710
  renderSidebar();
711
+ renderTabs();
547
712
  if (path) renderThread();
548
713
  }
549
714
 
@@ -572,7 +737,6 @@ function syncOverlayState() {
572
737
  !$('commandCenter').hidden ||
573
738
  !$('codeViewer').hidden ||
574
739
  !$('settingsSheet').hidden ||
575
- !$('workbench').hidden ||
576
740
  !$('approvalModal').hidden;
577
741
  document.body.classList.toggle('has-overlay', open);
578
742
  }
@@ -583,7 +747,7 @@ function closeTopOverlay() {
583
747
  if (!$('codeViewer').hidden) return closeViewer();
584
748
  if (!$('settingsSheet').hidden) return closeSettings();
585
749
  if (!$('approvalModal').hidden) return closeApproval(false);
586
- if (!$('workbench').hidden) return closeWorkbench();
750
+ if (state.review.open) return closeReview();
587
751
  return false;
588
752
  }
589
753
 
@@ -742,6 +906,7 @@ async function send() {
742
906
  userBody.textContent = text;
743
907
  userMsg.appendChild(userBody);
744
908
  THREAD.appendChild(userMsg);
909
+ updateHomeMode();
745
910
 
746
911
  const botMsg = document.createElement('article');
747
912
  botMsg.className = 'msg assistant enter';
@@ -886,6 +1051,11 @@ async function send() {
886
1051
  row.addEventListener('click', () => toggleToolOutput(row, event.detail, !event.ok));
887
1052
  }
888
1053
  }
1054
+ } else if (event.type === 'status') {
1055
+ if (event.message) {
1056
+ $('composerHint').textContent = event.message;
1057
+ $('threadMeta').textContent = event.message;
1058
+ }
889
1059
  } else if (event.type === 'approval') {
890
1060
  const allowed = await askApproval(event);
891
1061
  await post('approve', { id: event.id, allowed });
@@ -918,8 +1088,9 @@ async function send() {
918
1088
  const { sessions } = await get('sessions');
919
1089
  state.sessions = sessions;
920
1090
  renderSidebar();
1091
+ renderTabs();
921
1092
  renderHeader();
922
- if (state.workbench.open && state.workbench.tab === 'changes') void loadChanges(true);
1093
+ if (state.review.open && state.review.mode === 'changes') void loadChanges(true);
923
1094
  }
924
1095
  }
925
1096
  }
@@ -943,7 +1114,8 @@ async function send() {
943
1114
  finishWork();
944
1115
  setBusy(false);
945
1116
  scrollToEnd();
946
- if (state.workbench.open && state.workbench.tab === 'changes') void loadChanges(true);
1117
+ renderHeader();
1118
+ if (state.review.open && state.review.mode === 'changes') void loadChanges(true);
947
1119
  }
948
1120
  }
949
1121
 
@@ -1117,6 +1289,7 @@ async function runCommand(text) {
1117
1289
  userBody.textContent = text;
1118
1290
  userMsg.appendChild(userBody);
1119
1291
  THREAD.appendChild(userMsg);
1292
+ updateHomeMode();
1120
1293
 
1121
1294
  const botMsg = document.createElement('article');
1122
1295
  botMsg.className = 'msg assistant enter';
@@ -1509,10 +1682,10 @@ function renderTerminalSettings(body) {
1509
1682
  });
1510
1683
  card.appendChild(settingsRow('默认 Shell', '新建终端标签时使用。', shellSelect));
1511
1684
 
1512
- const isTermOpen = state.workbench.open && state.workbench.tab === 'terminal';
1685
+ const isTermOpen = state.dock.open;
1513
1686
  const openBtn = settingsButton(isTermOpen ? '收起终端' : '打开终端', () => {
1514
1687
  closeSettings();
1515
- if (isTermOpen) closeWorkbench();
1688
+ if (isTermOpen) termClose();
1516
1689
  else termOpen();
1517
1690
  }, true);
1518
1691
  card.appendChild(settingsRow('终端面板', '在右侧工作台打开,不会挡住输入框。快捷键 Ctrl+`。', openBtn));
@@ -1769,113 +1942,144 @@ function renderAboutSettings(body) {
1769
1942
  body.appendChild(section);
1770
1943
  }
1771
1944
 
1772
- // ---------------- 右侧工作台 ----------------
1773
- const WB_STORAGE = { width: 'banana.wb.width', tab: 'banana.wb.tab' };
1774
- const WB_MIN = 340;
1775
- const WB_DEFAULT = 420;
1945
+ // ---------------- 外壳:侧栏 / Review / 终端 dock ----------------
1946
+ const SHELL_STORAGE = {
1947
+ sidebarWidth: 'banana.sidebar.width',
1948
+ sidebarOpen: 'banana.sidebar.open',
1949
+ reviewWidth: 'banana.review.width',
1950
+ reviewMode: 'banana.review.mode',
1951
+ tabState: 'banana.tabs.v1',
1952
+ dockHeight: 'banana.dock.height',
1953
+ dockOpen: 'banana.dock.open',
1954
+ };
1776
1955
 
1777
- function wbApplyWidth(width) {
1778
- const max = Math.max(WB_MIN, Math.min(820, window.innerWidth * 0.72));
1779
- state.workbench.width = Math.max(WB_MIN, Math.min(Number(width) || WB_DEFAULT, max));
1780
- $('workbench').style.setProperty('--wb-width', `${Math.round(state.workbench.width)}px`);
1956
+ function applySidebarWidth(width) {
1957
+ const max = Math.max(240, Math.min(430, window.innerWidth * 0.42));
1958
+ state.sidebar.width = Math.max(240, Math.min(Number(width) || 344, max));
1959
+ document.documentElement.style.setProperty('--sidebar', `${Math.round(state.sidebar.width)}px`);
1781
1960
  }
1782
1961
 
1783
- function wbIsNarrow() {
1784
- return window.matchMedia('(max-width: 980px)').matches;
1962
+ function setSidebar(open) {
1963
+ state.sidebar.open = Boolean(open);
1964
+ $('sidebar').classList.toggle('open', state.sidebar.open && window.matchMedia('(max-width: 900px)').matches);
1965
+ $('sidebar').hidden = !state.sidebar.open && !window.matchMedia('(max-width: 900px)').matches;
1966
+ if (window.matchMedia('(max-width: 900px)').matches) $('sidebar').hidden = false;
1967
+ try {
1968
+ localStorage.setItem(SHELL_STORAGE.sidebarOpen, state.sidebar.open ? '1' : '0');
1969
+ } catch {}
1785
1970
  }
1786
1971
 
1787
- function openWorkbench(tab) {
1788
- const wb = $('workbench');
1789
- if (tab) state.workbench.tab = tab;
1790
- wb.classList.remove('is-closing');
1791
- wb.hidden = false;
1792
- state.workbench.open = true;
1793
- wbApplyWidth(state.workbench.width);
1794
- // 强制读一次布局,保证从 0 宽开始过渡(不依赖可能被后台节流的 rAF)
1795
- void wb.offsetWidth;
1796
- wb.classList.add('open');
1797
- setWorkbenchTab(state.workbench.tab);
1798
- $('wbScrim').hidden = !wbIsNarrow();
1799
- syncOverlayState();
1972
+ function toggleSidebar() {
1973
+ setSidebar(!state.sidebar.open || (window.matchMedia('(max-width: 900px)').matches && !$('sidebar').classList.contains('open')));
1800
1974
  }
1801
1975
 
1802
- function closeWorkbench() {
1803
- const wb = $('workbench');
1804
- if (!state.workbench.open) return;
1805
- state.workbench.open = false;
1806
- wb.classList.remove('open');
1807
- wb.classList.add('is-closing');
1808
- $('wbScrim').hidden = true;
1809
- setTimeout(() => {
1810
- if (state.workbench.open) return;
1811
- wb.hidden = true;
1812
- wb.classList.remove('is-closing');
1813
- syncOverlayState();
1814
- $('input')?.focus();
1815
- }, 280);
1976
+ function applyReviewWidth(width) {
1977
+ const max = Math.max(480, Math.min(800, window.innerWidth * 0.7));
1978
+ state.review.width = Math.max(480, Math.min(Number(width) || 520, max));
1979
+ $('reviewPanel').style.setProperty('--review-width', `${Math.round(state.review.width)}px`);
1816
1980
  }
1817
1981
 
1818
- function setWorkbenchTab(tab) {
1819
- if (!['terminal', 'files', 'changes'].includes(tab)) tab = 'terminal';
1820
- state.workbench.tab = tab;
1982
+ function setReviewMode(mode) {
1983
+ if (!['changes', 'files'].includes(mode)) mode = 'changes';
1984
+ state.review.mode = mode;
1821
1985
  try {
1822
- localStorage.setItem(WB_STORAGE.tab, tab);
1986
+ localStorage.setItem(SHELL_STORAGE.reviewMode, mode);
1823
1987
  } catch {}
1824
- $('wbTabs').querySelectorAll('.wb-tab').forEach((node) => {
1825
- const on = node.dataset.tab === tab;
1826
- node.classList.toggle('is-on', on);
1827
- node.setAttribute('aria-selected', String(on));
1828
- });
1829
- $('workbench').querySelectorAll('.wb-pane').forEach((node) => {
1830
- node.classList.toggle('active', node.dataset.pane === tab);
1831
- });
1832
- if (tab === 'terminal') {
1833
- termEnsure();
1834
- requestAnimationFrame(() => termFitActive());
1835
- } else if (tab === 'files') {
1988
+ $('reviewTabChanges').classList.toggle('is-on', mode === 'changes');
1989
+ $('reviewTabFiles').classList.toggle('is-on', mode === 'files');
1990
+ $('changesPanel').classList.toggle('is-on', mode === 'changes');
1991
+ $('filesPanel').classList.toggle('is-on', mode === 'files');
1992
+ if (mode === 'changes') void loadChanges();
1993
+ else {
1994
+ applyFilesLayout();
1995
+ $('filesTitle').textContent = `工作区文件 · ${state.info?.workspace ?? ''}`;
1836
1996
  if (!$('filesTree').childElementCount) void loadTree('.');
1837
- } else if (tab === 'changes') {
1838
- void loadChanges(true);
1839
1997
  }
1840
1998
  }
1841
1999
 
1842
- function initWorkbench() {
2000
+ function openReview(mode = 'changes') {
2001
+ state.review.open = true;
2002
+ const panel = $('reviewPanel');
2003
+ panel.classList.remove('is-closing');
2004
+ panel.hidden = false;
2005
+ applyReviewWidth(state.review.width);
2006
+ setReviewMode(mode);
2007
+ syncOverlayState();
2008
+ }
2009
+
2010
+ function closeReview() {
2011
+ const panel = $('reviewPanel');
2012
+ if (!state.review.open) return;
2013
+ state.review.open = false;
2014
+ panel.classList.add('is-closing');
2015
+ setTimeout(() => {
2016
+ if (state.review.open) return;
2017
+ panel.hidden = true;
2018
+ panel.classList.remove('is-closing');
2019
+ syncOverlayState();
2020
+ $('input')?.focus();
2021
+ termFitActive();
2022
+ }, 200);
2023
+ }
2024
+
2025
+ function initShell() {
1843
2026
  try {
1844
- const savedWidth = Number(localStorage.getItem(WB_STORAGE.width));
1845
- if (savedWidth) state.workbench.width = savedWidth;
1846
- const savedTab = localStorage.getItem(WB_STORAGE.tab);
1847
- if (savedTab) state.workbench.tab = savedTab;
1848
- } catch {}
1849
- wbApplyWidth(state.workbench.width);
1850
- $('wbTabs').querySelectorAll('.wb-tab').forEach((node) => {
1851
- node.addEventListener('click', () => setWorkbenchTab(node.dataset.tab));
2027
+ const sw = Number(localStorage.getItem(SHELL_STORAGE.sidebarWidth));
2028
+ applySidebarWidth(sw || 344);
2029
+ const rw = Number(localStorage.getItem(SHELL_STORAGE.reviewWidth));
2030
+ applyReviewWidth(rw || 520);
2031
+ const rm = localStorage.getItem(SHELL_STORAGE.reviewMode);
2032
+ if (rm) state.review.mode = rm;
2033
+ const dh = Number(localStorage.getItem(SHELL_STORAGE.dockHeight));
2034
+ if (dh) state.dock.height = Math.max(120, Math.min(dh, window.innerHeight * 0.6));
2035
+ const openSidebar = localStorage.getItem(SHELL_STORAGE.sidebarOpen);
2036
+ state.sidebar.open = openSidebar === null ? window.innerWidth > 900 : openSidebar !== '0';
2037
+ } catch {
2038
+ applySidebarWidth(344);
2039
+ applyReviewWidth(520);
2040
+ }
2041
+ setSidebar(state.sidebar.open);
2042
+ $('reviewTabChanges').addEventListener('click', () => setReviewMode('changes'));
2043
+ $('reviewTabFiles').addEventListener('click', () => setReviewMode('files'));
2044
+ $('reviewClose').addEventListener('click', closeReview);
2045
+ $('reviewRefresh').addEventListener('click', () => {
2046
+ state.changes = null;
2047
+ if (state.review.mode === 'changes') void loadChanges(true);
2048
+ else $('filesTree').innerHTML = '', void loadTree('.');
2049
+ });
2050
+ $('sbResizer').addEventListener('pointerdown', (event) => {
2051
+ event.preventDefault();
2052
+ const move = (moveEvent) => applySidebarWidth(moveEvent.clientX - 8);
2053
+ const up = () => {
2054
+ window.removeEventListener('pointermove', move);
2055
+ window.removeEventListener('pointerup', up);
2056
+ try {
2057
+ localStorage.setItem(SHELL_STORAGE.sidebarWidth, String(Math.round(state.sidebar.width)));
2058
+ } catch {}
2059
+ };
2060
+ window.addEventListener('pointermove', move);
2061
+ window.addEventListener('pointerup', up);
1852
2062
  });
1853
- $('wbClose').addEventListener('click', closeWorkbench);
1854
- $('wbScrim').addEventListener('click', closeWorkbench);
1855
- $('changesRefresh').addEventListener('click', () => loadChanges(true));
1856
- $('wbResizer').addEventListener('pointerdown', (event) => {
2063
+ $('reviewResizer').addEventListener('pointerdown', (event) => {
1857
2064
  event.preventDefault();
1858
- $('workbench').classList.add('is-resizing');
1859
2065
  const move = (moveEvent) => {
1860
- wbApplyWidth(window.innerWidth - moveEvent.clientX);
1861
- if (state.workbench.tab === 'terminal') termFitActive();
2066
+ applyReviewWidth(window.innerWidth - 8 - moveEvent.clientX);
2067
+ termFitActive();
1862
2068
  };
1863
2069
  const up = () => {
1864
2070
  window.removeEventListener('pointermove', move);
1865
2071
  window.removeEventListener('pointerup', up);
1866
- $('workbench').classList.remove('is-resizing');
1867
2072
  try {
1868
- localStorage.setItem(WB_STORAGE.width, String(Math.round(state.workbench.width)));
2073
+ localStorage.setItem(SHELL_STORAGE.reviewWidth, String(Math.round(state.review.width)));
1869
2074
  } catch {}
1870
- if (state.workbench.tab === 'terminal') termFitActive();
2075
+ termFitActive();
1871
2076
  };
1872
2077
  window.addEventListener('pointermove', move);
1873
2078
  window.addEventListener('pointerup', up);
1874
2079
  });
1875
2080
  window.addEventListener('resize', () => {
1876
- wbApplyWidth(state.workbench.width);
1877
- $('wbScrim').hidden = !state.workbench.open || !wbIsNarrow();
1878
- if (state.workbench.tab === 'terminal') termFitActive();
2081
+ applyReviewWidth(state.review.width);
2082
+ termFitActive();
1879
2083
  });
1880
2084
  }
1881
2085
 
@@ -1899,14 +2103,18 @@ async function loadChanges(force = false) {
1899
2103
  function renderChanges() {
1900
2104
  const list = $('changesList');
1901
2105
  const detail = $('changesDetail');
2106
+ const stats = $('reviewStats');
1902
2107
  list.innerHTML = '';
1903
2108
  const turns = state.changes?.turns ?? [];
1904
2109
  if (!turns.length) {
1905
- list.innerHTML = '<div class="hint" style="padding:12px">还没有文件改动。让 Banana 改一次文件后,这里会出现增删行。</div>';
2110
+ stats.textContent = '';
2111
+ list.innerHTML = '<div class="changes-empty"><strong>还没有改动</strong><span>让 Banana 改一次文件后,这里会出现增删行。</span></div>';
1906
2112
  detail.innerHTML = '<div class="hint">没有可显示的改动。</div>';
1907
2113
  return;
1908
2114
  }
1909
2115
  let total = 0;
2116
+ let additions = 0;
2117
+ let deletions = 0;
1910
2118
  for (const turn of turns) {
1911
2119
  const group = document.createElement('div');
1912
2120
  group.className = 'changes-group';
@@ -1916,9 +2124,12 @@ function renderChanges() {
1916
2124
  group.appendChild(head);
1917
2125
  for (const file of turn.files) {
1918
2126
  total += 1;
2127
+ additions += file.additions;
2128
+ deletions += file.deletions;
1919
2129
  const button = document.createElement('button');
1920
2130
  button.className = 'change-file';
1921
2131
  button.innerHTML =
2132
+ `<span class="change-status ${file.missing ? 'deleted' : file.existed ? 'modified' : 'added'}">${file.missing ? 'D' : file.existed ? 'M' : 'A'}</span>` +
1922
2133
  `<span class="cf-name">${escapeHtml(file.path)}</span>` +
1923
2134
  `<span class="cf-count"><i class="plus">+${file.additions}</i><i class="minus">-${file.deletions}</i></span>`;
1924
2135
  button.addEventListener('click', () => {
@@ -1930,7 +2141,7 @@ function renderChanges() {
1930
2141
  }
1931
2142
  list.appendChild(group);
1932
2143
  }
1933
- $('changesTitle').textContent = `本会话改动 · ${total} 个文件`;
2144
+ stats.innerHTML = `<i class="plus">+${additions}</i> <i class="minus">-${deletions}</i> · ${total} 个文件`;
1934
2145
  // 默认打开第一个有内容的文件
1935
2146
  const firstFile = turns.find((turn) => turn.files.length)?.files[0];
1936
2147
  if (firstFile) {
@@ -2043,21 +2254,21 @@ function applyFilesLayout() {
2043
2254
 
2044
2255
  function openFiles() {
2045
2256
  applyFilesLayout();
2046
- $('filesTitle').textContent = `项目文件 · ${state.info?.workspace ?? ''}`;
2047
- openWorkbench('files');
2257
+ $('filesTitle').textContent = `工作区文件 · ${state.info?.workspace ?? ''}`;
2258
+ openReview('files');
2048
2259
  }
2049
2260
 
2050
2261
  function openChanges() {
2051
- openWorkbench('changes');
2262
+ openReview('changes');
2052
2263
  void loadChanges(true);
2053
2264
  }
2054
2265
 
2055
2266
  function closeFiles() {
2056
- closeWorkbench();
2267
+ closeReview();
2057
2268
  }
2058
2269
 
2059
2270
  function toggleFiles() {
2060
- if (!$('workbench').hidden && state.workbench.tab === 'files') closeWorkbench();
2271
+ if (state.review.open && state.review.mode === 'files') closeReview();
2061
2272
  else openFiles();
2062
2273
  }
2063
2274
 
@@ -2193,7 +2404,7 @@ function termTheme() {
2193
2404
  foreground: dark ? '#d9d9d4' : '#26262b',
2194
2405
  cursor: dark ? '#e8e8e2' : '#16161a',
2195
2406
  cursorAccent: dark ? '#101013' : '#fbfbfa',
2196
- selectionBackground: dark ? 'rgba(120, 150, 220, 0.28)' : 'rgba(70, 110, 190, 0.18)',
2407
+ selectionBackground: dark ? 'rgba(220, 222, 141, 0.3)' : 'rgba(138, 109, 18, 0.18)',
2197
2408
  black: dark ? '#2a2a30' : '#3a3a40',
2198
2409
  red: dark ? '#d1786e' : '#b4453a',
2199
2410
  green: dark ? '#86b98d' : '#2f7d50',
@@ -2218,7 +2429,7 @@ function termActiveTab() {
2218
2429
  }
2219
2430
 
2220
2431
  function termFitActive() {
2221
- if ($('workbench').hidden || state.workbench.tab !== 'terminal') return;
2432
+ if ($('terminalPanel').hidden) return;
2222
2433
  const tab = termActiveTab();
2223
2434
  if (!tab) return;
2224
2435
  try {
@@ -2229,6 +2440,18 @@ function termFitActive() {
2229
2440
  }
2230
2441
  }
2231
2442
 
2443
+ function applyDockHeight(height, persist = true) {
2444
+ const max = Math.max(160, window.innerHeight * 0.6);
2445
+ state.dock.height = Math.max(120, Math.min(Number(height) || 280, max));
2446
+ $('terminalPanel').style.setProperty('--term-height', `${Math.round(state.dock.height)}px`);
2447
+ if (persist) {
2448
+ try {
2449
+ localStorage.setItem(SHELL_STORAGE.dockHeight, String(Math.round(state.dock.height)));
2450
+ } catch {}
2451
+ }
2452
+ requestAnimationFrame(() => termFitActive());
2453
+ }
2454
+
2232
2455
  function termUpdateCwd(cwd) {
2233
2456
  const label = $('termCwd');
2234
2457
  if (label && cwd) label.textContent = cwd;
@@ -2463,21 +2686,41 @@ function termEnsure() {
2463
2686
  }
2464
2687
 
2465
2688
  function termOpen() {
2466
- openWorkbench('terminal');
2689
+ const panel = $('terminalPanel');
2690
+ state.dock.open = true;
2691
+ panel.classList.remove('is-closing');
2692
+ panel.hidden = false;
2693
+ applyDockHeight(state.dock.height, false);
2467
2694
  termUpdateCwd(state.info?.workspace ?? '');
2695
+ termEnsure();
2696
+ try {
2697
+ localStorage.setItem(SHELL_STORAGE.dockOpen, '1');
2698
+ } catch {}
2468
2699
  setTimeout(() => {
2469
2700
  termFitActive();
2470
2701
  termActiveTab()?.terminal.focus();
2471
- }, 320);
2702
+ }, 220);
2472
2703
  }
2473
2704
 
2474
2705
  function termClose() {
2475
- closeWorkbench();
2706
+ const panel = $('terminalPanel');
2707
+ if (!state.dock.open) return;
2708
+ state.dock.open = false;
2709
+ panel.classList.add('is-closing');
2710
+ try {
2711
+ localStorage.setItem(SHELL_STORAGE.dockOpen, '0');
2712
+ } catch {}
2713
+ setTimeout(() => {
2714
+ if (state.dock.open) return;
2715
+ panel.hidden = true;
2716
+ panel.classList.remove('is-closing');
2717
+ $('input')?.focus();
2718
+ }, 200);
2476
2719
  }
2477
2720
 
2478
2721
  function termToggle() {
2479
- if (!state.workbench.open || state.workbench.tab !== 'terminal') termOpen();
2480
- else closeWorkbench();
2722
+ if (state.dock.open) termClose();
2723
+ else termOpen();
2481
2724
  }
2482
2725
 
2483
2726
  function termRestyle() {
@@ -2528,7 +2771,25 @@ function bindTerminal() {
2528
2771
  });
2529
2772
 
2530
2773
  const observer = new ResizeObserver(() => termFitActive());
2531
- observer.observe($('workbench'));
2774
+ observer.observe($('terminalPanel'));
2775
+
2776
+ $('termResizer').addEventListener('pointerdown', (event) => {
2777
+ event.preventDefault();
2778
+ const startY = event.clientY;
2779
+ const startHeight = state.dock.height;
2780
+ const move = (moveEvent) => {
2781
+ applyDockHeight(startHeight - (moveEvent.clientY - startY), false);
2782
+ termFitActive();
2783
+ };
2784
+ const up = () => {
2785
+ window.removeEventListener('pointermove', move);
2786
+ window.removeEventListener('pointerup', up);
2787
+ applyDockHeight(state.dock.height);
2788
+ if (state.dock.height <= 150) termClose();
2789
+ };
2790
+ window.addEventListener('pointermove', move);
2791
+ window.addEventListener('pointerup', up);
2792
+ });
2532
2793
 
2533
2794
  document.addEventListener('banana:theme', () => {
2534
2795
  if (term.tabs.length) termRestyle();
@@ -2861,15 +3122,18 @@ function initDesktop() {
2861
3122
 
2862
3123
  function bind() {
2863
3124
  $('newChat').innerHTML = svg('plus');
3125
+ $('newTab').innerHTML = svg('plus');
3126
+ $('searchBtn').innerHTML = svg('search');
2864
3127
  $('sendBtn').innerHTML = svg('send');
2865
3128
  $('abortBtn').innerHTML = svg('stop');
2866
- $('toggleSidebar').innerHTML = svg('list');
3129
+ $('toggleSidebar').innerHTML = '<svg class="brand-mark" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.6c2.6-.4 4.9.5 6.3 2.6M12.3 7.2c1.4-2.1 3.7-3 6.3-2.6-1.1 1.6-1.2 3.1-.4 4.6.8 1.5.8 3-.1 4.5-1.1 2-3.3 3.5-5.9 4.3-2.3.7-4.7.7-7.1 0"/><path d="M7.2 18c-1.1-2-1.2-4-.4-6 .9-2.3 2.5-3.7 4.7-4.2"/></svg>';
2867
3130
  $('closeSettings').innerHTML = svg('x');
3131
+ $('reviewClose').innerHTML = svg('x');
3132
+ $('reviewRefresh').innerHTML = svg('refresh');
2868
3133
  $('filesBtn').innerHTML = svg('panel');
2869
- $('changesBtn').innerHTML = svg('diff');
2870
- $('wbClose').innerHTML = svg('x');
3134
+ $('reviewBtn').innerHTML = svg('diff');
2871
3135
  $('pluginsBtn').innerHTML = svg('plug');
2872
- initWorkbench();
3136
+ initShell();
2873
3137
  initFileResize();
2874
3138
  bindTerminal();
2875
3139
  initTooltips();
@@ -2888,6 +3152,7 @@ function bind() {
2888
3152
  renderHeader();
2889
3153
  renderSidebar();
2890
3154
  renderThread();
3155
+ setActiveTab(state.info?.session?.id ?? null);
2891
3156
  $('input').focus();
2892
3157
  } catch (error) {
2893
3158
  toast('新建对话失败:' + (error?.message ?? error));
@@ -2903,9 +3168,15 @@ function bind() {
2903
3168
  $('settingsSheet').addEventListener('mousedown', (event) => {
2904
3169
  if (event.target === $('settingsSheet')) closeSettings();
2905
3170
  });
2906
- $('toggleSidebar').addEventListener('click', () => $('sidebar').classList.toggle('open'));
3171
+ $('toggleSidebar').addEventListener('click', toggleSidebar);
2907
3172
  $('filesBtn').addEventListener('click', toggleFiles);
2908
- $('changesBtn').addEventListener('click', openChanges);
3173
+ $('reviewBtn').addEventListener('click', () => {
3174
+ if (state.review.open && state.review.mode === 'changes') closeReview();
3175
+ else openChanges();
3176
+ });
3177
+ $('searchBtn').addEventListener('click', openCommandCenter);
3178
+ $('openSettingsFoot').addEventListener('click', () => openSettings());
3179
+ $('newTab').addEventListener('click', () => $('newChat').click());
2909
3180
  $('themeBtn').addEventListener('click', cycleTheme);
2910
3181
  $('pluginsBtn').addEventListener('click', () => openSettings('plugins'));
2911
3182
  $('previewWrap').addEventListener('click', toggleWrap);
@@ -2936,7 +3207,7 @@ function bind() {
2936
3207
  const result = await post('undo');
2937
3208
  toast(result.had ? `已回滚:恢复 ${result.restored} 个文件` : '没有可回滚的改动');
2938
3209
  state.changes = null;
2939
- if (state.workbench.open && state.workbench.tab === 'changes') void loadChanges(true);
3210
+ if (state.review.open && state.review.mode === 'changes') void loadChanges(true);
2940
3211
  });
2941
3212
 
2942
3213
  $('composer').addEventListener('submit', (event) => { event.preventDefault(); send(); });
@@ -3037,8 +3308,23 @@ async function boot() {
3037
3308
  commandList = [];
3038
3309
  }
3039
3310
  await refresh(true);
3311
+ initTabs();
3312
+ $('workspaceLabel').textContent = state.info?.workspace ?? '';
3313
+ updateHomeMode();
3314
+ try {
3315
+ if (localStorage.getItem(SHELL_STORAGE.dockOpen) === '1') termOpen();
3316
+ } catch {}
3040
3317
  grow();
3041
3318
  $('input').focus();
3042
3319
  }
3043
3320
 
3321
+ function updateHomeMode() {
3322
+ const card = $('chatCard');
3323
+ if (!card) return;
3324
+ const empty = !(state.info?.session?.messages?.length);
3325
+ card.classList.toggle('home-mode', empty);
3326
+ const tips = $('homeTips');
3327
+ if (tips) tips.hidden = !empty;
3328
+ }
3329
+
3044
3330
  boot().catch((error) => toast('加载失败:' + error.message));