dsh-neotui 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Neo-TUI: mouse-driven terminal UI client for DeepSeek Harness (B-tier per dsh-tui-design.md)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/views.js CHANGED
@@ -1824,6 +1824,7 @@ export class App {
1824
1824
  this.sidebarVisible = true; // Ctrl+B hides the whole session pane (nvim-style)
1825
1825
  this.sidebarWidth = 30; // draggable divider: the session pane's column width
1826
1826
  this.draggingDivider = false;
1827
+ this.inputDrag = false; // mouse drag-selection inside the input is active
1827
1828
  this.feedbackMap = new Map(); // messageId → {rating, version}
1828
1829
  this.searchQuery = null; // active find-in-conversation term (highlight)
1829
1830
  this.findQuery = null; // term being typed in the find picker
@@ -2768,6 +2769,13 @@ export class App {
2768
2769
  // unhandled keys fall through to global shortcuts
2769
2770
  }
2770
2771
  if (ev.type === "mouse") {
2772
+ // input drag-selection: the gesture continues across motion events
2773
+ // even when the pointer leaves the input area
2774
+ if (ev.kind === "release" && ev.button === 0 && this.inputDrag) {
2775
+ this.inputDrag = false;
2776
+ if (this.chat.input.onMouse(ev)) this.redraw();
2777
+ return;
2778
+ }
2771
2779
  // Draggable sidebar divider: press on the boundary column (±1) starts a
2772
2780
  // resize; drag events (motion flag) update the width live.
2773
2781
  const divX = this.sidebarVisible ? this.sidebarWidth : -1;
@@ -2788,6 +2796,10 @@ export class App {
2788
2796
  // Pure mouse motion must never change focus/mode (vim-style: INSERT is
2789
2797
  // keyboard-only). It reaches just the already-focused widget (drag select).
2790
2798
  if (ev.motion) {
2799
+ if (this.inputDrag) {
2800
+ if (this.chat.input.onMouse(ev)) this.redraw();
2801
+ return;
2802
+ }
2791
2803
  if (this.focused?.onMouse?.(ev)) this.redraw();
2792
2804
  return;
2793
2805
  }
@@ -2795,9 +2807,15 @@ export class App {
2795
2807
  this.focus(this.sidebar);
2796
2808
  if (this.sidebar.onMouse(ev)) this.redraw();
2797
2809
  } else if (this.chat.input.inside(ev.x, ev.y)) {
2798
- // Clicking the input only positions the cursor while already in INSERT
2799
- // mode; it never enters INSERT on its own (i / Esc control the mode).
2800
- if (this.focused === this.chat.input) {
2810
+ // mouse SELECTION in the input works regardless of mode; typing stays
2811
+ // gated behind i / Esc (vim-style)
2812
+ if (ev.kind === "press" && ev.button === 0) {
2813
+ this.inputDrag = true;
2814
+ if (this.chat.input.onMouse(ev)) this.redraw();
2815
+ } else if (ev.kind === "release" && ev.button === 0) {
2816
+ this.inputDrag = false;
2817
+ if (this.chat.input.onMouse(ev)) this.redraw();
2818
+ } else if (this.focused === this.chat.input) {
2801
2819
  if (this.chat.input.onMouse(ev)) this.redraw();
2802
2820
  } else if (ev.kind === "press") {
2803
2821
  this.toast("按 i 进入输入(vim 式)");
package/src/widgets.js CHANGED
@@ -258,6 +258,8 @@ export class Input extends Widget {
258
258
  this.app = opts.app ?? null;
259
259
  this.pendingPaste = null; // large-paste stage 1: held-back clipboard text
260
260
  this.pasteMark = null; // code-point span of the immutable "[已复制…]" token
261
+ this.selStart = null; // drag-selection [start, end) code-point span
262
+ this.selEnd = null;
261
263
  this.onChange = opts.onChange ?? null;
262
264
  this.allowEmptyEnter = opts.allowEmptyEnter ?? false;
263
265
  this.history = [];
@@ -333,12 +335,13 @@ export class Input extends Widget {
333
335
  this.#touch();
334
336
  }
335
337
  this.#edit(at, at, text);
336
- this.onChange?.();
337
338
  }
338
339
  /** EVERY edit goes through here so the immutable paste token behaves as
339
340
  * one unit: deleting any part of it removes it whole, typing inside it
340
- * replaces it, edits elsewhere just shift it. */
341
+ * replaces it, edits elsewhere just shift it. Always notifies onChange —
342
+ * the second-paste swap must reflow the layout just like typing. */
341
343
  #edit(from, to, text = "") {
344
+ this.selStart = this.selEnd = null; // edits consume the selection
342
345
  const cps = this.#cps();
343
346
  const t = Array.from(text);
344
347
  const m = this.pasteMark;
@@ -351,6 +354,7 @@ export class Input extends Widget {
351
354
  this.pasteMark = null;
352
355
  this.pendingPaste = null;
353
356
  this.value = cps.join("");
357
+ this.onChange?.();
354
358
  return;
355
359
  }
356
360
  if (m && to <= m.start) {
@@ -360,6 +364,7 @@ export class Input extends Widget {
360
364
  cps.splice(from, to - from, ...t);
361
365
  this.cursor = from + t.length;
362
366
  this.value = cps.join("");
367
+ this.onChange?.();
363
368
  }
364
369
  #deleteAt(idx) {
365
370
  this.#edit(idx, idx + 1);
@@ -419,6 +424,21 @@ export class Input extends Widget {
419
424
  screen.text(this.x + 1, y, r.text, { fg: this.fg, bg: this.bg });
420
425
  }
421
426
  }
427
+ // drag-selection highlight: invert the selected columns per wrapped row
428
+ if (this.selStart !== null && this.selEnd !== null && this.selEnd > this.selStart) {
429
+ for (let ri = start; ri < Math.min(rows.length, start + h); ri++) {
430
+ const r = rows[ri];
431
+ const lo = Math.max(r.start, this.selStart);
432
+ const hi = Math.min(r.end, this.selEnd);
433
+ if (lo >= hi) continue;
434
+ const rowY = this.y + (ri - start);
435
+ const text = Array.from(r.text);
436
+ const preW = strWidth(text.slice(0, lo - r.start).join(""));
437
+ const selW = strWidth(text.slice(lo - r.start, hi - r.start).join(""));
438
+ const x0 = this.x + (ri === 0 ? strWidth(this.prompt) : 1) + preW;
439
+ screen.invertRect(x0, rowY, Math.min(this.x + this.w - 1, x0 + Math.max(0, selW - 1)), rowY);
440
+ }
441
+ }
422
442
  // Native terminal caret (blinking bar) is positioned by the app after the
423
443
  // frame; store the cell it should occupy (the char at the cursor index).
424
444
  const curY = this.y + (curRow - start);
@@ -433,6 +453,7 @@ export class Input extends Widget {
433
453
  const row = start + Math.max(0, Math.min(h - 1, ev.y - this.y));
434
454
  const rx = ev.x - this.x - (row === 0 ? strWidth(this.prompt) : 1);
435
455
  this.cursor = this.#indexAtVisual(row, Math.max(0, rx));
456
+ this.#snapCursor();
436
457
  } else {
437
458
  const rx = ev.x - this.x - strWidth(this.prompt);
438
459
  let w = 0, idx = 0;
@@ -443,11 +464,52 @@ export class Input extends Widget {
443
464
  idx++;
444
465
  }
445
466
  this.cursor = idx;
467
+ this.#snapCursor();
446
468
  }
469
+ // press anchors a drag-selection at the cursor
470
+ this.selStart = this.cursor;
471
+ this.selEnd = this.cursor;
472
+ return true;
473
+ }
474
+ if ((ev.kind === "drag" || ev.kind === "release") && ev.button === 0 && this.selStart !== null) {
475
+ // extend the selection with the pointer (clamped to the text)
476
+ if (this.multi) {
477
+ const h = this.height();
478
+ const start = this.scrollY ?? 0;
479
+ const row = start + Math.max(0, Math.min(h - 1, ev.y - this.y));
480
+ const rx = ev.x - this.x - (row === 0 ? strWidth(this.prompt) : 1);
481
+ this.cursor = this.#indexAtVisual(row, Math.max(0, rx));
482
+ } else {
483
+ const rx = ev.x - this.x - strWidth(this.prompt);
484
+ let w = 0, idx = 0;
485
+ for (const ch of Array.from(this.value)) {
486
+ const cw = strWidth(ch);
487
+ if (rx < w + cw / 2) break;
488
+ w += cw;
489
+ idx++;
490
+ }
491
+ this.cursor = idx;
492
+ }
493
+ this.#snapCursor();
494
+ this.selEnd = this.cursor;
495
+ if (this.selEnd < this.selStart) { const t = this.selStart; this.selStart = this.selEnd; this.selEnd = t; }
447
496
  return true;
448
497
  }
449
498
  return false;
450
499
  }
500
+ /** The paste token is a single cursor unit: the caret never rests inside
501
+ * its span — LEFT from the end hops to its start, RIGHT from the start
502
+ * hops to its end, and clicks/moves snap to the nearest boundary.
503
+ * dir: -1 = leftward movement → start, +1 = rightward → end, 0 = nearest. */
504
+ #snapCursor(dir = 0) {
505
+ const m = this.pasteMark;
506
+ if (!m) return;
507
+ if (this.cursor > m.start && this.cursor < m.end) {
508
+ if (dir < 0) this.cursor = m.start;
509
+ else if (dir > 0) this.cursor = m.end;
510
+ else this.cursor = this.cursor - m.start < m.end - this.cursor ? m.start : m.end;
511
+ }
512
+ }
451
513
  /** Cancel the held-back paste AND its token. */
452
514
  #touch() { this.pendingPaste = null; this.pasteMark = null; }
453
515
  /** Claude-Code-style two-stage paste: the first Ctrl+Shift+V of a large
@@ -485,38 +547,44 @@ export class Input extends Widget {
485
547
  if (ev.type !== "key") return false;
486
548
  switch (ev.name) {
487
549
  case "backspace":
488
- if (this.cursor > 0) { this.#edit(this.cursor - 1, this.cursor); this.onChange?.(); }
550
+ if (this.cursor > 0) this.#edit(this.cursor - 1, this.cursor);
489
551
  return true;
490
552
  case "delete":
491
- if (this.cursor < this.#cps().length) { this.#edit(this.cursor, this.cursor + 1); this.onChange?.(); }
553
+ if (this.cursor < this.#cps().length) this.#edit(this.cursor, this.cursor + 1);
492
554
  return true;
493
- case "left": this.selectAll = false; this.cursor = Math.max(0, this.cursor - 1); return true;
494
- case "right": this.selectAll = false; this.cursor = Math.min(this.#cps().length, this.cursor + 1); return true;
555
+ case "left": this.selectAll = false; this.selStart = this.selEnd = null; this.cursor = Math.max(0, this.cursor - 1); this.#snapCursor(-1); return true;
556
+ case "right": this.selectAll = false; this.selStart = this.selEnd = null; this.cursor = Math.min(this.#cps().length, this.cursor + 1); this.#snapCursor(1); return true;
495
557
  case "home": {
558
+ this.selStart = this.selEnd = null;
496
559
  if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].start; }
497
560
  else this.cursor = 0;
561
+ this.#snapCursor();
498
562
  return true;
499
563
  }
500
564
  case "end": {
565
+ this.selStart = this.selEnd = null;
501
566
  if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].end; }
502
567
  else this.cursor = this.#cps().length;
568
+ this.#snapCursor();
503
569
  return true;
504
570
  }
505
571
  case "up":
572
+ this.selStart = this.selEnd = null;
506
573
  if (this.multi) {
507
574
  const rows = this.#visualRows();
508
575
  const { row, col } = this.#cursorVisual();
509
- if (row > 0) this.cursor = this.#indexAtVisual(row - 1, col);
576
+ if (row > 0) { this.cursor = this.#indexAtVisual(row - 1, col); this.#snapCursor(); }
510
577
  } else if (this.history.length) {
511
578
  this.histIdx = this.histIdx < 0 ? this.history.length - 1 : Math.max(0, this.histIdx - 1);
512
579
  this.setValue(this.history[this.histIdx] ?? "");
513
580
  }
514
581
  return true;
515
582
  case "down":
583
+ this.selStart = this.selEnd = null;
516
584
  if (this.multi) {
517
585
  const rows = this.#visualRows();
518
586
  const { row, col } = this.#cursorVisual();
519
- if (row < rows.length - 1) this.cursor = this.#indexAtVisual(row + 1, col);
587
+ if (row < rows.length - 1) { this.cursor = this.#indexAtVisual(row + 1, col); this.#snapCursor(); }
520
588
  } else if (this.histIdx >= 0) {
521
589
  this.histIdx++;
522
590
  if (this.histIdx >= this.history.length) { this.histIdx = -1; this.setValue(""); }
@@ -527,9 +595,24 @@ export class Input extends Widget {
527
595
  if (ev.ctrl) {
528
596
  switch (ev.key) {
529
597
  case "j": if (this.multi) { this.insert("\n"); return true; } return false;
530
- case "c": this.#touch(); this.value = ""; this.cursor = 0; this.selectAll = false; this.onChange?.(); this.app?.toast?.("已清空输入栏"); return true;
531
- case "u": this.#edit(0, this.cursor); this.onChange?.(); return true;
532
- case "k": this.#edit(this.cursor, this.#cps().length); this.onChange?.(); return true;
598
+ case "c": {
599
+ // a drag-selection copies it; otherwise Ctrl+C clears the input
600
+ if (this.selStart !== null && this.selEnd !== null && this.selEnd > this.selStart) {
601
+ const text = Array.from(this.value).slice(this.selStart, this.selEnd).join("");
602
+ this.selStart = this.selEnd = null;
603
+ this.app?.copyText?.(text);
604
+ return true;
605
+ }
606
+ this.#touch();
607
+ this.value = "";
608
+ this.cursor = 0;
609
+ this.selectAll = false;
610
+ this.onChange?.();
611
+ this.app?.toast?.("已清空输入栏");
612
+ return true;
613
+ }
614
+ case "u": this.#edit(0, this.cursor); return true;
615
+ case "k": this.#edit(this.cursor, this.#cps().length); return true;
533
616
  case "l": {
534
617
  // expand/collapse the input: expanded drops the 6-line cap and
535
618
  // lets the editor fill the window above the input