dsh-neotui 0.0.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.2",
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;
@@ -781,6 +787,7 @@ export class ControlPanel extends Widget {
781
787
  this.subPages = ["常规", "插件"];
782
788
  this.subPage = 0;
783
789
  this.sel = 0;
790
+ this.scroll = 0;
784
791
  this.commands = DEFAULT_COMMANDS;
785
792
  this.plugins = null;
786
793
  this.pluginError = null;
@@ -884,9 +891,15 @@ export class ControlPanel extends Widget {
884
891
  }
885
892
  const items = this.items();
886
893
  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;
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;
890
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 });
891
904
  const label = it[0];
892
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 });
@@ -947,9 +960,9 @@ export class ControlPanel extends Widget {
947
960
  }
948
961
  return true;
949
962
  }
950
- const idx = ev.y - this.y - 2;
963
+ const idx = this.scroll + (ev.y - this.y - 2);
951
964
  const items = this.items();
952
- 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) {
953
966
  this.sel = idx;
954
967
  const it = items[idx];
955
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
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);
@@ -2396,11 +2433,14 @@ export class App {
2396
2433
  if (this.menu) this.menu.render(s);
2397
2434
  if (this.overlay) this.overlay.render(s);
2398
2435
  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 });
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 });
2404
2444
  }
2405
2445
  if (this.renameInput && this.popup) this.renameInput.render(s);
2406
2446
 
@@ -2409,6 +2449,11 @@ export class App {
2409
2449
  if (this.overlay && typeof this.overlay.kittyTransmit === "function" && kittyCapable()) {
2410
2450
  tail = this.overlay.kittyTransmit();
2411
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;
2412
2457
  this.term.output.write(out + tail);
2413
2458
  }
2414
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(""); }