dsh-neotui 0.1.10 → 0.1.12

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.10",
3
+ "version": "0.1.12",
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/panels.js CHANGED
@@ -1219,6 +1219,7 @@ export class ControlPanel extends Widget {
1219
1219
  ["[", "上一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "[", text: "[", ctrl: false, alt: false, shift: false }); }],
1220
1220
  ["]", "下一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "]", text: "]", ctrl: false, alt: false, shift: false }); }],
1221
1221
  ["Ctrl+L", "输入栏 展开/折叠", () => { this.app.closeOverlay(); this.app.focus(this.app.chat.input); this.app.chat.input.onKey({ type: "key", name: "char", key: "l", text: "l", ctrl: true, alt: false, shift: false }); }],
1222
+ ["Ctrl+Shift+C", "复制输入栏选区", () => { this.app.closeOverlay(); this.app.chat.input.onKey({ type: "key", name: "char", key: "c", text: "c", ctrl: true, alt: false, shift: true }); }],
1222
1223
  ["Ctrl+P", "控制面板", () => { this.page = 1; this.sel = 0; this.app.redraw(); }],
1223
1224
  ["Ctrl+M", "切换模型", () => { this.app.overlay = buildModelPicker(this.app); }],
1224
1225
  ["Ctrl+T", "轨迹视图", () => { this.app.closeOverlay(); this.app.setMode("trajectory"); }],
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);
@@ -446,6 +466,33 @@ export class Input extends Widget {
446
466
  this.cursor = idx;
447
467
  this.#snapCursor();
448
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; }
449
496
  return true;
450
497
  }
451
498
  return false;
@@ -500,26 +547,29 @@ export class Input extends Widget {
500
547
  if (ev.type !== "key") return false;
501
548
  switch (ev.name) {
502
549
  case "backspace":
503
- 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);
504
551
  return true;
505
552
  case "delete":
506
- 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);
507
554
  return true;
508
- case "left": this.selectAll = false; this.cursor = Math.max(0, this.cursor - 1); this.#snapCursor(-1); return true;
509
- case "right": this.selectAll = false; this.cursor = Math.min(this.#cps().length, this.cursor + 1); this.#snapCursor(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;
510
557
  case "home": {
558
+ this.selStart = this.selEnd = null;
511
559
  if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].start; }
512
560
  else this.cursor = 0;
513
561
  this.#snapCursor();
514
562
  return true;
515
563
  }
516
564
  case "end": {
565
+ this.selStart = this.selEnd = null;
517
566
  if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].end; }
518
567
  else this.cursor = this.#cps().length;
519
568
  this.#snapCursor();
520
569
  return true;
521
570
  }
522
571
  case "up":
572
+ this.selStart = this.selEnd = null;
523
573
  if (this.multi) {
524
574
  const rows = this.#visualRows();
525
575
  const { row, col } = this.#cursorVisual();
@@ -530,6 +580,7 @@ export class Input extends Widget {
530
580
  }
531
581
  return true;
532
582
  case "down":
583
+ this.selStart = this.selEnd = null;
533
584
  if (this.multi) {
534
585
  const rows = this.#visualRows();
535
586
  const { row, col } = this.#cursorVisual();
@@ -544,9 +595,29 @@ export class Input extends Widget {
544
595
  if (ev.ctrl) {
545
596
  switch (ev.key) {
546
597
  case "j": if (this.multi) { this.insert("\n"); return true; } return false;
547
- case "c": this.#touch(); this.value = ""; this.cursor = 0; this.selectAll = false; this.onChange?.(); this.app?.toast?.("已清空输入栏"); return true;
548
- case "u": this.#edit(0, this.cursor); this.onChange?.(); return true;
549
- case "k": this.#edit(this.cursor, this.#cps().length); this.onChange?.(); return true;
598
+ case "c": {
599
+ if (ev.shift) {
600
+ // Ctrl+Shift+C: copy the drag-selection (if any)
601
+ if (this.selStart !== null && this.selEnd !== null && this.selEnd > this.selStart) {
602
+ const text = Array.from(this.value).slice(this.selStart, this.selEnd).join("");
603
+ this.selStart = this.selEnd = null;
604
+ this.app?.copyText?.(text);
605
+ } else {
606
+ this.app?.toast?.("先用鼠标拖动选中要复制的内容");
607
+ }
608
+ return true;
609
+ }
610
+ // plain Ctrl+C keeps ONE meaning here: clear the input
611
+ this.#touch();
612
+ this.value = "";
613
+ this.cursor = 0;
614
+ this.selectAll = false;
615
+ this.onChange?.();
616
+ this.app?.toast?.("已清空输入栏");
617
+ return true;
618
+ }
619
+ case "u": this.#edit(0, this.cursor); return true;
620
+ case "k": this.#edit(this.cursor, this.#cps().length); return true;
550
621
  case "l": {
551
622
  // expand/collapse the input: expanded drops the 6-line cap and
552
623
  // lets the editor fill the window above the input