@draht/tui 2026.3.6 → 2026.3.14

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.
@@ -1,5 +1,5 @@
1
1
  import { getEditorKeybindings } from "../keybindings.js";
2
- import { matchesKey } from "../keys.js";
2
+ import { decodeKittyPrintable, matchesKey } from "../keys.js";
3
3
  import { KillRing } from "../kill-ring.js";
4
4
  import { CURSOR_MARKER } from "../tui.js";
5
5
  import { UndoStack } from "../undo-stack.js";
@@ -39,14 +39,19 @@ export function wordWrapLine(line, maxWidth) {
39
39
  const isWs = isWhitespaceChar(grapheme);
40
40
  // Overflow check before advancing.
41
41
  if (currentWidth + gWidth > maxWidth) {
42
- if (wrapOppIndex >= 0) {
43
- // Backtrack to last wrap opportunity.
42
+ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
43
+ // Backtrack to last wrap opportunity (the remaining content
44
+ // plus the current grapheme still fits within maxWidth).
44
45
  chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex });
45
46
  chunkStart = wrapOppIndex;
46
47
  currentWidth -= wrapOppWidth;
47
48
  }
48
49
  else if (chunkStart < charIndex) {
49
- // No wrap opportunity: force-break at current position.
50
+ // No viable wrap opportunity: force-break at current position.
51
+ // This also handles the case where backtracking to a word
52
+ // boundary wouldn't help because the remaining content plus
53
+ // the current grapheme (e.g. a wide character) still exceeds
54
+ // maxWidth.
50
55
  chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
51
56
  chunkStart = charIndex;
52
57
  currentWidth = 0;
@@ -68,48 +73,6 @@ export function wordWrapLine(line, maxWidth) {
68
73
  chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
69
74
  return chunks;
70
75
  }
71
- // Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints.
72
- const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/;
73
- const KITTY_MOD_SHIFT = 1;
74
- const KITTY_MOD_ALT = 2;
75
- const KITTY_MOD_CTRL = 4;
76
- const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
77
- const KITTY_ALLOWED_MODIFIERS = KITTY_MOD_SHIFT | KITTY_LOCK_MASK;
78
- // Decode a printable CSI-u sequence, preferring the shifted key when present.
79
- function decodeKittyPrintable(data) {
80
- const match = data.match(KITTY_CSI_U_REGEX);
81
- if (!match)
82
- return undefined;
83
- // CSI-u groups: <codepoint>[:<shifted>[:<base>]];<mod>u
84
- const codepoint = Number.parseInt(match[1] ?? "", 10);
85
- if (!Number.isFinite(codepoint))
86
- return undefined;
87
- const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined;
88
- const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
89
- // Modifiers are 1-indexed in CSI-u; normalize to our bitmask.
90
- const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
91
- // Only accept printable CSI-u input for plain or Shift-modified text keys.
92
- // Reject unsupported modifier bits (e.g. Super/Meta) to avoid inserting
93
- // characters from modifier-only terminal events.
94
- if ((modifier & ~KITTY_ALLOWED_MODIFIERS) !== 0)
95
- return undefined;
96
- if (modifier & (KITTY_MOD_ALT | KITTY_MOD_CTRL))
97
- return undefined;
98
- // Prefer the shifted keycode when Shift is held.
99
- let effectiveCodepoint = codepoint;
100
- if (modifier & KITTY_MOD_SHIFT && typeof shiftedKey === "number") {
101
- effectiveCodepoint = shiftedKey;
102
- }
103
- // Drop control characters or invalid codepoints.
104
- if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32)
105
- return undefined;
106
- try {
107
- return String.fromCodePoint(effectiveCodepoint);
108
- }
109
- catch {
110
- return undefined;
111
- }
112
- }
113
76
  export class Editor {
114
77
  state = {
115
78
  lines: [""],
@@ -238,7 +201,7 @@ export class Editor {
238
201
  }
239
202
  /** Internal setText that doesn't reset history state - used by navigateHistory */
240
203
  setTextInternal(text) {
241
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
204
+ const lines = text.split("\n");
242
205
  this.state.lines = lines.length === 0 ? [""] : lines;
243
206
  this.state.cursorLine = this.state.lines.length - 1;
244
207
  this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
@@ -420,6 +383,7 @@ export class Editor {
420
383
  if (kb.matches(data, "tab")) {
421
384
  const selected = this.autocompleteList.getSelectedItem();
422
385
  if (selected && this.autocompleteProvider) {
386
+ const shouldChainSlashArgumentAutocomplete = this.shouldChainSlashArgumentAutocompleteOnTabSelection();
423
387
  this.pushUndoSnapshot();
424
388
  this.lastAction = null;
425
389
  const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
@@ -429,6 +393,9 @@ export class Editor {
429
393
  this.cancelAutocomplete();
430
394
  if (this.onChange)
431
395
  this.onChange(this.getText());
396
+ if (shouldChainSlashArgumentAutocomplete && this.isBareCompletedSlashCommandAtCursor()) {
397
+ this.tryTriggerAutocomplete();
398
+ }
432
399
  }
433
400
  return;
434
401
  }
@@ -718,11 +685,12 @@ export class Editor {
718
685
  setText(text) {
719
686
  this.lastAction = null;
720
687
  this.historyIndex = -1; // Exit history browsing mode
688
+ const normalized = this.normalizeText(text);
721
689
  // Push undo snapshot if content differs (makes programmatic changes undoable)
722
- if (this.getText() !== text) {
690
+ if (this.getText() !== normalized) {
723
691
  this.pushUndoSnapshot();
724
692
  }
725
- this.setTextInternal(text);
693
+ this.setTextInternal(normalized);
726
694
  }
727
695
  /**
728
696
  * Insert text at the current cursor position.
@@ -737,6 +705,14 @@ export class Editor {
737
705
  this.historyIndex = -1;
738
706
  this.insertTextAtCursorInternal(text);
739
707
  }
708
+ /**
709
+ * Normalize text for editor storage:
710
+ * - Normalize line endings (\r\n and \r -> \n)
711
+ * - Expand tabs to 4 spaces
712
+ */
713
+ normalizeText(text) {
714
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
715
+ }
740
716
  /**
741
717
  * Internal text insertion at cursor. Handles single and multi-line text.
742
718
  * Does not push undo snapshots or trigger autocomplete - caller is responsible.
@@ -745,8 +721,8 @@ export class Editor {
745
721
  insertTextAtCursorInternal(text) {
746
722
  if (!text)
747
723
  return;
748
- // Normalize line endings
749
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
724
+ // Normalize line endings and tabs
725
+ const normalized = this.normalizeText(text);
750
726
  const insertedLines = normalized.split("\n");
751
727
  const currentLine = this.state.lines[this.state.cursorLine] || "";
752
728
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -837,12 +813,10 @@ export class Editor {
837
813
  this.historyIndex = -1; // Exit history browsing mode
838
814
  this.lastAction = null;
839
815
  this.pushUndoSnapshot();
840
- // Clean the pasted text
841
- const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
842
- // Convert tabs to spaces (4 spaces per tab)
843
- const tabExpandedText = cleanText.replace(/\t/g, " ");
816
+ // Clean the pasted text: normalize line endings, expand tabs
817
+ const cleanText = this.normalizeText(pastedText);
844
818
  // Filter out non-printable characters except newlines
845
- let filteredText = tabExpandedText
819
+ let filteredText = cleanText
846
820
  .split("")
847
821
  .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
848
822
  .join("");
@@ -1578,7 +1552,49 @@ export class Editor {
1578
1552
  isInSlashCommandContext(textBeforeCursor) {
1579
1553
  return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
1580
1554
  }
1555
+ shouldChainSlashArgumentAutocompleteOnTabSelection() {
1556
+ if (this.autocompleteState !== "regular") {
1557
+ return false;
1558
+ }
1559
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1560
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1561
+ return this.isInSlashCommandContext(textBeforeCursor) && !textBeforeCursor.trimStart().includes(" ");
1562
+ }
1563
+ isBareCompletedSlashCommandAtCursor() {
1564
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1565
+ if (this.state.cursorCol !== currentLine.length) {
1566
+ return false;
1567
+ }
1568
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol).trimStart();
1569
+ return /^\/\S+ $/.test(textBeforeCursor);
1570
+ }
1581
1571
  // Autocomplete methods
1572
+ /**
1573
+ * Find the best autocomplete item index for the given prefix.
1574
+ * Returns -1 if no match is found.
1575
+ *
1576
+ * Match priority:
1577
+ * 1. Exact match (prefix === item.value) -> always selected
1578
+ * 2. Prefix match -> first item whose value starts with prefix
1579
+ * 3. No match -> -1 (keep default highlight)
1580
+ *
1581
+ * Matching is case-sensitive and checks item.value only.
1582
+ */
1583
+ getBestAutocompleteMatchIndex(items, prefix) {
1584
+ if (!prefix)
1585
+ return -1;
1586
+ let firstPrefixIndex = -1;
1587
+ for (let i = 0; i < items.length; i++) {
1588
+ const value = items[i].value;
1589
+ if (value === prefix) {
1590
+ return i; // Exact match always wins
1591
+ }
1592
+ if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
1593
+ firstPrefixIndex = i;
1594
+ }
1595
+ }
1596
+ return firstPrefixIndex;
1597
+ }
1582
1598
  tryTriggerAutocomplete(explicitTab = false) {
1583
1599
  if (!this.autocompleteProvider)
1584
1600
  return;
@@ -1595,6 +1611,11 @@ export class Editor {
1595
1611
  if (suggestions && suggestions.items.length > 0) {
1596
1612
  this.autocompletePrefix = suggestions.prefix;
1597
1613
  this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1614
+ // If typed prefix exactly matches one of the suggestions, select that item
1615
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1616
+ if (bestMatchIndex >= 0) {
1617
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1618
+ }
1598
1619
  this.autocompleteState = "regular";
1599
1620
  }
1600
1621
  else {
@@ -1648,6 +1669,11 @@ export class Editor {
1648
1669
  }
1649
1670
  this.autocompletePrefix = suggestions.prefix;
1650
1671
  this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1672
+ // If typed prefix exactly matches one of the suggestions, select that item
1673
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1674
+ if (bestMatchIndex >= 0) {
1675
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1676
+ }
1651
1677
  this.autocompleteState = "force";
1652
1678
  }
1653
1679
  else {
@@ -1674,6 +1700,11 @@ export class Editor {
1674
1700
  this.autocompletePrefix = suggestions.prefix;
1675
1701
  // Always create new SelectList to ensure update
1676
1702
  this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1703
+ // If typed prefix exactly matches one of the suggestions, select that item
1704
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1705
+ if (bestMatchIndex >= 0) {
1706
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1707
+ }
1677
1708
  }
1678
1709
  else {
1679
1710
  this.cancelAutocomplete();