dsh-neotui 0.1.8 → 0.1.10

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/widgets.js +71 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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/widgets.js CHANGED
@@ -257,7 +257,7 @@ export class Input extends Widget {
257
257
  this.expanded = false; // Ctrl+L: drop the line cap and fill the window
258
258
  this.app = opts.app ?? null;
259
259
  this.pendingPaste = null; // large-paste stage 1: held-back clipboard text
260
- this.pendingPlaceholder = null; // the "[已复制 N 行内容]" marker currently shown
260
+ this.pasteMark = null; // code-point span of the immutable "[已复制…]" token
261
261
  this.onChange = opts.onChange ?? null;
262
262
  this.allowEmptyEnter = opts.allowEmptyEnter ?? false;
263
263
  this.history = [];
@@ -325,19 +325,45 @@ export class Input extends Widget {
325
325
  this.onChange?.();
326
326
  }
327
327
  insert(text) {
328
- const cps = this.selectAll ? [] : this.#cps();
329
328
  const at = this.selectAll ? 0 : this.cursor;
330
- this.selectAll = false;
331
- cps.splice(at, 0, ...Array.from(text));
332
- this.value = cps.join("");
333
- this.cursor = at + Array.from(text).length;
329
+ if (this.selectAll) {
330
+ this.selectAll = false;
331
+ this.value = "";
332
+ this.cursor = 0;
333
+ this.#touch();
334
+ }
335
+ this.#edit(at, at, text);
334
336
  this.onChange?.();
335
337
  }
336
- #deleteAt(idx) {
338
+ /** EVERY edit goes through here so the immutable paste token behaves as
339
+ * one unit: deleting any part of it removes it whole, typing inside it
340
+ * replaces it, edits elsewhere just shift it. */
341
+ #edit(from, to, text = "") {
337
342
  const cps = this.#cps();
338
- cps.splice(idx, 1);
343
+ const t = Array.from(text);
344
+ const m = this.pasteMark;
345
+ if (m && to > m.start && from < m.end) {
346
+ // the edit touches the token: apply it over the whole token span
347
+ const lo = Math.min(from, m.start);
348
+ const hi = Math.max(to, m.end);
349
+ cps.splice(lo, hi - lo, ...t);
350
+ this.cursor = lo + t.length;
351
+ this.pasteMark = null;
352
+ this.pendingPaste = null;
353
+ this.value = cps.join("");
354
+ return;
355
+ }
356
+ if (m && to <= m.start) {
357
+ const delta = t.length - (to - from);
358
+ m.start += delta; m.end += delta;
359
+ }
360
+ cps.splice(from, to - from, ...t);
361
+ this.cursor = from + t.length;
339
362
  this.value = cps.join("");
340
363
  }
364
+ #deleteAt(idx) {
365
+ this.#edit(idx, idx + 1);
366
+ }
341
367
  /** Scroll offset that keeps the cursor's visual row inside the window. */
342
368
  #scrollStart(h) {
343
369
  const rows = this.#visualRows();
@@ -407,6 +433,7 @@ export class Input extends Widget {
407
433
  const row = start + Math.max(0, Math.min(h - 1, ev.y - this.y));
408
434
  const rx = ev.x - this.x - (row === 0 ? strWidth(this.prompt) : 1);
409
435
  this.cursor = this.#indexAtVisual(row, Math.max(0, rx));
436
+ this.#snapCursor();
410
437
  } else {
411
438
  const rx = ev.x - this.x - strWidth(this.prompt);
412
439
  let w = 0, idx = 0;
@@ -417,34 +444,50 @@ export class Input extends Widget {
417
444
  idx++;
418
445
  }
419
446
  this.cursor = idx;
447
+ this.#snapCursor();
420
448
  }
421
449
  return true;
422
450
  }
423
451
  return false;
424
452
  }
425
- /** Any manual edit cancels the held-back paste (the placeholder stays as
426
- * ordinary text). */
427
- #touch() { this.pendingPaste = null; this.pendingPlaceholder = null; }
453
+ /** The paste token is a single cursor unit: the caret never rests inside
454
+ * its span — LEFT from the end hops to its start, RIGHT from the start
455
+ * hops to its end, and clicks/moves snap to the nearest boundary.
456
+ * dir: -1 = leftward movement → start, +1 = rightward → end, 0 = nearest. */
457
+ #snapCursor(dir = 0) {
458
+ const m = this.pasteMark;
459
+ if (!m) return;
460
+ if (this.cursor > m.start && this.cursor < m.end) {
461
+ if (dir < 0) this.cursor = m.start;
462
+ else if (dir > 0) this.cursor = m.end;
463
+ else this.cursor = this.cursor - m.start < m.end - this.cursor ? m.start : m.end;
464
+ }
465
+ }
466
+ /** Cancel the held-back paste AND its token. */
467
+ #touch() { this.pendingPaste = null; this.pasteMark = null; }
428
468
  /** Claude-Code-style two-stage paste: the first Ctrl+Shift+V of a large
429
- * clipboard shows a "[已复制 N 行内容]" placeholder; pasting the same
430
- * content again inserts it in full, like a normal paste. */
469
+ * clipboard shows a "[已复制 N 行内容]" placeholder an IMMUTABLE token:
470
+ * deleting/typing into it consumes it whole, edits around it keep it, and
471
+ * pasting the same content again replaces exactly that token. */
431
472
  #paste(text) {
432
473
  text = String(text ?? "");
433
474
  const large = text.includes("\n") || text.length > 300;
434
475
  if (large) {
435
476
  if (this.pendingPaste && this.pendingPaste.text === text) {
436
477
  const full = this.pendingPaste.text;
437
- const placeholder = this.pendingPlaceholder;
478
+ const m = this.pasteMark;
438
479
  this.#touch();
439
- if (this.value === placeholder) this.setValue(full);
480
+ if (m) this.#edit(m.start, m.end, full);
440
481
  else this.insert(full);
441
482
  this.app?.toast?.("已粘贴完整内容");
442
483
  return true;
443
484
  }
444
485
  const lines = text.split("\n").length;
486
+ const placeholder = `[已复制 ${lines} 行内容]`;
487
+ const at = this.selectAll ? 0 : this.cursor;
488
+ this.insert(placeholder);
489
+ this.pasteMark = { start: at, end: at + Array.from(placeholder).length };
445
490
  this.pendingPaste = { text };
446
- this.pendingPlaceholder = `[已复制 ${lines} 行内容]`;
447
- this.insert(this.pendingPlaceholder);
448
491
  this.app?.toast?.("再次 Ctrl+Shift+V 粘贴完整内容(Ctrl+L 展开输入栏)");
449
492
  return true;
450
493
  }
@@ -457,28 +500,30 @@ export class Input extends Widget {
457
500
  if (ev.type !== "key") return false;
458
501
  switch (ev.name) {
459
502
  case "backspace":
460
- if (this.cursor > 0) { this.#touch(); this.#deleteAt(this.cursor - 1); this.cursor--; this.onChange?.(); }
503
+ if (this.cursor > 0) { this.#edit(this.cursor - 1, this.cursor); this.onChange?.(); }
461
504
  return true;
462
505
  case "delete":
463
- if (this.cursor < this.#cps().length) { this.#touch(); this.#deleteAt(this.cursor); this.onChange?.(); }
506
+ if (this.cursor < this.#cps().length) { this.#edit(this.cursor, this.cursor + 1); this.onChange?.(); }
464
507
  return true;
465
- case "left": this.selectAll = false; this.cursor = Math.max(0, this.cursor - 1); return true;
466
- case "right": this.selectAll = false; this.cursor = Math.min(this.#cps().length, this.cursor + 1); 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;
467
510
  case "home": {
468
511
  if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].start; }
469
512
  else this.cursor = 0;
513
+ this.#snapCursor();
470
514
  return true;
471
515
  }
472
516
  case "end": {
473
517
  if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].end; }
474
518
  else this.cursor = this.#cps().length;
519
+ this.#snapCursor();
475
520
  return true;
476
521
  }
477
522
  case "up":
478
523
  if (this.multi) {
479
524
  const rows = this.#visualRows();
480
525
  const { row, col } = this.#cursorVisual();
481
- if (row > 0) this.cursor = this.#indexAtVisual(row - 1, col);
526
+ if (row > 0) { this.cursor = this.#indexAtVisual(row - 1, col); this.#snapCursor(); }
482
527
  } else if (this.history.length) {
483
528
  this.histIdx = this.histIdx < 0 ? this.history.length - 1 : Math.max(0, this.histIdx - 1);
484
529
  this.setValue(this.history[this.histIdx] ?? "");
@@ -488,7 +533,7 @@ export class Input extends Widget {
488
533
  if (this.multi) {
489
534
  const rows = this.#visualRows();
490
535
  const { row, col } = this.#cursorVisual();
491
- if (row < rows.length - 1) this.cursor = this.#indexAtVisual(row + 1, col);
536
+ if (row < rows.length - 1) { this.cursor = this.#indexAtVisual(row + 1, col); this.#snapCursor(); }
492
537
  } else if (this.histIdx >= 0) {
493
538
  this.histIdx++;
494
539
  if (this.histIdx >= this.history.length) { this.histIdx = -1; this.setValue(""); }
@@ -500,8 +545,8 @@ export class Input extends Widget {
500
545
  switch (ev.key) {
501
546
  case "j": if (this.multi) { this.insert("\n"); return true; } return false;
502
547
  case "c": this.#touch(); this.value = ""; this.cursor = 0; this.selectAll = false; this.onChange?.(); this.app?.toast?.("已清空输入栏"); return true;
503
- case "u": this.#touch(); this.value = this.#cps().slice(this.cursor).join(""); this.cursor = 0; this.onChange?.(); return true;
504
- case "k": this.#touch(); this.value = this.#cps().slice(0, this.cursor).join(""); this.onChange?.(); 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;
505
550
  case "l": {
506
551
  // expand/collapse the input: expanded drops the 6-line cap and
507
552
  // lets the editor fill the window above the input
@@ -527,13 +572,13 @@ export class Input extends Widget {
527
572
  }
528
573
  return false;
529
574
  }
530
- this.#touch();
531
575
  this.insert(ev.text);
532
576
  return true;
533
577
  case "enter":
534
578
  if (ev.shift && this.multi) { this.insert("\n"); return true; } // Shift+Enter = newline
535
579
  if (this.value.trim() === "" && !this.allowEmptyEnter) return false;
536
580
  const v = this.value;
581
+ this.#touch();
537
582
  this.history.push(v);
538
583
  this.histIdx = -1;
539
584
  this.value = "";