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 +1 -1
- package/src/views.js +21 -3
- package/src/widgets.js +94 -11
package/package.json
CHANGED
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
|
-
//
|
|
2799
|
-
//
|
|
2800
|
-
if (
|
|
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)
|
|
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)
|
|
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":
|
|
531
|
-
|
|
532
|
-
|
|
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
|