dsh-neotui 0.2.2 → 0.3.0

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/src/term.js CHANGED
@@ -30,6 +30,7 @@ export class Term {
30
30
  this.onResize = onResize ?? (() => {});
31
31
  this.kitty = kitty;
32
32
  this.kittyActive = false; // set when the terminal answers the CSI ? u query
33
+ this.handoff = process.env.DSH_TUI_RESTART_HANDOFF === "1";
33
34
  this.escTimer = null; // pending lone-ESC fallback (split-sequence safety)
34
35
  this.decoder = new StringDecoder("utf8");
35
36
  this.buf = "";
@@ -90,7 +91,7 @@ export class Term {
90
91
  }
91
92
  }
92
93
 
93
- #emit(ev) { this.onEvent(ev); }
94
+ #emit(ev) { this.handoff = false; this.onEvent(ev); }
94
95
 
95
96
  /** A lone ESC waits briefly for the rest of its sequence; if nothing
96
97
  * arrives it becomes the standalone Escape key. */
@@ -144,13 +145,12 @@ export class Term {
144
145
  continue;
145
146
  }
146
147
  const ch = buf[i];
147
- // Restart handoff recovery: a terminal can split ESC from the remainder
148
- // of an SGR mouse/kitty reply while the old process is relinquishing the
149
- // tty. The new process must discard these orphaned control tails rather
150
- // than insert them as user text.
151
- if (ch === "[" && (buf.startsWith("[<", i) || /^\[\d+(?:;\d+)*[uMm]/.test(buf.slice(i)))) {
148
+ // Only a deliberately restarted process may discard one leading tail
149
+ // whose ESC belonged to its predecessor. Normal typed text beginning
150
+ // with "[99;5u" or "[<…M" must always survive.
151
+ if (this.handoff && i === 0 && ch === "[") {
152
152
  const tail = /^(?:\[<\d+;\d+;\d+[Mm]|\[\d+(?:;\d+)*u)/.exec(buf.slice(i));
153
- if (tail) { i += tail[0].length; continue; }
153
+ if (tail) { this.handoff = false; i += tail[0].length; continue; }
154
154
  }
155
155
  if (ch === "\x1b") {
156
156
  // escape sequence
@@ -278,7 +278,10 @@ export class Term {
278
278
  // CSI ? flags u — the terminal answered our kitty-protocol query, so
279
279
  // it DOES honor the protocol (WezTerm etc. reply with the flags it
280
280
  // supports; terminals with the feature off reply nothing at all).
281
- if (final === "u") this.kittyActive = true;
281
+ if (final === "u") {
282
+ const flags = Number(params.split(";")[0] || 0);
283
+ if (flags & 1) this.kittyActive = true;
284
+ }
282
285
  return; // other private responses (cursor pos) stay ignored
283
286
  }
284
287
  if (prefix === ">") return;
@@ -297,6 +300,8 @@ export class Term {
297
300
  const code = nums[2] ?? 0;
298
301
  const mod = nums[1] ?? 1;
299
302
  const ctrl = !!(mod - 1 & 4), alt = !!(mod - 1 & 2), shift = !!(mod - 1 & 1);
303
+ const special = TILDE_NAMES[code];
304
+ if (special) { this.#emit({ type: "key", name: special, ctrl, alt, shift }); return; }
300
305
  let text = "";
301
306
  try { text = String.fromCodePoint(code); } catch { return; }
302
307
  this.#emit({ type: "key", name: "char", key: text.toLowerCase(), text, ctrl, alt, shift });
@@ -318,8 +323,11 @@ export class Term {
318
323
  }
319
324
  const name = KEY_NAMES[final];
320
325
  if (!name) return;
321
- // modifier is the last param before the final byte (e.g. CSI 1;5A)
322
- const mod = nums.length > 1 ? nums[nums.length - 1] : 1;
326
+ // Modifier is normally the last param (CSI 1;5A). Some terminals emit
327
+ // count-only CSI 5C/D for Ctrl+Right/Left; 2..8 are xterm modifier values
328
+ // and must not silently degrade to an unmodified pane switch.
329
+ const countOnlyModifier = nums.length === 1 && nums[0] >= 2 && nums[0] <= 8;
330
+ const mod = nums.length > 1 ? nums[nums.length - 1] : countOnlyModifier ? nums[0] : 1;
323
331
  const ctrl = !!(mod - 1 & 4), alt = !!(mod - 1 & 2), shift = !!(mod - 1 & 1);
324
332
  this.#emit({ type: "key", name, ctrl, alt, shift });
325
333
  }
package/src/text.js CHANGED
@@ -34,9 +34,17 @@ export function graphemes(s) {
34
34
  }
35
35
 
36
36
  export function graphemeWidth(g) {
37
- const widths = Array.from(g, (ch) => wcwidth(ch.codePointAt(0)));
38
- // Emoji joined by ZWJ occupy one glyph cell-pair, not the sum of members.
39
- return g.includes("\u200d") ? Math.max(0, ...widths) : widths.reduce((a, b) => a + b, 0);
37
+ const cps = Array.from(g, (ch) => ch.codePointAt(0));
38
+ const widths = cps.map(wcwidth);
39
+ // A grapheme cluster is rendered as one terminal glyph. Emoji joined by ZWJ,
40
+ // skin-tone modifiers, flags, keycaps, and base+combining clusters therefore
41
+ // occupy the widest constituent width, not the sum of every code point.
42
+ const clustered = cps.length > 1 && (
43
+ g.includes("\u200d") || cps.some((cp) => cp >= 0x1f3fb && cp <= 0x1f3ff) ||
44
+ cps.every((cp) => cp >= 0x1f1e6 && cp <= 0x1f1ff) || cps.includes(0x20e3) ||
45
+ widths.some((width) => width === 0)
46
+ );
47
+ return clustered ? Math.max(0, ...widths) : widths.reduce((a, b) => a + b, 0);
40
48
  }
41
49
 
42
50
  export function strWidth(s) {