dsh-neotui 0.0.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.2",
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) => {
@@ -393,10 +412,16 @@ export class WorkspacePanel extends Widget {
393
412
  };
394
413
  walk(this.tree);
395
414
  this.searchResults = results;
396
- for (let i = 0; i < Math.min(this.h - 2, results.length); i++) {
397
- const sel = i === this.searchSel;
415
+ const visH = Math.max(1, this.h - 2);
416
+ if (this.searchSel < (this.searchScroll ?? 0)) this.searchScroll = this.searchSel;
417
+ else if (this.searchSel >= (this.searchScroll ?? 0) + visH) this.searchScroll = this.searchSel - visH + 1;
418
+ this.searchScroll = Math.max(0, Math.min(Math.max(0, results.length - visH), this.searchScroll ?? 0));
419
+ for (let i = 0; i < visH; i++) {
420
+ const idx = this.searchScroll + i;
421
+ if (idx >= results.length) break;
422
+ const sel = idx === this.searchSel;
398
423
  screen.fillRect(this.x + 1, this.y + 2 + i, mid - 2, this.y + 2 + i, " ", { bg: sel ? K.MENUSEL : -1 });
399
- screen.text(this.x + 2, this.y + 2 + i, truncate("⚲ " + basename(results[i]), mid - 6), { fg: sel ? K.BOLD : K.TXT, bg: sel ? K.MENUSEL : -1 });
424
+ screen.text(this.x + 2, this.y + 2 + i, truncate("⚲ " + basename(results[idx]), mid - 6), { fg: sel ? K.BOLD : K.TXT, bg: sel ? K.MENUSEL : -1 });
400
425
  }
401
426
  screen.text(this.x + 1, this.y + this.h - 1, ` 匹配 ${results.length} 个文件 · Esc 退出搜索`, { fg: K.FAINT });
402
427
  return;
@@ -421,6 +446,99 @@ export class WorkspacePanel extends Widget {
421
446
  }
422
447
  }
423
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
+
424
542
  // ---- Trajectory view ----
425
543
 
426
544
  export class TrajectoryPanel extends Widget {
@@ -781,6 +899,7 @@ export class ControlPanel extends Widget {
781
899
  this.subPages = ["常规", "插件"];
782
900
  this.subPage = 0;
783
901
  this.sel = 0;
902
+ this.scroll = 0;
784
903
  this.commands = DEFAULT_COMMANDS;
785
904
  this.plugins = null;
786
905
  this.pluginError = null;
@@ -884,9 +1003,15 @@ export class ControlPanel extends Widget {
884
1003
  }
885
1004
  const items = this.items();
886
1005
  if (this.sel >= items.length) this.sel = Math.max(0, items.length - 1);
887
- for (let i = 0; i < Math.min(this.h - 3, items.length); i++) {
888
- const it = items[i];
889
- const sel = i === this.sel;
1006
+ const visible = Math.max(1, this.h - 3);
1007
+ if (this.sel < this.scroll) this.scroll = this.sel;
1008
+ else if (this.sel >= this.scroll + visible) this.scroll = this.sel - visible + 1;
1009
+ this.scroll = Math.max(0, Math.min(Math.max(0, items.length - visible), this.scroll));
1010
+ for (let i = 0; i < visible; i++) {
1011
+ const idx = this.scroll + i;
1012
+ const it = items[idx];
1013
+ if (!it) { s.hline(this.x + 1, this.x + this.w - 2, this.y + 2 + i, " ", { bg: T.PANEL }); continue; }
1014
+ const sel = idx === this.sel;
890
1015
  s.fillRect(this.x + 1, this.y + 2 + i, this.x + this.w - 2, this.y + 2 + i, " ", { bg: sel ? T.MENUSEL : T.PANEL });
891
1016
  const label = it[0];
892
1017
  s.text(this.x + 2, this.y + 2 + i, truncate(label, this.w - 34), { fg: sel ? T.BOLD : (this.page === 0 ? T.ACCENT : T.TXT), bg: sel ? T.MENUSEL : T.PANEL, attrs: sel ? 1 : 0 });
@@ -947,9 +1072,9 @@ export class ControlPanel extends Widget {
947
1072
  }
948
1073
  return true;
949
1074
  }
950
- const idx = ev.y - this.y - 2;
1075
+ const idx = this.scroll + (ev.y - this.y - 2);
951
1076
  const items = this.items();
952
- if (idx >= 0 && idx < items.length && idx < this.h - 3) {
1077
+ if (idx >= 0 && idx < items.length && (ev.y - this.y - 2) < this.h - 3) {
953
1078
  this.sel = idx;
954
1079
  const it = items[idx];
955
1080
  if (it && it[2]) { it[2](); this.app.redraw(); }
package/src/term.js CHANGED
@@ -44,7 +44,8 @@ export class Term {
44
44
  this.input.on("data", (chunk) => this.#feed(this.decoder.write(chunk)));
45
45
  const o = this.output;
46
46
  o.write("\x1b[?1049h"); // alt screen
47
- o.write("\x1b[?25l"); // hide cursor
47
+ o.write("\x1b[?25l"); // hide cursor (shown only over the focused input)
48
+ o.write("\x1b[5 q"); // DECSCUSR: blinking vertical bar caret
48
49
  o.write("\x1b[?1000h"); // mouse: clicks
49
50
  o.write("\x1b[?1002h"); // mouse: drag
50
51
  o.write("\x1b[?1003h"); // mouse: all motion
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";
@@ -528,6 +528,7 @@ export class ChatView extends Widget {
528
528
  this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
529
529
  this.thinkMode = "expanded"; // think blocks: expanded by default (t toggles)
530
530
  this.bashMode = "expanded"; // tool blocks: expanded | collapsed (b toggles)
531
+ this.todosVisible = true; // todo block above the input (Shift+T toggles)
531
532
  this.running = false;
532
533
  this.hasMore = false;
533
534
  this.loadingOlder = false;
@@ -540,9 +541,10 @@ export class ChatView extends Widget {
540
541
  this.input = new Input({
541
542
  x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
542
543
  multi: true, maxLines: 6,
544
+ bg: T.BG2, border: T.BORDER2,
543
545
  placeholder: "输入消息…(Ctrl+J 换行,Enter 发送)",
544
546
  onEnter: (v) => this.send(v),
545
- onChange: () => this.#inputChanged(),
547
+ onChange: () => this.inputChanged(),
546
548
  });
547
549
  this.contextNode = null;
548
550
  this.rebuildQueued = false;
@@ -679,12 +681,20 @@ export class ChatView extends Widget {
679
681
  }
680
682
  }
681
683
 
682
- #inputChanged() {
684
+ /** Height of the collapsible todo block (0 when empty or collapsed). */
685
+ todoHeight() {
686
+ const todos = this.app.todos;
687
+ if (!this.todosVisible || !todos || todos.length === 0) return 0;
688
+ return Math.min(todos.length, 6) + 1; // 1 header row + up to 6 todos
689
+ }
690
+
691
+ inputChanged() {
683
692
  // Multi-line input grew/shrunk → reflow view vs input, keep the tail visible.
684
693
  const ih = this.input.height();
685
694
  const prevIh = this.input.h;
686
695
  this.input.h = ih;
687
- this.view.h = this.h - ih - 1;
696
+ const th = this.todoHeight();
697
+ this.view.h = this.h - ih - th - 1;
688
698
  this.input.y = this.y + this.h - ih;
689
699
  if (ih !== prevIh) this.app.layout();
690
700
  this.app.redraw();
@@ -694,7 +704,8 @@ export class ChatView extends Widget {
694
704
  this.x = x; this.y = y; this.w = w; this.h = h;
695
705
  const ih = this.input.height();
696
706
  this.input.h = ih;
697
- this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - 1;
707
+ const th = this.todoHeight();
708
+ this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - th - 1;
698
709
  this.input.x = x; this.input.y = y + h - ih; this.input.w = w;
699
710
  this.cache.clear();
700
711
  this.#rebuild();
@@ -955,6 +966,7 @@ export class ChatView extends Widget {
955
966
  { t: open ? "▾ " : "▸ ", fg: K.ACCENT },
956
967
  { t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
957
968
  { t: ` ${glyph}`, fg: status === "TOOLOK" ? K.OK : status === "TOOLERR" ? K.ERR : K.WARN },
969
+ { t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
958
970
  ]);
959
971
  mark(realIdx, bi);
960
972
  if (!open) {
@@ -1107,10 +1119,27 @@ export class ChatView extends Widget {
1107
1119
  screen.invertRect(this.view.x, this.view.y + (y0 - this.view.scrollY), this.view.x + this.view.w - 2, this.view.y + (y1 - this.view.scrollY));
1108
1120
  }
1109
1121
  }
1122
+ this.#renderTodos(screen);
1110
1123
  screen.hline(this.x, this.x + this.w - 1, this.input.y - 1, "─", { fg: T.BORDER2 });
1111
1124
  this.input.render(screen);
1112
1125
  }
1113
1126
 
1127
+ /** Collapsible todo block between the view and the input (Shift+T toggles). */
1128
+ #renderTodos(screen) {
1129
+ const th = this.todoHeight();
1130
+ if (th === 0) return;
1131
+ const todos = this.app.todos ?? [];
1132
+ const y = this.input.y - th - 1;
1133
+ screen.fillRect(this.x, y, this.x + this.w - 1, y + th - 1, " ", { bg: T.THINKBG });
1134
+ screen.text(this.x, y, " ☐ 任务清单(Shift+T 折叠)", { fg: K.FAINT });
1135
+ for (let i = 0; i < Math.min(todos.length, th - 1); i++) {
1136
+ const t = todos[i];
1137
+ const icon = t.status === "completed" ? "✓" : t.status === "in_progress" ? "◉" : "○";
1138
+ const color = t.status === "completed" ? T.FAINT : t.status === "in_progress" ? T.WARN : T.DIM;
1139
+ screen.text(this.x + 2, y + 1 + i, `${icon} ${truncate(t.content ?? String(t), this.w - 6)}`, { fg: color });
1140
+ }
1141
+ }
1142
+
1114
1143
  onMouse(ev) {
1115
1144
  if (this.input.inside(ev.x, ev.y)) {
1116
1145
  // INSERT mode is keyboard-only; only position the cursor if already there.
@@ -1213,6 +1242,12 @@ export class ChatView extends Widget {
1213
1242
  return true;
1214
1243
  }
1215
1244
  if (ev.name === "char" && ev.key === "t" && !ev.ctrl) {
1245
+ if (ev.shift) {
1246
+ this.todosVisible = !this.todosVisible;
1247
+ this.app.toast(this.todosVisible ? "任务块:显示(Shift+T 折叠)" : "任务块:折叠(Shift+T 展开)");
1248
+ this.inputChanged();
1249
+ return true;
1250
+ }
1216
1251
  this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
1217
1252
  this.expanded.clear();
1218
1253
  this.collapsedBlocks.clear();
@@ -1227,12 +1262,16 @@ export class ChatView extends Widget {
1227
1262
  function toolSummary(b) {
1228
1263
  // Prefer the human description of what the tool did (the agent's `description`
1229
1264
  // argument), then the host card title, then the raw command — mirrors the web.
1265
+ // File tools get a path-based summary so read/edit collapse as concisely as bash.
1230
1266
  if (b.args) {
1231
1267
  try {
1232
1268
  const a = JSON.parse(b.args);
1233
1269
  if (typeof a === "object" && a !== null) {
1234
- const desc = a.description ?? a.summary ?? a.title ?? a.path ?? a.query ?? a.content ?? a.name;
1270
+ const desc = a.description ?? a.summary ?? a.title ?? a.query ?? a.content ?? a.name;
1235
1271
  if (desc) return String(desc).slice(0, 120);
1272
+ if (a.file_path) return `read ${String(a.file_path)}`.slice(0, 120);
1273
+ if (a.path && a.command) return `${a.command} ${String(a.path)}`.slice(0, 120);
1274
+ if (a.path) return String(a.path).slice(0, 120);
1236
1275
  return a.command ?? null;
1237
1276
  }
1238
1277
  return String(a).slice(0, 120);
@@ -1532,6 +1571,12 @@ export class App {
1532
1571
  this.model = host.model ?? "";
1533
1572
  } catch (e) { this.log(`[app] host.describe: ${e.message}`); }
1534
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
+ }
1535
1580
  this.api.connectMux();
1536
1581
  this.api.connectHost();
1537
1582
  this.api.onFrame = (frame) => this.#onFrame(frame);
@@ -1584,6 +1629,8 @@ export class App {
1584
1629
  case "session/projection":
1585
1630
  this.projections[frame.key] = frame.value;
1586
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();
1587
1634
  break;
1588
1635
  case "approval/resolved":
1589
1636
  case "question/resolved":
@@ -1638,6 +1685,43 @@ export class App {
1638
1685
  } catch (e) { this.toast(`移动失败: ${e.message}`); }
1639
1686
  }
1640
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
+
1641
1725
  renameSession(s) {
1642
1726
  this.closeOverlay();
1643
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) });
@@ -1780,8 +1864,10 @@ export class App {
1780
1864
 
1781
1865
  async newSessionIn(group = null) {
1782
1866
  // Reuse an existing empty draft instead of minting a fresh blank session on
1783
- // every "new session" click (this is how the meaningless blank sessions pile up).
1784
- 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];
1785
1871
  if (blank && (group?.workspaceId == null || this.#sessionInWorkspace(blank.sessionId, group.workspaceId))) {
1786
1872
  await this.refreshSessions();
1787
1873
  this.openSession(blank.sessionId);
@@ -2396,11 +2482,13 @@ export class App {
2396
2482
  if (this.menu) this.menu.render(s);
2397
2483
  if (this.overlay) this.overlay.render(s);
2398
2484
  if (this.toastMsg) {
2399
- const w = strWidth(this.toastMsg) + 4;
2400
- const x0 = Math.max(0, Math.floor(s.w / 2 - w / 2));
2401
- const x1 = Math.min(s.w - 1, Math.floor(s.w / 2 + w / 2));
2402
- s.fillRect(x0, 0, x1, 0, " ", { bg: T.BORDER });
2403
- s.text(x0 + 2, 0, truncate(this.toastMsg, s.w - 4), { fg: T.BOLD, bg: T.BORDER });
2485
+ // Toasts land in the LOWER half (just above the input/footer) where the
2486
+ // user's attention is while pressing shortcuts a solid color block.
2487
+ const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
2488
+ const x0 = Math.max(2, Math.floor((s.w - w) / 2));
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 });
2491
+ s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
2404
2492
  }
2405
2493
  if (this.renameInput && this.popup) this.renameInput.render(s);
2406
2494
 
@@ -2409,6 +2497,11 @@ export class App {
2409
2497
  if (this.overlay && typeof this.overlay.kittyTransmit === "function" && kittyCapable()) {
2410
2498
  tail = this.overlay.kittyTransmit();
2411
2499
  }
2500
+ // Native terminal caret: a blinking vertical bar (text-editor style) at
2501
+ // the focused input's cursor cell; hidden everywhere else.
2502
+ const cell = this.focused?.cursorCell;
2503
+ if (cell) tail = `\x1b[?25h\x1b[${cell.y + 1};${cell.x + 1}H` + tail;
2504
+ else tail = "\x1b[?25l" + tail;
2412
2505
  this.term.output.write(out + tail);
2413
2506
  }
2414
2507
 
package/src/widgets.js CHANGED
@@ -247,9 +247,59 @@ export class Input extends Widget {
247
247
  this.histIdx = -1;
248
248
  }
249
249
  #cps() { return Array.from(this.value); } // code points
250
- #lines() { return this.value.split("\n"); }
251
- /** Rendered height: 1 line, or up to maxLines when multi-line. */
252
- height() { return this.multi ? Math.max(1, Math.min(this.maxLines, this.#lines().length)) : 1; }
250
+ /** Visual rows for multi-line input: logical lines wrapped at the width. */
251
+ #visualRows() {
252
+ const inner0 = Math.max(1, this.w - strWidth(this.prompt) - 2);
253
+ const innerN = Math.max(1, this.w - 2);
254
+ const rows = [];
255
+ const cps = Array.from(this.value);
256
+ let text = "", width = 0, limit = inner0, start = 0;
257
+ for (let i = 0; i < cps.length; i++) {
258
+ const ch = cps[i];
259
+ if (ch === "\n") {
260
+ rows.push({ text, start, end: i, limit });
261
+ text = ""; width = 0; limit = innerN; start = i + 1;
262
+ continue;
263
+ }
264
+ const cw = strWidth(ch);
265
+ if (width + cw > limit && width > 0) {
266
+ rows.push({ text, start, end: i, limit });
267
+ text = ""; width = 0; limit = innerN; start = i;
268
+ }
269
+ text += ch; width += cw;
270
+ }
271
+ rows.push({ text, start, end: cps.length, limit });
272
+ return rows;
273
+ }
274
+ /** [visualRow, display-col] of the cursor in wrapped coordinates. */
275
+ #cursorVisual() {
276
+ const rows = this.#visualRows();
277
+ const cursor = Math.max(0, Math.min(this.cursor, Array.from(this.value).length));
278
+ for (let ri = 0; ri < rows.length; ri++) {
279
+ const r = rows[ri];
280
+ if (cursor >= r.start && cursor <= r.end) {
281
+ const before = Array.from(r.text).slice(0, cursor - r.start);
282
+ return { row: ri, col: before.reduce((w, ch) => w + strWidth(ch), 0) };
283
+ }
284
+ }
285
+ const last = rows[rows.length - 1];
286
+ return { row: rows.length - 1, col: strWidth(last.text) };
287
+ }
288
+ /** Code-point index of the nearest position at a visual [row, col]. */
289
+ #indexAtVisual(row, col) {
290
+ const rows = this.#visualRows();
291
+ const r = rows[Math.max(0, Math.min(row, rows.length - 1))];
292
+ const cps = Array.from(r.text);
293
+ let w = 0, j = 0;
294
+ for (; j < cps.length; j++) {
295
+ const cw = strWidth(cps[j]);
296
+ if (col < w + cw / 2) break;
297
+ w += cw;
298
+ }
299
+ return r.start + j;
300
+ }
301
+ /** Rendered height: 1, or wrapped rows capped at maxLines when multi. */
302
+ height() { return this.multi ? Math.max(1, Math.min(this.maxLines, this.#visualRows().length)) : 1; }
253
303
  setValue(v, opts = {}) {
254
304
  this.value = String(v);
255
305
  this.cursor = this.#cps().length;
@@ -270,75 +320,88 @@ export class Input extends Widget {
270
320
  cps.splice(idx, 1);
271
321
  this.value = cps.join("");
272
322
  }
273
- /** [line, display-col] of the cursor. */
274
- #cursorPos() {
275
- const before = this.#cps().slice(0, this.cursor);
276
- let line = 0, col = 0;
277
- for (const ch of before) {
278
- if (ch === "\n") { line++; col = 0; }
279
- else col += strWidth(ch);
280
- }
281
- return [line, col];
282
- }
283
- /** Code-point index of the nearest position at [line, col]. */
284
- #indexAtLineCol(line, col) {
285
- const lines = this.#lines();
286
- let idx = 0;
287
- for (let i = 0; i < Math.min(line, lines.length); i++) idx += Array.from(lines[i]).length + 1;
288
- const target = lines[Math.min(line, lines.length - 1)] ?? "";
289
- const cps = Array.from(target);
290
- let w = 0;
291
- for (let j = 0; j < cps.length; j++) {
292
- const cw = strWidth(cps[j]);
293
- if (col < w + cw / 2) break;
294
- w += cw;
295
- idx++;
296
- }
297
- return idx;
323
+ /** Scroll offset that keeps the cursor's visual row inside the window. */
324
+ #scrollStart(h) {
325
+ const rows = this.#visualRows();
326
+ const { row } = this.#cursorVisual();
327
+ const maxStart = Math.max(0, rows.length - this.maxLines);
328
+ let start = this.scrollY ?? 0;
329
+ if (row < start) start = row;
330
+ else if (row >= start + h) start = row - h + 1;
331
+ start = Math.max(0, Math.min(maxStart, start));
332
+ this.scrollY = start;
333
+ return start;
298
334
  }
299
335
  render(screen) {
300
- const n = this.height();
301
- screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + n - 1, " ", { bg: this.bg });
336
+ if (!this.multi) {
337
+ // single-line: horizontal scroll (search/rename/picker inputs)
338
+ screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y, " ", { bg: this.bg });
339
+ screen.hline(this.x, this.x + this.w - 1, this.y, "─", { fg: this.border, bg: this.bg });
340
+ const promptW = strWidth(this.prompt);
341
+ const inner = Math.max(0, this.w - promptW - 2);
342
+ screen.text(this.x, this.y, this.prompt, { fg: T.ACCENT, bg: this.bg });
343
+ if (this.value === "" && this.placeholder) {
344
+ screen.text(this.x + promptW, this.y, truncate(this.placeholder, inner), { fg: T.FAINT, bg: this.bg });
345
+ this.cursorCell = { x: this.x + promptW, y: this.y };
346
+ return;
347
+ }
348
+ const before = Array.from(this.value).slice(0, this.cursor).join("");
349
+ const cx = strWidth(before);
350
+ let start = 0;
351
+ while (cx - start >= inner) start += Math.max(1, Math.floor(inner / 2));
352
+ const visible = truncate(Array.from(this.value).slice(start).join(""), inner);
353
+ screen.text(this.x + promptW, this.y, visible, { fg: this.fg, bg: this.bg });
354
+ this.cursorCell = { x: this.x + promptW + Math.min(inner, Math.max(0, cx - start)), y: this.y };
355
+ return;
356
+ }
357
+ // multi-line: auto-wrap + scroll window that follows the cursor
358
+ const rows = this.#visualRows();
359
+ const h = Math.min(this.maxLines, rows.length);
360
+ screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + h - 1, " ", { bg: this.bg });
302
361
  screen.hline(this.x, this.x + this.w - 1, this.y, "─", { fg: this.border, bg: this.bg });
303
- const lines = this.#lines();
304
- const [curLine, curCol] = this.#cursorPos();
305
- const promptW = strWidth(this.prompt);
306
- const inner0 = Math.max(0, this.w - promptW - 2);
307
- const innerN = Math.max(0, this.w - 2);
308
362
  if (this.value === "" && this.placeholder) {
309
363
  screen.text(this.x, this.y, this.prompt, { fg: T.ACCENT, bg: this.bg });
310
- screen.text(this.x + promptW, this.y, truncate(this.placeholder, inner0), { fg: T.FAINT, bg: this.bg });
364
+ screen.text(this.x + strWidth(this.prompt), this.y, truncate(this.placeholder, this.w - strWidth(this.prompt) - 2), { fg: T.FAINT, bg: this.bg });
365
+ this.cursorCell = { x: this.x + strWidth(this.prompt), y: this.y };
311
366
  return;
312
367
  }
313
- for (let li = 0; li < n; li++) {
314
- const y = this.y + li;
315
- const line = lines[li] ?? "";
316
- if (li === 0) {
368
+ const { row: curRow, col: curCol } = this.#cursorVisual();
369
+ const start = this.#scrollStart(h);
370
+ for (let ri = start; ri < Math.min(rows.length, start + h); ri++) {
371
+ const r = rows[ri];
372
+ const y = this.y + (ri - start);
373
+ if (ri === 0) {
317
374
  screen.text(this.x, y, this.prompt, { fg: T.ACCENT, bg: this.bg });
318
- let start = 0;
319
- if (curLine === 0) {
320
- while (curCol - start >= inner0) start += Math.max(1, Math.floor(inner0 / 2));
321
- }
322
- const visible = truncate(Array.from(line).slice(start).join(""), inner0);
323
- screen.text(this.x + promptW, y, visible, { fg: this.fg, bg: this.bg });
324
- if (curLine === 0) {
325
- const at = Math.min(inner0, Math.max(0, curCol - start));
326
- screen.put(this.x + promptW + at, y, "█", { fg: T.FAINT, bg: this.bg });
327
- }
375
+ screen.text(this.x + strWidth(this.prompt), y, r.text, { fg: this.fg, bg: this.bg });
328
376
  } else {
329
- screen.text(this.x + 1, y, truncate(line, innerN), { fg: this.fg, bg: this.bg });
330
- if (curLine === li) {
331
- const at = Math.min(innerN, Math.max(0, curCol));
332
- screen.put(this.x + 1 + at, y, "█", { fg: T.FAINT, bg: this.bg });
333
- }
377
+ screen.text(this.x + 1, y, r.text, { fg: this.fg, bg: this.bg });
334
378
  }
335
379
  }
380
+ // Native terminal caret (blinking bar) is positioned by the app after the
381
+ // frame; store the cell it should occupy (the char at the cursor index).
382
+ const curY = this.y + (curRow - start);
383
+ const curX = curRow === 0 ? this.x + strWidth(this.prompt) + curCol : this.x + 1 + curCol;
384
+ this.cursorCell = { x: Math.min(this.x + this.w - 1, curX), y: Math.min(this.y + h - 1, curY) };
336
385
  }
337
386
  onMouse(ev) {
338
387
  if (ev.kind === "press" && ev.button === 0) {
339
- const line = Math.max(0, Math.min(this.#lines().length - 1, ev.y - this.y));
340
- const rx = ev.x - this.x - (line === 0 ? strWidth(this.prompt) : 1);
341
- this.cursor = this.#indexAtLineCol(line, Math.max(0, rx));
388
+ if (this.multi) {
389
+ const h = this.height();
390
+ const start = this.scrollY ?? 0;
391
+ const row = start + Math.max(0, Math.min(h - 1, ev.y - this.y));
392
+ const rx = ev.x - this.x - (row === 0 ? strWidth(this.prompt) : 1);
393
+ this.cursor = this.#indexAtVisual(row, Math.max(0, rx));
394
+ } else {
395
+ const rx = ev.x - this.x - strWidth(this.prompt);
396
+ let w = 0, idx = 0;
397
+ for (const ch of Array.from(this.value)) {
398
+ const cw = strWidth(ch);
399
+ if (rx < w + cw / 2) break;
400
+ w += cw;
401
+ idx++;
402
+ }
403
+ this.cursor = idx;
404
+ }
342
405
  return true;
343
406
  }
344
407
  return false;
@@ -355,12 +418,21 @@ export class Input extends Widget {
355
418
  return true;
356
419
  case "left": this.selectAll = false; this.cursor = Math.max(0, this.cursor - 1); return true;
357
420
  case "right": this.selectAll = false; this.cursor = Math.min(this.#cps().length, this.cursor + 1); return true;
358
- case "home": { const [l] = this.#cursorPos(); this.cursor = this.#indexAtLineCol(l, 0); return true; }
359
- case "end": { const [l] = this.#cursorPos(); const line = this.#lines()[l] ?? ""; this.cursor = this.#indexAtLineCol(l, strWidth(line)); return true; }
421
+ case "home": {
422
+ if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].start; }
423
+ else this.cursor = 0;
424
+ return true;
425
+ }
426
+ case "end": {
427
+ if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].end; }
428
+ else this.cursor = this.#cps().length;
429
+ return true;
430
+ }
360
431
  case "up":
361
432
  if (this.multi) {
362
- const [l, c] = this.#cursorPos();
363
- if (l > 0) this.cursor = this.#indexAtLineCol(l - 1, c);
433
+ const rows = this.#visualRows();
434
+ const { row, col } = this.#cursorVisual();
435
+ if (row > 0) this.cursor = this.#indexAtVisual(row - 1, col);
364
436
  } else if (this.history.length) {
365
437
  this.histIdx = this.histIdx < 0 ? this.history.length - 1 : Math.max(0, this.histIdx - 1);
366
438
  this.setValue(this.history[this.histIdx] ?? "");
@@ -368,8 +440,9 @@ export class Input extends Widget {
368
440
  return true;
369
441
  case "down":
370
442
  if (this.multi) {
371
- const [l, c] = this.#cursorPos();
372
- if (l < this.#lines().length - 1) this.cursor = this.#indexAtLineCol(l + 1, c);
443
+ const rows = this.#visualRows();
444
+ const { row, col } = this.#cursorVisual();
445
+ if (row < rows.length - 1) this.cursor = this.#indexAtVisual(row + 1, col);
373
446
  } else if (this.histIdx >= 0) {
374
447
  this.histIdx++;
375
448
  if (this.histIdx >= this.history.length) { this.histIdx = -1; this.setValue(""); }