dsh-neotui 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -10,14 +10,29 @@ dsh plugin --profile dsh-neotui add dsh-neotui-app # 装 bundle,自动加入
10
10
  dsh --profile dsh-neotui # 启动
11
11
  ```
12
12
 
13
- ## 运行
13
+ ## 本地开发(从源码跑)
14
+
15
+ 克隆后先自链核心包(app bundle 通过包名 `dsh-neotui` 引核心,Node 会从真实路径向上找 `node_modules`):
16
+
17
+ ```bash
18
+ ln -sfn .. node_modules/dsh-neotui # 仓库根自链,让 app/src 能 import "dsh-neotui"
19
+ ```
20
+
21
+ 然后把 profile 的两条软链指到本仓库(`~/.dsh/profiles/node_modules/`):
22
+
23
+ ```bash
24
+ ln -sfn "$(pwd)" ~/.dsh/profiles/node_modules/dsh-neotui
25
+ ln -sfn "$(pwd)/app" ~/.dsh/profiles/node_modules/dsh-neotui-app
26
+ ```
27
+
28
+ 启动(本地开发 profile `~/.dsh/profiles/dsh-neotui`):
14
29
 
15
30
  ```bash
16
- dsh --profile dsh-neotui # 本地开发 profile(~/.dsh/profiles/dsh-neotui,软链到本仓库)
17
- dsh --profile dsh-neotui --session <id> # 启动即打开某会话
18
- dsh --profile dsh-neotui --cwd ~/work # 新建会话的默认目录
19
- dsh --profile dsh-neotui --port 3981 # 内嵌 API 指定端口(默认 0 = 系统分配)
20
- dsh --profile dsh-neotui --attach 3080 # 共存调试:连已运行的 web 宿主(存储自动隔离,不碰 $DSH_HOME)
31
+ dsh --profile dsh-neotui
32
+ dsh --profile dsh-neotui --session <id>
33
+ dsh --profile dsh-neotui --cwd ~/work
34
+ dsh --profile dsh-neotui --port 3981
35
+ dsh --profile dsh-neotui --attach 3080 # 共存调试:连已运行的 web 宿主(存储隔离,不碰 $DSH_HOME)
21
36
 
22
37
  node bin/dsh-tui.js # 纯客户端模式:连接已运行的 web 宿主(默认 3080)
23
38
  node bin/dsh-tui.js --base http://host:port
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
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
@@ -393,10 +393,16 @@ export class WorkspacePanel extends Widget {
393
393
  };
394
394
  walk(this.tree);
395
395
  this.searchResults = results;
396
- for (let i = 0; i < Math.min(this.h - 2, results.length); i++) {
397
- const sel = i === this.searchSel;
396
+ const visH = Math.max(1, this.h - 2);
397
+ if (this.searchSel < (this.searchScroll ?? 0)) this.searchScroll = this.searchSel;
398
+ else if (this.searchSel >= (this.searchScroll ?? 0) + visH) this.searchScroll = this.searchSel - visH + 1;
399
+ this.searchScroll = Math.max(0, Math.min(Math.max(0, results.length - visH), this.searchScroll ?? 0));
400
+ for (let i = 0; i < visH; i++) {
401
+ const idx = this.searchScroll + i;
402
+ if (idx >= results.length) break;
403
+ const sel = idx === this.searchSel;
398
404
  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 });
405
+ 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
406
  }
401
407
  screen.text(this.x + 1, this.y + this.h - 1, ` 匹配 ${results.length} 个文件 · Esc 退出搜索`, { fg: K.FAINT });
402
408
  return;
@@ -496,6 +502,15 @@ export class TrajectoryPanel extends Widget {
496
502
  this.buildLines();
497
503
  }
498
504
  async load(sessionId) {
505
+ // Re-entry into the same session reuses the already-built steps — instant,
506
+ // like the web view (which keeps its timeline in memory). A fresh session
507
+ // or an explicit refresh (r) re-fetches the recent window.
508
+ if (this.sessionId === sessionId && this.steps.length > 0) {
509
+ this.loading = false;
510
+ this.buildLines();
511
+ this.app.redraw();
512
+ return;
513
+ }
499
514
  this.sessionId = sessionId;
500
515
  this.loading = true;
501
516
  this.steps = [];
@@ -506,8 +521,8 @@ export class TrajectoryPanel extends Widget {
506
521
  this.app.setStatus("加载轨迹…");
507
522
  try {
508
523
  // One bounded call for the recent steps (maxMessages = model messages =
509
- // steps). Instant, and older steps load on demand via PgUp/click.
510
- const h = await this.app.api.call("session.history", { sessionId, maxMessages: 40 });
524
+ // steps). Older steps load on demand via PgUp/click.
525
+ const h = await this.app.api.call("session.history", { sessionId, maxMessages: 20 });
511
526
  this.stats = h.projections?.values?.sessionStats ?? null;
512
527
  this.minSeq = h.events[0]?.event?.seq ?? null;
513
528
  this.hasMore = h.hasMore;
@@ -557,7 +572,7 @@ export class TrajectoryPanel extends Widget {
557
572
  buildLines() {
558
573
  const w = Math.max(40, this.w - 2);
559
574
  const lines = [];
560
- lines.push([{ t: "轨迹 — 步骤时间轴(每行一个色块,点击查看详情)", fg: K.ACCENT, bold: true }]);
575
+ lines.push([{ t: "轨迹 — 步骤时间轴(每行一个色块,点击查看详情 · r 刷新)", fg: K.ACCENT, bold: true }]);
561
576
  if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 加载)", fg: K.FAINT }]);
562
577
  else lines.push([{ t: "" }]);
563
578
  const st = this.stats;
@@ -609,6 +624,7 @@ export class TrajectoryPanel extends Widget {
609
624
  return true;
610
625
  }
611
626
  if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.buildLines(); this.app.redraw(); return true; }
627
+ if (ev.name === "char" && ev.key === "r" && !ev.ctrl) { this.steps = []; this.load(this.sessionId); return true; }
612
628
  if (ev.name === "pgup") { if (this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; } return this.view.scroll(-this.view.h); }
613
629
  if (ev.name === "pgdn" || ev.name === "up" || ev.name === "down") return this.view.onKey(ev);
614
630
  return false;
@@ -771,6 +787,7 @@ export class ControlPanel extends Widget {
771
787
  this.subPages = ["常规", "插件"];
772
788
  this.subPage = 0;
773
789
  this.sel = 0;
790
+ this.scroll = 0;
774
791
  this.commands = DEFAULT_COMMANDS;
775
792
  this.plugins = null;
776
793
  this.pluginError = null;
@@ -874,9 +891,15 @@ export class ControlPanel extends Widget {
874
891
  }
875
892
  const items = this.items();
876
893
  if (this.sel >= items.length) this.sel = Math.max(0, items.length - 1);
877
- for (let i = 0; i < Math.min(this.h - 3, items.length); i++) {
878
- const it = items[i];
879
- const sel = i === this.sel;
894
+ const visible = Math.max(1, this.h - 3);
895
+ if (this.sel < this.scroll) this.scroll = this.sel;
896
+ else if (this.sel >= this.scroll + visible) this.scroll = this.sel - visible + 1;
897
+ this.scroll = Math.max(0, Math.min(Math.max(0, items.length - visible), this.scroll));
898
+ for (let i = 0; i < visible; i++) {
899
+ const idx = this.scroll + i;
900
+ const it = items[idx];
901
+ if (!it) { s.hline(this.x + 1, this.x + this.w - 2, this.y + 2 + i, " ", { bg: T.PANEL }); continue; }
902
+ const sel = idx === this.sel;
880
903
  s.fillRect(this.x + 1, this.y + 2 + i, this.x + this.w - 2, this.y + 2 + i, " ", { bg: sel ? T.MENUSEL : T.PANEL });
881
904
  const label = it[0];
882
905
  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 });
@@ -937,9 +960,9 @@ export class ControlPanel extends Widget {
937
960
  }
938
961
  return true;
939
962
  }
940
- const idx = ev.y - this.y - 2;
963
+ const idx = this.scroll + (ev.y - this.y - 2);
941
964
  const items = this.items();
942
- if (idx >= 0 && idx < items.length && idx < this.h - 3) {
965
+ if (idx >= 0 && idx < items.length && (ev.y - this.y - 2) < this.h - 3) {
943
966
  this.sel = idx;
944
967
  const it = items[idx];
945
968
  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
@@ -128,7 +129,13 @@ export class Term {
128
129
  if (cp === 13) this.#emit({ type: "key", name: "enter", ctrl: false, alt: true, shift: false });
129
130
  else this.#emit({ type: "key", name: "char", key: next.toLowerCase(), text: next, ctrl: false, alt: true, shift: false });
130
131
  i += 2;
131
- } else { i = buf.length; break; }
132
+ } else {
133
+ // Lone ESC at end of buffer = the standalone Escape key. Terminals
134
+ // deliver escape sequences atomically, so a bare \x1b is never the
135
+ // head of a sequence that will arrive in a later chunk.
136
+ this.#emit({ type: "key", name: "escape", ctrl: false, alt: false, shift: false });
137
+ i++;
138
+ }
132
139
  } else if (ch === "\r") {
133
140
  this.#emit({ type: "key", name: "enter", ctrl: false, alt: false, shift: false });
134
141
  i++;
package/src/views.js CHANGED
@@ -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;
@@ -679,12 +680,20 @@ export class ChatView extends Widget {
679
680
  }
680
681
  }
681
682
 
683
+ /** Height of the collapsible todo block (0 when empty or collapsed). */
684
+ #todoHeight() {
685
+ const todos = this.app.todos;
686
+ if (!this.todosVisible || !todos || todos.length === 0) return 0;
687
+ return Math.min(todos.length, 6) + 1; // 1 header row + up to 6 todos
688
+ }
689
+
682
690
  #inputChanged() {
683
691
  // Multi-line input grew/shrunk → reflow view vs input, keep the tail visible.
684
692
  const ih = this.input.height();
685
693
  const prevIh = this.input.h;
686
694
  this.input.h = ih;
687
- this.view.h = this.h - ih - 1;
695
+ const th = this.#todoHeight();
696
+ this.view.h = this.h - ih - th - 1;
688
697
  this.input.y = this.y + this.h - ih;
689
698
  if (ih !== prevIh) this.app.layout();
690
699
  this.app.redraw();
@@ -694,7 +703,8 @@ export class ChatView extends Widget {
694
703
  this.x = x; this.y = y; this.w = w; this.h = h;
695
704
  const ih = this.input.height();
696
705
  this.input.h = ih;
697
- this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - 1;
706
+ const th = this.#todoHeight();
707
+ this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - th - 1;
698
708
  this.input.x = x; this.input.y = y + h - ih; this.input.w = w;
699
709
  this.cache.clear();
700
710
  this.#rebuild();
@@ -1107,10 +1117,27 @@ export class ChatView extends Widget {
1107
1117
  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
1118
  }
1109
1119
  }
1120
+ this.#renderTodos(screen);
1110
1121
  screen.hline(this.x, this.x + this.w - 1, this.input.y - 1, "─", { fg: T.BORDER2 });
1111
1122
  this.input.render(screen);
1112
1123
  }
1113
1124
 
1125
+ /** Collapsible todo block between the view and the input (Shift+T toggles). */
1126
+ #renderTodos(screen) {
1127
+ const th = this.#todoHeight();
1128
+ if (th === 0) return;
1129
+ const todos = this.app.todos ?? [];
1130
+ const y = this.input.y - th - 1;
1131
+ screen.fillRect(this.x, y, this.x + this.w - 1, y + th - 1, " ", { bg: T.THINKBG });
1132
+ screen.text(this.x, y, " ☐ 任务清单(Shift+T 折叠)", { fg: K.FAINT });
1133
+ for (let i = 0; i < Math.min(todos.length, th - 1); i++) {
1134
+ const t = todos[i];
1135
+ const icon = t.status === "completed" ? "✓" : t.status === "in_progress" ? "◉" : "○";
1136
+ const color = t.status === "completed" ? T.FAINT : t.status === "in_progress" ? T.WARN : T.DIM;
1137
+ screen.text(this.x + 2, y + 1 + i, `${icon} ${truncate(t.content ?? String(t), this.w - 6)}`, { fg: color });
1138
+ }
1139
+ }
1140
+
1114
1141
  onMouse(ev) {
1115
1142
  if (this.input.inside(ev.x, ev.y)) {
1116
1143
  // INSERT mode is keyboard-only; only position the cursor if already there.
@@ -1213,6 +1240,12 @@ export class ChatView extends Widget {
1213
1240
  return true;
1214
1241
  }
1215
1242
  if (ev.name === "char" && ev.key === "t" && !ev.ctrl) {
1243
+ if (ev.shift) {
1244
+ this.todosVisible = !this.todosVisible;
1245
+ this.app.toast(this.todosVisible ? "任务块:显示(Shift+T 折叠)" : "任务块:折叠(Shift+T 展开)");
1246
+ this.#inputChanged();
1247
+ return true;
1248
+ }
1216
1249
  this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
1217
1250
  this.expanded.clear();
1218
1251
  this.collapsedBlocks.clear();
@@ -1227,12 +1260,16 @@ export class ChatView extends Widget {
1227
1260
  function toolSummary(b) {
1228
1261
  // Prefer the human description of what the tool did (the agent's `description`
1229
1262
  // argument), then the host card title, then the raw command — mirrors the web.
1263
+ // File tools get a path-based summary so read/edit collapse as concisely as bash.
1230
1264
  if (b.args) {
1231
1265
  try {
1232
1266
  const a = JSON.parse(b.args);
1233
1267
  if (typeof a === "object" && a !== null) {
1234
- const desc = a.description ?? a.summary ?? a.title ?? a.path ?? a.query ?? a.content ?? a.name;
1268
+ const desc = a.description ?? a.summary ?? a.title ?? a.query ?? a.content ?? a.name;
1235
1269
  if (desc) return String(desc).slice(0, 120);
1270
+ if (a.file_path) return `read ${String(a.file_path)}`.slice(0, 120);
1271
+ if (a.path && a.command) return `${a.command} ${String(a.path)}`.slice(0, 120);
1272
+ if (a.path) return String(a.path).slice(0, 120);
1236
1273
  return a.command ?? null;
1237
1274
  }
1238
1275
  return String(a).slice(0, 120);
@@ -1511,10 +1548,18 @@ export class App {
1511
1548
  setJobs(jobs) { this.jobs = jobs; this.layout(); this.redraw(); }
1512
1549
 
1513
1550
  #startPolling() {
1514
- this.pollTimer = setInterval(() => {
1551
+ // Self-rescheduling so the interval actually tracks run state (a fixed
1552
+ // setInterval would freeze at the idle 1500ms forever).
1553
+ let ticks = 0;
1554
+ const tick = () => {
1515
1555
  if (this.chat.sessionId) this.chat.pollTail();
1516
- this.refreshSessions();
1517
- }, this.chat.pollSlow ? 2000 : (this.chat.running ? 500 : 1500));
1556
+ // session.list is expensive (~100ms); refresh the sidebar every ~5s, not
1557
+ // on every streaming poll.
1558
+ if (ticks++ % 10 === 0) this.refreshSessions();
1559
+ const delay = this.chat.pollSlow ? 2000 : (this.chat.running ? 500 : 1500);
1560
+ this.pollTimer = setTimeout(tick, delay);
1561
+ };
1562
+ this.pollTimer = setTimeout(tick, 500);
1518
1563
  }
1519
1564
 
1520
1565
  async init() {
@@ -2007,9 +2052,6 @@ export class App {
2007
2052
  tx += strWidth(seg);
2008
2053
  }
2009
2054
  if (this.currentSession == null) s.text(x + w - 16, 0, "未选会话", { fg: T.FAINT, bg: T.PANEL });
2010
- const modeLabel = this.focused === this.chat?.input ? " INSERT " : " NORMAL ";
2011
- const modeColor = this.focused === this.chat?.input ? T.OK : T.FAINT;
2012
- s.text(x + w - strWidth(modeLabel), 0, modeLabel, { fg: modeColor, bg: T.PANEL, attrs: 1 });
2013
2055
  }
2014
2056
 
2015
2057
  #clickTab(px) {
@@ -2135,6 +2177,19 @@ export class App {
2135
2177
  }
2136
2178
  // global keys
2137
2179
  if (ev.type === "key") {
2180
+ // INSERT mode: Esc exits, everything else goes to the input for editing.
2181
+ // Global shortcuts (Ctrl+P, Ctrl+B, F7, …) are disabled here so plain
2182
+ // typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
2183
+ if (this.focused === this.chat.input) {
2184
+ if (ev.name === "escape") {
2185
+ this.focus(this.chat);
2186
+ this.toast("已退出输入(i 重新进入)");
2187
+ } else {
2188
+ this.chat.input.onKey(ev);
2189
+ }
2190
+ this.redraw();
2191
+ return;
2192
+ }
2138
2193
  if (this.searchActive && (ev.ctrl && ev.key === " " || ev.name === "f7")) {
2139
2194
  this.overlay = new ControlPanel(this, { startPage: 0 });
2140
2195
  this.redraw();
@@ -2174,11 +2229,13 @@ export class App {
2174
2229
  if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
2175
2230
  if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
2176
2231
  if (ev.name === "escape") {
2177
- if (this.focused === this.chat.input) {
2178
- this.focus(this.chat);
2179
- this.toast("已退出输入(i 重新进入)");
2180
- this.redraw();
2181
- } else if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
2232
+ if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
2233
+ else {
2234
+ // Esc in NORMAL mode interrupts a running turn (insert→normal→interrupt).
2235
+ const cur = this.sessions.find((s) => s.sessionId === this.currentSession);
2236
+ if (cur?.running) { this.cancelSession(cur); this.toast("已请求中断当前回合"); }
2237
+ else if (this.mode !== "chat") this.setMode("chat");
2238
+ }
2182
2239
  return;
2183
2240
  }
2184
2241
  if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
@@ -2297,6 +2354,10 @@ export class App {
2297
2354
  const rows = [];
2298
2355
  // ── row 0: identity ──
2299
2356
  const row0 = { left: [], right: [] };
2357
+ // Editing-mode badge first (near the input at the bottom): INSERT is loud,
2358
+ // NORMAL is subtle — the user's eyes are at the input line, not the top.
2359
+ const editing = this.focused === this.chat?.input;
2360
+ row0.left.push({ t: editing ? " INSERT " : " NORMAL ", fg: editing ? T.OK : T.FAINT, bg: T.STATUSBG, bold: editing });
2300
2361
  // Left badge: the session's permission/mode (e.g. "工作区写入/创造模式"),
2301
2362
  // which is far more meaningful than a static "工作区" label.
2302
2363
  const perm = this.projections.permissions?.currentValue;
@@ -2372,11 +2433,14 @@ export class App {
2372
2433
  if (this.menu) this.menu.render(s);
2373
2434
  if (this.overlay) this.overlay.render(s);
2374
2435
  if (this.toastMsg) {
2375
- const w = strWidth(this.toastMsg) + 4;
2376
- const x0 = Math.max(0, Math.floor(s.w / 2 - w / 2));
2377
- const x1 = Math.min(s.w - 1, Math.floor(s.w / 2 + w / 2));
2378
- s.fillRect(x0, 0, x1, 0, " ", { bg: T.BORDER });
2379
- s.text(x0 + 2, 0, truncate(this.toastMsg, s.w - 4), { fg: T.BOLD, bg: T.BORDER });
2436
+ // 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.
2439
+ const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
2440
+ 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 });
2443
+ s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
2380
2444
  }
2381
2445
  if (this.renameInput && this.popup) this.renameInput.render(s);
2382
2446
 
@@ -2385,6 +2449,11 @@ export class App {
2385
2449
  if (this.overlay && typeof this.overlay.kittyTransmit === "function" && kittyCapable()) {
2386
2450
  tail = this.overlay.kittyTransmit();
2387
2451
  }
2452
+ // Native terminal caret: a blinking vertical bar (text-editor style) at
2453
+ // the focused input's cursor cell; hidden everywhere else.
2454
+ const cell = this.focused?.cursorCell;
2455
+ if (cell) tail = `\x1b[?25h\x1b[${cell.y + 1};${cell.x + 1}H` + tail;
2456
+ else tail = "\x1b[?25l" + tail;
2388
2457
  this.term.output.write(out + tail);
2389
2458
  }
2390
2459
 
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(""); }
@@ -401,6 +474,7 @@ export class Input extends Widget {
401
474
  this.insert(ev.text);
402
475
  return true;
403
476
  case "enter":
477
+ if (ev.shift && this.multi) { this.insert("\n"); return true; } // Shift+Enter = newline
404
478
  if (this.value.trim() === "" && !this.allowEmptyEnter) return false;
405
479
  const v = this.value;
406
480
  this.history.push(v);