dsh-neotui 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Neo-TUI: mouse-driven terminal UI client for DeepSeek Harness (B-tier per dsh-tui-design.md)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/panels.js CHANGED
@@ -1216,6 +1216,8 @@ export class ControlPanel extends Widget {
1216
1216
  ["n", "新建会话", () => this.app.newSession()],
1217
1217
  ["g g", "滚动到顶", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = 0; }],
1218
1218
  ["G", "滚动到底", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = this.app.chat.view.maxScroll(); }],
1219
+ ["[", "上一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "[", text: "[", ctrl: false, alt: false, shift: false }); }],
1220
+ ["]", "下一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "]", text: "]", ctrl: false, alt: false, shift: false }); }],
1219
1221
  ["Ctrl+P", "控制面板", () => { this.page = 1; this.sel = 0; this.app.redraw(); }],
1220
1222
  ["Ctrl+M", "切换模型", () => { this.app.overlay = buildModelPicker(this.app); }],
1221
1223
  ["Ctrl+T", "轨迹视图", () => { this.app.closeOverlay(); this.app.setMode("trajectory"); }],
package/src/views.js CHANGED
@@ -784,8 +784,8 @@ export class ChatView extends Widget {
784
784
  this.view.follow = true;
785
785
  }
786
786
 
787
- async loadOlder() {
788
- if (!this.hasMore || this.loadingOlder || this.minSeq == null) return;
787
+ async loadOlder(onDone = null) {
788
+ if (!this.hasMore || this.loadingOlder || this.minSeq == null) { onDone?.(); return; }
789
789
  this.loadingOlder = true;
790
790
  this.app.setStatus("加载更早记录…");
791
791
  try {
@@ -803,6 +803,51 @@ export class ChatView extends Widget {
803
803
  }
804
804
  this.loadingOlder = false;
805
805
  this.#rebuild();
806
+ onDone?.();
807
+ }
808
+
809
+ /** [ / ] — jump the viewport to the END of the previous (dir -1) or next
810
+ * (dir +1) user question. `previous` walks to the question entirely above
811
+ * the viewport top (loading older history first when needed); `next` walks
812
+ * to the first question that starts below the viewport top. */
813
+ #jumpQuestion(dir) {
814
+ this.flushRebuild(); // lineMap must reflect the current nodes array
815
+ const top = this.view.scrollY;
816
+ const qs = [];
817
+ for (let i = 0; i < this.nodes.length; i++) {
818
+ if (this.nodes[i]?.kind !== "user") continue;
819
+ let first = -1, last = -1;
820
+ for (let li = 0; li < this.lineMap.length; li++) {
821
+ if (this.lineMap[li]?.nodeIdx === i) { if (first < 0) first = li; last = li; }
822
+ }
823
+ if (first < 0) continue; // node outside the rendered window
824
+ qs.push({ first, last });
825
+ }
826
+ let target = null;
827
+ if (dir < 0) {
828
+ for (let k = qs.length - 1; k >= 0; k--) {
829
+ if (qs[k].last < top) { target = qs[k]; break; }
830
+ }
831
+ if (!target && this.hasMore && this.minSeq != null) {
832
+ this.loadOlder(() => this.#jumpQuestion(-1)); // after the prepend, retry against the fuller window
833
+ return true;
834
+ }
835
+ } else {
836
+ target = qs.find((q) => q.first > top) ?? null;
837
+ }
838
+ if (!target) {
839
+ this.app.toast(dir < 0 ? "已到最早的问题" : "已到最后的问题");
840
+ return false;
841
+ }
842
+ // the question's last CONTENT line (skip trailing blank separator rows);
843
+ // put it ~3 rows into the viewport, never above the question's own start
844
+ let end = target.last;
845
+ while (end > target.first && !(this.lines[end] ?? []).some((g) => g.t.trim() !== "")) end--;
846
+ this.view.anchorLock = null;
847
+ this.view.follow = false;
848
+ this.view.scrollY = Math.max(0, Math.max(target.first, end - 3));
849
+ this.app.redraw();
850
+ return true;
806
851
  }
807
852
 
808
853
  onFrame(frame) {
@@ -1540,6 +1585,11 @@ export class ChatView extends Widget {
1540
1585
  return true;
1541
1586
  }
1542
1587
  if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.anchorLock = null; this.view.follow = true; this.view.scrollY = this.view.maxScroll(); return true; }
1588
+ // [ / ] — jump to the end of the previous / next user question. Guarded by
1589
+ // focus so the sidebar's session-move [ ] keys keep working there.
1590
+ if (ev.name === "char" && (ev.key === "[" || ev.key === "]") && !ev.ctrl && !ev.shift && this.app.focused === this) {
1591
+ return this.#jumpQuestion(ev.key === "[" ? -1 : 1);
1592
+ }
1543
1593
  if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
1544
1594
  if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.app.redraw(); return true; }
1545
1595
  if (ev.name === "char" && ev.key === "i" && !ev.ctrl) { this.app.focus(this.input); return true; }
@@ -1768,6 +1818,8 @@ export class App {
1768
1818
  this.overlay = null; // Picker / Popup / ImagePopup modal
1769
1819
  this.mode = "chat"; // chat | workspace | trajectory
1770
1820
  this.sidebarVisible = true; // Ctrl+B hides the whole session pane (nvim-style)
1821
+ this.sidebarWidth = 30; // draggable divider: the session pane's column width
1822
+ this.draggingDivider = false;
1771
1823
  this.feedbackMap = new Map(); // messageId → {rating, version}
1772
1824
  this.searchQuery = null; // active find-in-conversation term (highlight)
1773
1825
  this.findQuery = null; // term being typed in the find picker
@@ -1779,10 +1831,10 @@ export class App {
1779
1831
  this.skillsPanel = null;
1780
1832
 
1781
1833
  this.sidebar = new SidebarTree(this);
1782
- this.sidebar.w = 30;
1834
+ this.sidebar.w = this.sidebarWidth;
1783
1835
  this.sidebar.h = screen.h - 1;
1784
- this.searchInput = new Input({ x: 0, y: 0, w: 30, h: 1, prompt: "/ ", placeholder: "搜索会话…" });
1785
- this.chat = new ChatView({ x: 30, y: 0, w: screen.w - 30, h: screen.h - 1, app: this });
1836
+ this.searchInput = new Input({ x: 0, y: 0, w: this.sidebarWidth, h: 1, prompt: "/ ", placeholder: "搜索会话…" });
1837
+ this.chat = new ChatView({ x: this.sidebarWidth, y: 0, w: screen.w - this.sidebarWidth, h: screen.h - 1, app: this });
1786
1838
  this.status = new StatusBar({ x: 0, y: screen.h - 1, w: screen.w, h: 1 });
1787
1839
  this.focus(this.chat);
1788
1840
  this.layout();
@@ -1795,11 +1847,12 @@ export class App {
1795
1847
  }
1796
1848
 
1797
1849
  layout() {
1798
- const x = this.sidebarVisible ? 30 : 0;
1850
+ const x = this.sidebarVisible ? this.sidebarWidth : 0;
1799
1851
  const w = this.screen.w - x;
1800
1852
  const footerH = this.footerHeight();
1801
1853
  const mainH = this.screen.h - 1 - footerH;
1802
- this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = 30; this.sidebar.h = this.screen.h - 1;
1854
+ this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
1855
+ this.searchInput.w = this.sidebarWidth;
1803
1856
  this.chat.resize(x, 1, w, mainH);
1804
1857
  for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.subagentPanel, this.skillsPanel]) {
1805
1858
  if (p?.relayout) p.relayout(x, 1, w, mainH);
@@ -2554,7 +2607,7 @@ export class App {
2554
2607
  }
2555
2608
 
2556
2609
  #renderTabBar(s) {
2557
- const x = this.sidebarVisible ? 30 : 0;
2610
+ const x = this.sidebarVisible ? this.sidebarWidth : 0;
2558
2611
  const w = this.screen.w - x;
2559
2612
  s.fillRect(x, 0, x + w - 1, 0, " ", { bg: T.PANEL });
2560
2613
  const tabs = [...this.#modeTabs()];
@@ -2571,7 +2624,7 @@ export class App {
2571
2624
  }
2572
2625
 
2573
2626
  #clickTab(px) {
2574
- const x = this.sidebarVisible ? 30 : 0;
2627
+ const x = this.sidebarVisible ? this.sidebarWidth : 0;
2575
2628
  const tabs = [...this.#modeTabs()];
2576
2629
  const panelLabel = this.#panelLabel(this.mode);
2577
2630
  if (panelLabel) tabs.push([this.mode, panelLabel]);
@@ -2661,7 +2714,7 @@ export class App {
2661
2714
  }
2662
2715
 
2663
2716
  // tab bar clicks (row 0 of the main area)
2664
- if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? 30 : 0)) {
2717
+ if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? this.sidebarWidth : 0)) {
2665
2718
  if (this.#clickTab(ev.x)) { this.redraw(); return; }
2666
2719
  }
2667
2720
  // mouse routes by position (click = focus + dispatch)
@@ -2679,6 +2732,23 @@ export class App {
2679
2732
  // unhandled keys fall through to global shortcuts
2680
2733
  }
2681
2734
  if (ev.type === "mouse") {
2735
+ // Draggable sidebar divider: press on the boundary column (±1) starts a
2736
+ // resize; drag events (motion flag) update the width live.
2737
+ const divX = this.sidebarVisible ? this.sidebarWidth : -1;
2738
+ const onDivider = divX >= 0 && ev.y >= 1 && ev.x >= divX - 1 && ev.x <= divX + 1;
2739
+ if (ev.kind === "press" && ev.button === 0 && onDivider) {
2740
+ this.draggingDivider = true;
2741
+ this.redraw();
2742
+ return;
2743
+ }
2744
+ if (this.draggingDivider) {
2745
+ if (ev.kind === "drag" && ev.button === 0) {
2746
+ const nw = Math.max(14, Math.min(ev.x, Math.floor(this.screen.w * 0.6)));
2747
+ if (nw !== this.sidebarWidth) { this.sidebarWidth = nw; this.layout(); this.redraw(); }
2748
+ return;
2749
+ }
2750
+ if (ev.kind === "release" && ev.button === 0) { this.draggingDivider = false; this.redraw(); return; }
2751
+ }
2682
2752
  // Pure mouse motion must never change focus/mode (vim-style: INSERT is
2683
2753
  // keyboard-only). It reaches just the already-focused widget (drag select).
2684
2754
  if (ev.motion) {