@earendil-works/pi-tui 0.79.0 → 0.79.2

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.
@@ -3,7 +3,7 @@ import { decodePrintableKey, 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";
6
- import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js";
6
+ import { cjkBreakRegex, getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth, } from "../utils.js";
7
7
  import { findWordBackward, findWordForward } from "../word-navigation.js";
8
8
  import { SelectList } from "./select-list.js";
9
9
  const graphemeSegmenter = getGraphemeSegmenter();
@@ -140,14 +140,23 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
140
140
  }
141
141
  // Advance.
142
142
  currentWidth += gWidth;
143
- // Record wrap opportunity: whitespace followed by non-whitespace.
144
- // Multiple spaces join (no break between them); the break point is
145
- // after the last space before the next word.
143
+ // Record wrap opportunity: whitespace followed by non-whitespace
144
+ // (multiple spaces join; the break point is after the last space),
145
+ // or at a boundary where either side is CJK (CJK allows breaking
146
+ // between any adjacent characters).
146
147
  const next = segments[i + 1];
147
148
  if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
148
149
  wrapOppIndex = next.index;
149
150
  wrapOppWidth = currentWidth;
150
151
  }
152
+ else if (!isWs && next && !isWhitespaceChar(next.segment)) {
153
+ const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme);
154
+ const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment);
155
+ if (isCjk || nextIsCjk) {
156
+ wrapOppIndex = next.index;
157
+ wrapOppWidth = currentWidth;
158
+ }
159
+ }
151
160
  }
152
161
  // Push final chunk.
153
162
  chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
@@ -158,6 +167,17 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
158
167
  maxPrimaryColumnWidth: 32,
159
168
  };
160
169
  const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
170
+ const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
171
+ function escapeCharacterClass(value) {
172
+ return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&");
173
+ }
174
+ function buildTriggerPattern(triggerCharacters) {
175
+ return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`);
176
+ }
177
+ function buildDebouncePattern(triggerCharacters) {
178
+ const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass);
179
+ return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);
180
+ }
161
181
  export class Editor {
162
182
  state = {
163
183
  lines: [""],
@@ -177,6 +197,9 @@ export class Editor {
177
197
  borderColor;
178
198
  // Autocomplete support
179
199
  autocompleteProvider;
200
+ autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
201
+ autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters);
202
+ autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters);
180
203
  autocompleteList;
181
204
  autocompleteState = null;
182
205
  autocompletePrefix = "";
@@ -195,6 +218,7 @@ export class Editor {
195
218
  // Prompt history for up/down navigation
196
219
  history = [];
197
220
  historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
221
+ historyDraft = null;
198
222
  // Kill ring for Emacs-style kill/yank operations
199
223
  killRing = new KillRing();
200
224
  lastAction = null;
@@ -253,6 +277,7 @@ export class Editor {
253
277
  setAutocompleteProvider(provider) {
254
278
  this.cancelAutocomplete();
255
279
  this.autocompleteProvider = provider;
280
+ this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []);
256
281
  }
257
282
  /**
258
283
  * Add a prompt to history for up/down arrow navigation.
@@ -271,9 +296,6 @@ export class Editor {
271
296
  this.history.pop();
272
297
  }
273
298
  }
274
- isEditorEmpty() {
275
- return this.state.lines.length === 1 && this.state.lines[0] === "";
276
- }
277
299
  isOnFirstVisualLine() {
278
300
  const visualLines = this.buildVisualLineMap(this.lastWidth);
279
301
  const currentVisualLine = this.findCurrentVisualLine(visualLines);
@@ -294,16 +316,32 @@ export class Editor {
294
316
  // Capture state when first entering history browsing mode
295
317
  if (this.historyIndex === -1 && newIndex >= 0) {
296
318
  this.pushUndoSnapshot();
319
+ this.historyDraft = structuredClone(this.state);
297
320
  }
298
321
  this.historyIndex = newIndex;
299
322
  if (this.historyIndex === -1) {
300
- // Returned to "current" state - clear editor
301
- this.setTextInternal("");
323
+ const draft = this.historyDraft;
324
+ this.historyDraft = null;
325
+ if (draft) {
326
+ this.state = draft;
327
+ this.preferredVisualCol = null;
328
+ this.snappedFromCursorCol = null;
329
+ this.scrollOffset = 0;
330
+ if (this.onChange)
331
+ this.onChange(this.getText());
332
+ }
333
+ else {
334
+ this.setTextInternal("");
335
+ }
302
336
  }
303
337
  else {
304
338
  this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end");
305
339
  }
306
340
  }
341
+ exitHistoryBrowsing() {
342
+ this.historyIndex = -1;
343
+ this.historyDraft = null;
344
+ }
307
345
  /** Internal setText that doesn't reset history state - used by navigateHistory */
308
346
  setTextInternal(text, cursorPlacement = "end") {
309
347
  const lines = text.split("\n");
@@ -368,8 +406,10 @@ export class Editor {
368
406
  result.push(horizontal.repeat(width));
369
407
  }
370
408
  // Render each visible layout line
371
- // Emit hardware cursor marker only when focused and not showing autocomplete
372
- const emitCursorMarker = this.focused && !this.autocompleteState;
409
+ // Emit hardware cursor marker when focused so TUI can position the
410
+ // hardware cursor for IME candidate-window placement even while
411
+ // autocomplete (e.g. slash-command menu) is visible.
412
+ const emitCursorMarker = this.focused;
373
413
  for (const layoutLine of visibleLines) {
374
414
  let displayText = layoutLine.text;
375
415
  let lineVisibleWidth = visibleWidth(layoutLine.text);
@@ -616,10 +656,7 @@ export class Editor {
616
656
  }
617
657
  // Arrow key navigation (with history support)
618
658
  if (kb.matches(data, "tui.editor.cursorUp")) {
619
- if (this.isEditorEmpty()) {
620
- this.navigateHistory(-1);
621
- }
622
- else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
659
+ if (this.isOnFirstVisualLine() && this.history.length > 0) {
623
660
  this.navigateHistory(-1);
624
661
  }
625
662
  else if (this.isOnFirstVisualLine()) {
@@ -795,7 +832,7 @@ export class Editor {
795
832
  setText(text) {
796
833
  this.cancelAutocomplete();
797
834
  this.lastAction = null;
798
- this.historyIndex = -1; // Exit history browsing mode
835
+ this.exitHistoryBrowsing();
799
836
  const normalized = this.normalizeText(text);
800
837
  // Push undo snapshot if content differs (makes programmatic changes undoable)
801
838
  if (this.getText() !== normalized) {
@@ -814,7 +851,7 @@ export class Editor {
814
851
  this.cancelAutocomplete();
815
852
  this.pushUndoSnapshot();
816
853
  this.lastAction = null;
817
- this.historyIndex = -1;
854
+ this.exitHistoryBrowsing();
818
855
  this.insertTextAtCursorInternal(text);
819
856
  }
820
857
  /**
@@ -867,7 +904,7 @@ export class Editor {
867
904
  }
868
905
  // All the editor methods from before...
869
906
  insertCharacter(char, skipUndoCoalescing) {
870
- this.historyIndex = -1; // Exit history browsing mode
907
+ this.exitHistoryBrowsing();
871
908
  // Undo coalescing (fish-style):
872
909
  // - Consecutive word chars coalesce into one undo unit
873
910
  // - Space captures state before itself (so undo removes space+following word together)
@@ -893,8 +930,8 @@ export class Editor {
893
930
  if (char === "/" && this.isAtStartOfMessage()) {
894
931
  this.tryTriggerAutocomplete();
895
932
  }
896
- // Auto-trigger for symbol-based completion like @ or # at token boundaries
897
- else if (char === "@" || char === "#") {
933
+ // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
934
+ else if (this.autocompleteTriggerCharacters.includes(char)) {
898
935
  const currentLine = this.state.lines[this.state.cursorLine] || "";
899
936
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
900
937
  const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
@@ -910,8 +947,8 @@ export class Editor {
910
947
  if (this.isInSlashCommandContext(textBeforeCursor)) {
911
948
  this.tryTriggerAutocomplete();
912
949
  }
913
- // Check if we're in a symbol-based completion context like @ or #
914
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
950
+ // Check if we're in a symbol-based completion context like @, #, or provider triggers
951
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
915
952
  this.tryTriggerAutocomplete();
916
953
  }
917
954
  }
@@ -922,7 +959,7 @@ export class Editor {
922
959
  }
923
960
  handlePaste(pastedText) {
924
961
  this.cancelAutocomplete();
925
- this.historyIndex = -1; // Exit history browsing mode
962
+ this.exitHistoryBrowsing();
926
963
  this.lastAction = null;
927
964
  this.pushUndoSnapshot();
928
965
  // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
@@ -980,7 +1017,7 @@ export class Editor {
980
1017
  }
981
1018
  addNewLine() {
982
1019
  this.cancelAutocomplete();
983
- this.historyIndex = -1; // Exit history browsing mode
1020
+ this.exitHistoryBrowsing();
984
1021
  this.lastAction = null;
985
1022
  this.pushUndoSnapshot();
986
1023
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1014,7 +1051,7 @@ export class Editor {
1014
1051
  this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
1015
1052
  this.pastes.clear();
1016
1053
  this.pasteCounter = 0;
1017
- this.historyIndex = -1;
1054
+ this.exitHistoryBrowsing();
1018
1055
  this.scrollOffset = 0;
1019
1056
  this.undoStack.clear();
1020
1057
  this.lastAction = null;
@@ -1024,7 +1061,7 @@ export class Editor {
1024
1061
  this.onSubmit(result);
1025
1062
  }
1026
1063
  handleBackspace() {
1027
- this.historyIndex = -1; // Exit history browsing mode
1064
+ this.exitHistoryBrowsing();
1028
1065
  this.lastAction = null;
1029
1066
  if (this.state.cursorCol > 0) {
1030
1067
  this.pushUndoSnapshot();
@@ -1065,8 +1102,8 @@ export class Editor {
1065
1102
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1066
1103
  this.tryTriggerAutocomplete();
1067
1104
  }
1068
- // Symbol-based completion context like @ or #
1069
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1105
+ // Symbol-based completion context like @, #, or provider triggers
1106
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1070
1107
  this.tryTriggerAutocomplete();
1071
1108
  }
1072
1109
  }
@@ -1206,7 +1243,7 @@ export class Editor {
1206
1243
  this.setCursorCol(currentLine.length);
1207
1244
  }
1208
1245
  deleteToStartOfLine() {
1209
- this.historyIndex = -1; // Exit history browsing mode
1246
+ this.exitHistoryBrowsing();
1210
1247
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1211
1248
  if (this.state.cursorCol > 0) {
1212
1249
  this.pushUndoSnapshot();
@@ -1234,7 +1271,7 @@ export class Editor {
1234
1271
  }
1235
1272
  }
1236
1273
  deleteToEndOfLine() {
1237
- this.historyIndex = -1; // Exit history browsing mode
1274
+ this.exitHistoryBrowsing();
1238
1275
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1239
1276
  if (this.state.cursorCol < currentLine.length) {
1240
1277
  this.pushUndoSnapshot();
@@ -1259,7 +1296,7 @@ export class Editor {
1259
1296
  }
1260
1297
  }
1261
1298
  deleteWordBackwards() {
1262
- this.historyIndex = -1; // Exit history browsing mode
1299
+ this.exitHistoryBrowsing();
1263
1300
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1264
1301
  // If at start of line, behave like backspace at column 0 (merge with previous line)
1265
1302
  if (this.state.cursorCol === 0) {
@@ -1295,7 +1332,7 @@ export class Editor {
1295
1332
  }
1296
1333
  }
1297
1334
  deleteWordForward() {
1298
- this.historyIndex = -1; // Exit history browsing mode
1335
+ this.exitHistoryBrowsing();
1299
1336
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1300
1337
  // If at end of line, merge with next line (delete the newline)
1301
1338
  if (this.state.cursorCol >= currentLine.length) {
@@ -1328,7 +1365,7 @@ export class Editor {
1328
1365
  }
1329
1366
  }
1330
1367
  handleForwardDelete() {
1331
- this.historyIndex = -1; // Exit history browsing mode
1368
+ this.exitHistoryBrowsing();
1332
1369
  this.lastAction = null;
1333
1370
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1334
1371
  if (this.state.cursorCol < currentLine.length) {
@@ -1364,8 +1401,8 @@ export class Editor {
1364
1401
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1365
1402
  this.tryTriggerAutocomplete();
1366
1403
  }
1367
- // Symbol-based completion context like @ or #
1368
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1404
+ // Symbol-based completion context like @, #, or provider triggers
1405
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1369
1406
  this.tryTriggerAutocomplete();
1370
1407
  }
1371
1408
  }
@@ -1551,7 +1588,7 @@ export class Editor {
1551
1588
  * Insert text at cursor position (used by yank operations).
1552
1589
  */
1553
1590
  insertYankedText(text) {
1554
- this.historyIndex = -1; // Exit history browsing mode
1591
+ this.exitHistoryBrowsing();
1555
1592
  const lines = text.split("\n");
1556
1593
  if (lines.length === 1) {
1557
1594
  // Single line - insert at cursor
@@ -1623,7 +1660,7 @@ export class Editor {
1623
1660
  this.undoStack.push(this.state);
1624
1661
  }
1625
1662
  undo() {
1626
- this.historyIndex = -1; // Exit history browsing mode
1663
+ this.exitHistoryBrowsing();
1627
1664
  const snapshot = this.undoStack.pop();
1628
1665
  if (!snapshot)
1629
1666
  return;
@@ -1784,14 +1821,25 @@ export class Editor {
1784
1821
  })();
1785
1822
  await this.autocompleteRequestTask;
1786
1823
  }
1824
+ setAutocompleteTriggerCharacters(triggerCharacters) {
1825
+ const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
1826
+ for (const character of triggerCharacters) {
1827
+ if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) {
1828
+ continue;
1829
+ }
1830
+ next.push(character);
1831
+ }
1832
+ this.autocompleteTriggerCharacters = next;
1833
+ this.autocompleteTriggerPattern = buildTriggerPattern(next);
1834
+ this.autocompleteDebouncePattern = buildDebouncePattern(next);
1835
+ }
1787
1836
  getAutocompleteDebounceMs(options) {
1788
1837
  if (options.explicitTab || options.force) {
1789
1838
  return 0;
1790
1839
  }
1791
1840
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1792
1841
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1793
- const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
1794
- return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1842
+ return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1795
1843
  }
1796
1844
  async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
1797
1845
  if (!this.autocompleteProvider)