@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.
- package/README.md +6 -0
- package/dist/components/editor.d.ts +20 -0
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +87 -56
- package/dist/components/editor.js.map +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +34 -41
- package/dist/components/input.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +3 -0
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts +14 -4
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +166 -79
- package/dist/keys.js.map +1 -1
- package/dist/terminal.d.ts +6 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +20 -0
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +12 -2
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +52 -30
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +30 -5
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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() !==
|
|
690
|
+
if (this.getText() !== normalized) {
|
|
723
691
|
this.pushUndoSnapshot();
|
|
724
692
|
}
|
|
725
|
-
this.setTextInternal(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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();
|