dsh-neotui 0.1.8 → 0.1.9

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 +50 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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();
@@ -422,29 +448,31 @@ export class Input extends Widget {
422
448
  }
423
449
  return false;
424
450
  }
425
- /** Any manual edit cancels the held-back paste (the placeholder stays as
426
- * ordinary text). */
427
- #touch() { this.pendingPaste = null; this.pendingPlaceholder = null; }
451
+ /** Cancel the held-back paste AND its token. */
452
+ #touch() { this.pendingPaste = null; this.pasteMark = null; }
428
453
  /** 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. */
454
+ * clipboard shows a "[已复制 N 行内容]" placeholder an IMMUTABLE token:
455
+ * deleting/typing into it consumes it whole, edits around it keep it, and
456
+ * pasting the same content again replaces exactly that token. */
431
457
  #paste(text) {
432
458
  text = String(text ?? "");
433
459
  const large = text.includes("\n") || text.length > 300;
434
460
  if (large) {
435
461
  if (this.pendingPaste && this.pendingPaste.text === text) {
436
462
  const full = this.pendingPaste.text;
437
- const placeholder = this.pendingPlaceholder;
463
+ const m = this.pasteMark;
438
464
  this.#touch();
439
- if (this.value === placeholder) this.setValue(full);
465
+ if (m) this.#edit(m.start, m.end, full);
440
466
  else this.insert(full);
441
467
  this.app?.toast?.("已粘贴完整内容");
442
468
  return true;
443
469
  }
444
470
  const lines = text.split("\n").length;
471
+ const placeholder = `[已复制 ${lines} 行内容]`;
472
+ const at = this.selectAll ? 0 : this.cursor;
473
+ this.insert(placeholder);
474
+ this.pasteMark = { start: at, end: at + Array.from(placeholder).length };
445
475
  this.pendingPaste = { text };
446
- this.pendingPlaceholder = `[已复制 ${lines} 行内容]`;
447
- this.insert(this.pendingPlaceholder);
448
476
  this.app?.toast?.("再次 Ctrl+Shift+V 粘贴完整内容(Ctrl+L 展开输入栏)");
449
477
  return true;
450
478
  }
@@ -457,10 +485,10 @@ export class Input extends Widget {
457
485
  if (ev.type !== "key") return false;
458
486
  switch (ev.name) {
459
487
  case "backspace":
460
- if (this.cursor > 0) { this.#touch(); this.#deleteAt(this.cursor - 1); this.cursor--; this.onChange?.(); }
488
+ if (this.cursor > 0) { this.#edit(this.cursor - 1, this.cursor); this.onChange?.(); }
461
489
  return true;
462
490
  case "delete":
463
- if (this.cursor < this.#cps().length) { this.#touch(); this.#deleteAt(this.cursor); this.onChange?.(); }
491
+ if (this.cursor < this.#cps().length) { this.#edit(this.cursor, this.cursor + 1); this.onChange?.(); }
464
492
  return true;
465
493
  case "left": this.selectAll = false; this.cursor = Math.max(0, this.cursor - 1); return true;
466
494
  case "right": this.selectAll = false; this.cursor = Math.min(this.#cps().length, this.cursor + 1); return true;
@@ -500,8 +528,8 @@ export class Input extends Widget {
500
528
  switch (ev.key) {
501
529
  case "j": if (this.multi) { this.insert("\n"); return true; } return false;
502
530
  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;
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;
505
533
  case "l": {
506
534
  // expand/collapse the input: expanded drops the 6-line cap and
507
535
  // lets the editor fill the window above the input
@@ -527,13 +555,13 @@ export class Input extends Widget {
527
555
  }
528
556
  return false;
529
557
  }
530
- this.#touch();
531
558
  this.insert(ev.text);
532
559
  return true;
533
560
  case "enter":
534
561
  if (ev.shift && this.multi) { this.insert("\n"); return true; } // Shift+Enter = newline
535
562
  if (this.value.trim() === "" && !this.allowEmptyEnter) return false;
536
563
  const v = this.value;
564
+ this.#touch();
537
565
  this.history.push(v);
538
566
  this.histIdx = -1;
539
567
  this.value = "";