dsh-neotui 0.0.3 → 0.0.4

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/panels.js +116 -4
  3. package/src/views.js +62 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
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": { "dsh-neotui": "bin/dsh-tui.js" },
package/src/panels.js CHANGED
@@ -272,7 +272,7 @@ export class WorkspacePanel extends Widget {
272
272
  this.workspaces = items;
273
273
  const tree = [];
274
274
  for (const ws of items) {
275
- tree.push({ depth: 0, name: `▣ ${ws.title}`, path: ws.path, isDir: true, open: false, ws: true });
275
+ tree.push({ depth: 0, name: `▣ ${ws.title}`, title: ws.title, path: ws.path, isDir: true, open: false, ws: true, workspaceId: ws.workspaceId });
276
276
  }
277
277
  this.tree = tree;
278
278
  this.rebuildTree();
@@ -319,6 +319,25 @@ export class WorkspacePanel extends Widget {
319
319
  } catch { node.children = []; }
320
320
  }
321
321
  onMouse(ev) {
322
+ if (ev.kind === "press" && ev.button === 2) {
323
+ // Right-click anywhere in the panel: workspace actions (add is the
324
+ // primary one; tree rows also offer move/rename).
325
+ const idx = this.treeScroll.scrollY + (ev.y - this.treeScroll.y);
326
+ const node = this.treeLinesNode(idx);
327
+ if (node?.ws) {
328
+ this.app.openMenu([
329
+ { label: "添加工作区…", action: () => this.app.addWorkspace() },
330
+ { label: "重命名工作区", action: () => this.app.renameWorkspace(node) },
331
+ { label: "上移工作区", action: () => this.app.moveWorkspace(node, -1) },
332
+ { label: "下移工作区", action: () => this.app.moveWorkspace(node, 1) },
333
+ ], ev);
334
+ } else {
335
+ this.app.openMenu([
336
+ { label: "添加工作区…", action: () => this.app.addWorkspace() },
337
+ ], ev);
338
+ }
339
+ return true;
340
+ }
322
341
  if (ev.x >= this.x + 1 && ev.x < this.x + Math.floor(this.w / 2)) {
323
342
  const idx = this.treeScroll.scrollY + (ev.y - this.treeScroll.y);
324
343
  const node = this.treeLinesNode(idx);
@@ -378,11 +397,11 @@ export class WorkspacePanel extends Widget {
378
397
  }
379
398
  }
380
399
  render(screen) {
381
- screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
400
+ screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
382
401
  const mid = this.x + Math.floor(this.w / 2);
383
- screen.put(mid, this.y, "┬", { fg: T.BORDER });
402
+ screen.put(mid, this.y, "┬", { fg: T.BORDER, bg: T.BG2 });
384
403
  screen.vline(mid, this.y + 1, this.y + this.h - 1);
385
- screen.text(this.x + 1, this.y, ` 工作区 (${this.workspaces.length}) — 点击目录展开,/ 搜索文件`, { fg: K.DIM });
404
+ screen.text(this.x + 1, this.y, ` 工作区 (${this.workspaces.length}) — 点击目录展开,/ 搜索文件,右键添加工作区`, { fg: K.DIM, bg: T.BG2 });
386
405
  if (this.query) {
387
406
  const results = [];
388
407
  const walk = (nodes) => {
@@ -427,6 +446,99 @@ export class WorkspacePanel extends Widget {
427
446
  }
428
447
  }
429
448
 
449
+ // ---- DirPicker: yazi-style folder selection buffer ----
450
+
451
+ export class DirPicker extends Widget {
452
+ constructor(app, { startPath, onPick, onCancel }) {
453
+ const w = Math.min(60, app.screen.w - 4), h = Math.min(20, app.screen.h - 4);
454
+ super({ x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2), w, h });
455
+ this.app = app;
456
+ this.path = startPath ?? process.cwd();
457
+ this.onPick = onPick;
458
+ this.onCancel = onCancel;
459
+ this.entries = [];
460
+ this.sel = 0;
461
+ this.scroll = 0;
462
+ this.load();
463
+ }
464
+ load() {
465
+ try {
466
+ this.entries = readdirSync(this.path, { withFileTypes: true })
467
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
468
+ .map((d) => d.name)
469
+ .sort((a, b) => a.localeCompare(b));
470
+ } catch { this.entries = []; }
471
+ this.sel = 0;
472
+ this.scroll = 0;
473
+ }
474
+ #items() { return ["✓ 选择此目录", "..", ...this.entries]; }
475
+ render(screen) {
476
+ screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
477
+ screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: K.ACCENT, bg: T.BG2 }, "选择文件夹");
478
+ screen.text(this.x + 2, this.y + 1, truncate("📁 " + this.path, this.w - 4), { fg: K.TXT, bg: T.BG2 });
479
+ const items = this.#items();
480
+ const lh = Math.max(1, this.h - 3);
481
+ if (this.sel < this.scroll) this.scroll = this.sel;
482
+ else if (this.sel >= this.scroll + lh) this.scroll = this.sel - lh + 1;
483
+ this.scroll = Math.max(0, Math.min(Math.max(0, items.length - lh), this.scroll));
484
+ for (let i = 0; i < lh; i++) {
485
+ const idx = this.scroll + i;
486
+ const it = items[idx];
487
+ const y = this.y + 2 + i;
488
+ if (it === undefined) { screen.hline(this.x + 1, this.x + this.w - 2, y, " ", { bg: T.BG2 }); continue; }
489
+ const sel = idx === this.sel;
490
+ const label = it === "✓ 选择此目录" ? it : it === ".." ? ".. (上级目录)" : "▸ " + it + "/";
491
+ const fg = it === "✓ 选择此目录" ? K.OK : it === ".." ? K.DIM : K.TXT;
492
+ screen.fillRect(this.x + 1, y, this.x + this.w - 2, y, " ", { bg: sel ? T.MENUSEL : T.BG2 });
493
+ screen.text(this.x + 2, y, truncate(label, this.w - 4), { fg: sel ? 0xffffff : fg, bg: sel ? T.MENUSEL : T.BG2, attrs: sel ? 1 : 0 });
494
+ }
495
+ screen.text(this.x + 2, this.y + this.h - 1, "↑↓/jk 移动 · Enter 进入/选择 · h/Backspace 上级 · Esc 取消", { fg: K.FAINT, bg: T.BG2 });
496
+ }
497
+ onKey(ev) {
498
+ if (ev.type !== "key") return false;
499
+ const items = this.#items();
500
+ switch (ev.name) {
501
+ case "up": this.sel = Math.max(0, this.sel - 1); return true;
502
+ case "down": this.sel = Math.min(items.length - 1, this.sel + 1); return true;
503
+ case "enter": {
504
+ const it = items[this.sel];
505
+ if (it === "✓ 选择此目录") { this.onPick?.(this.path); return true; }
506
+ if (it === "..") { this.path = dirname(this.path); this.load(); return true; }
507
+ this.path = join(this.path, it); this.load(); return true;
508
+ }
509
+ case "backspace": this.path = dirname(this.path); this.load(); return true;
510
+ case "char":
511
+ if (ev.key === "j" && !ev.ctrl) { this.sel = Math.min(items.length - 1, this.sel + 1); return true; }
512
+ if (ev.key === "k" && !ev.ctrl) { this.sel = Math.max(0, this.sel - 1); return true; }
513
+ if (ev.key === "h" && !ev.ctrl) { this.path = dirname(this.path); this.load(); return true; }
514
+ if (ev.key === "l" && !ev.ctrl) {
515
+ const it = items[this.sel];
516
+ if (it && it !== "✓ 选择此目录" && it !== "..") { this.path = join(this.path, it); this.load(); }
517
+ return true;
518
+ }
519
+ return false;
520
+ case "escape": this.onCancel?.(); return true;
521
+ }
522
+ return false;
523
+ }
524
+ onMouse(ev) {
525
+ if (ev.kind === "press" && ev.button === 0) {
526
+ const idx = this.scroll + (ev.y - this.y - 2);
527
+ const items = this.#items();
528
+ if (idx >= 0 && idx < items.length) {
529
+ const it = items[idx];
530
+ if (it === "✓ 选择此目录") { this.onPick?.(this.path); return true; }
531
+ if (it === "..") { this.path = dirname(this.path); this.load(); return true; }
532
+ this.path = join(this.path, it); this.load(); return true;
533
+ }
534
+ return true;
535
+ }
536
+ if (ev.kind === "wheel-up") { this.sel = Math.max(0, this.sel - 1); return true; }
537
+ if (ev.kind === "wheel-down") { this.sel = Math.min(this.#items().length - 1, this.sel + 1); return true; }
538
+ return true;
539
+ }
540
+ }
541
+
430
542
  // ---- Trajectory view ----
431
543
 
432
544
  export class TrajectoryPanel extends Widget {
package/src/views.js CHANGED
@@ -6,7 +6,7 @@ import { readFileSync } from "node:fs";
6
6
  import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
7
7
  import {
8
8
  Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
9
- modeName, permName, WorkspacePanel, TrajectoryPanel,
9
+ modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker,
10
10
  ImagePopup, kittyCapable, buildJobsPopup, buildGoalPopup, SettingsPanel, SubagentPanel,
11
11
  SkillsPanel, ControlPanel,
12
12
  } from "./panels.js";
@@ -541,9 +541,10 @@ export class ChatView extends Widget {
541
541
  this.input = new Input({
542
542
  x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
543
543
  multi: true, maxLines: 6,
544
+ bg: T.BG2, border: T.BORDER2,
544
545
  placeholder: "输入消息…(Ctrl+J 换行,Enter 发送)",
545
546
  onEnter: (v) => this.send(v),
546
- onChange: () => this.#inputChanged(),
547
+ onChange: () => this.inputChanged(),
547
548
  });
548
549
  this.contextNode = null;
549
550
  this.rebuildQueued = false;
@@ -681,18 +682,18 @@ export class ChatView extends Widget {
681
682
  }
682
683
 
683
684
  /** Height of the collapsible todo block (0 when empty or collapsed). */
684
- #todoHeight() {
685
+ todoHeight() {
685
686
  const todos = this.app.todos;
686
687
  if (!this.todosVisible || !todos || todos.length === 0) return 0;
687
688
  return Math.min(todos.length, 6) + 1; // 1 header row + up to 6 todos
688
689
  }
689
690
 
690
- #inputChanged() {
691
+ inputChanged() {
691
692
  // Multi-line input grew/shrunk → reflow view vs input, keep the tail visible.
692
693
  const ih = this.input.height();
693
694
  const prevIh = this.input.h;
694
695
  this.input.h = ih;
695
- const th = this.#todoHeight();
696
+ const th = this.todoHeight();
696
697
  this.view.h = this.h - ih - th - 1;
697
698
  this.input.y = this.y + this.h - ih;
698
699
  if (ih !== prevIh) this.app.layout();
@@ -703,7 +704,7 @@ export class ChatView extends Widget {
703
704
  this.x = x; this.y = y; this.w = w; this.h = h;
704
705
  const ih = this.input.height();
705
706
  this.input.h = ih;
706
- const th = this.#todoHeight();
707
+ const th = this.todoHeight();
707
708
  this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - th - 1;
708
709
  this.input.x = x; this.input.y = y + h - ih; this.input.w = w;
709
710
  this.cache.clear();
@@ -965,6 +966,7 @@ export class ChatView extends Widget {
965
966
  { t: open ? "▾ " : "▸ ", fg: K.ACCENT },
966
967
  { t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
967
968
  { t: ` ${glyph}`, fg: status === "TOOLOK" ? K.OK : status === "TOOLERR" ? K.ERR : K.WARN },
969
+ { t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
968
970
  ]);
969
971
  mark(realIdx, bi);
970
972
  if (!open) {
@@ -1124,7 +1126,7 @@ export class ChatView extends Widget {
1124
1126
 
1125
1127
  /** Collapsible todo block between the view and the input (Shift+T toggles). */
1126
1128
  #renderTodos(screen) {
1127
- const th = this.#todoHeight();
1129
+ const th = this.todoHeight();
1128
1130
  if (th === 0) return;
1129
1131
  const todos = this.app.todos ?? [];
1130
1132
  const y = this.input.y - th - 1;
@@ -1243,7 +1245,7 @@ export class ChatView extends Widget {
1243
1245
  if (ev.shift) {
1244
1246
  this.todosVisible = !this.todosVisible;
1245
1247
  this.app.toast(this.todosVisible ? "任务块:显示(Shift+T 折叠)" : "任务块:折叠(Shift+T 展开)");
1246
- this.#inputChanged();
1248
+ this.inputChanged();
1247
1249
  return true;
1248
1250
  }
1249
1251
  this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
@@ -1569,6 +1571,12 @@ export class App {
1569
1571
  this.model = host.model ?? "";
1570
1572
  } catch (e) { this.log(`[app] host.describe: ${e.message}`); }
1571
1573
  await this.refreshSessions();
1574
+ // Open on a blank session at the launch directory (reusing an existing
1575
+ // draft when available) so the blank-session homepage shows immediately
1576
+ // instead of a fully empty chat area.
1577
+ if (!this.currentSession) {
1578
+ await this.newSessionIn(null);
1579
+ }
1572
1580
  this.api.connectMux();
1573
1581
  this.api.connectHost();
1574
1582
  this.api.onFrame = (frame) => this.#onFrame(frame);
@@ -1621,6 +1629,8 @@ export class App {
1621
1629
  case "session/projection":
1622
1630
  this.projections[frame.key] = frame.value;
1623
1631
  if (frame.key === "tokenUsage") this.tokenUsage = frame.value;
1632
+ // the todo block's height depends on the todos projection → reflow
1633
+ if (frame.key === "todos") this.chat.inputChanged();
1624
1634
  break;
1625
1635
  case "approval/resolved":
1626
1636
  case "question/resolved":
@@ -1675,6 +1685,43 @@ export class App {
1675
1685
  } catch (e) { this.toast(`移动失败: ${e.message}`); }
1676
1686
  }
1677
1687
 
1688
+ /** Move a workspace up/down in the durable display order. */
1689
+ async moveWorkspace(node, delta) {
1690
+ const ws = this.workspaceItems?.find((w) => w.workspaceId === node.workspaceId);
1691
+ if (!ws || !this.workspaceItems) { this.toast("找不到工作区"); return; }
1692
+ const ids = this.workspaceItems.map((w) => w.workspaceId);
1693
+ const idx = ids.indexOf(ws.workspaceId);
1694
+ if (idx < 0) return;
1695
+ const target = idx + delta;
1696
+ if (target < 0 || target >= ids.length) return;
1697
+ const beforeWorkspaceId = delta === -1 ? ids[target] : (target + 1 < ids.length ? ids[target + 1] : undefined);
1698
+ try {
1699
+ await this.api.call("workspace.insertBefore", {
1700
+ workspaceId: ws.workspaceId,
1701
+ ...(beforeWorkspaceId !== undefined ? { beforeWorkspaceId } : {}),
1702
+ });
1703
+ await this.refreshSessions();
1704
+ } catch (e) { this.toast(`移动工作区失败: ${e.message}`); }
1705
+ }
1706
+
1707
+ /** Yazi-style folder picker → workspace.create. */
1708
+ addWorkspace() {
1709
+ this.overlay = new DirPicker(this, {
1710
+ startPath: process.cwd(),
1711
+ onPick: async (path) => {
1712
+ this.overlay = null;
1713
+ try {
1714
+ await this.api.call("workspace.create", { path });
1715
+ await this.refreshSessions();
1716
+ this.toast(`已添加工作区: ${path}`);
1717
+ } catch (e) { this.toast(`添加失败: ${e.message}`); }
1718
+ this.redraw();
1719
+ },
1720
+ onCancel: () => { this.overlay = null; this.redraw(); },
1721
+ });
1722
+ this.redraw();
1723
+ }
1724
+
1678
1725
  renameSession(s) {
1679
1726
  this.closeOverlay();
1680
1727
  const input = new Input({ x: 2, y: this.screen.h - 3, w: this.screen.w - 4, h: 1, prompt: "标题: ", allowEmptyEnter: true, onEnter: () => this.#commitRename(s, input) });
@@ -1817,8 +1864,10 @@ export class App {
1817
1864
 
1818
1865
  async newSessionIn(group = null) {
1819
1866
  // Reuse an existing empty draft instead of minting a fresh blank session on
1820
- // every "new session" click (this is how the meaningless blank sessions pile up).
1821
- const blank = this.sessions.find((s) => s.blank && !s.running && s.sessionId !== this.currentSession);
1867
+ // every "new session" click (this is how the meaningless blank sessions pile
1868
+ // up). Prefer a draft at the same directory (e.g. the launch cwd on open).
1869
+ const blanks = this.sessions.filter((s) => s.blank && !s.running && s.sessionId !== this.currentSession);
1870
+ const blank = blanks.find((s) => s.cwd === process.cwd()) ?? blanks[0];
1822
1871
  if (blank && (group?.workspaceId == null || this.#sessionInWorkspace(blank.sessionId, group.workspaceId))) {
1823
1872
  await this.refreshSessions();
1824
1873
  this.openSession(blank.sessionId);
@@ -2434,12 +2483,11 @@ export class App {
2434
2483
  if (this.overlay) this.overlay.render(s);
2435
2484
  if (this.toastMsg) {
2436
2485
  // Toasts land in the LOWER half (just above the input/footer) where the
2437
- // user's attention is while pressing shortcuts — a solid color block,
2438
- // not a top-of-screen whisper.
2486
+ // user's attention is while pressing shortcuts — a solid color block.
2439
2487
  const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
2440
2488
  const x0 = Math.max(2, Math.floor((s.w - w) / 2));
2441
- const y = Math.max(1, s.h - this.footerHeight() - 2);
2442
- s.fillRect(x0 - 1, y - 1, x0 + w, y + 1, " ", { bg: T.ACCENT });
2489
+ const y = Math.max(1, this.chat.input.y - this.chat.todoHeight() - 2);
2490
+ s.fillRect(x0, y, x0 + w - 1, y, " ", { bg: T.ACCENT });
2443
2491
  s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
2444
2492
  }
2445
2493
  if (this.renameInput && this.popup) this.renameInput.render(s);