@earendil-works/pi-tui 0.79.0 → 0.79.1

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.
@@ -158,6 +158,17 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
158
158
  maxPrimaryColumnWidth: 32,
159
159
  };
160
160
  const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
161
+ const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
162
+ function escapeCharacterClass(value) {
163
+ return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&");
164
+ }
165
+ function buildTriggerPattern(triggerCharacters) {
166
+ return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`);
167
+ }
168
+ function buildDebouncePattern(triggerCharacters) {
169
+ const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass);
170
+ return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);
171
+ }
161
172
  export class Editor {
162
173
  state = {
163
174
  lines: [""],
@@ -177,6 +188,9 @@ export class Editor {
177
188
  borderColor;
178
189
  // Autocomplete support
179
190
  autocompleteProvider;
191
+ autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
192
+ autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters);
193
+ autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters);
180
194
  autocompleteList;
181
195
  autocompleteState = null;
182
196
  autocompletePrefix = "";
@@ -195,6 +209,7 @@ export class Editor {
195
209
  // Prompt history for up/down navigation
196
210
  history = [];
197
211
  historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
212
+ historyDraft = null;
198
213
  // Kill ring for Emacs-style kill/yank operations
199
214
  killRing = new KillRing();
200
215
  lastAction = null;
@@ -253,6 +268,7 @@ export class Editor {
253
268
  setAutocompleteProvider(provider) {
254
269
  this.cancelAutocomplete();
255
270
  this.autocompleteProvider = provider;
271
+ this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []);
256
272
  }
257
273
  /**
258
274
  * Add a prompt to history for up/down arrow navigation.
@@ -271,9 +287,6 @@ export class Editor {
271
287
  this.history.pop();
272
288
  }
273
289
  }
274
- isEditorEmpty() {
275
- return this.state.lines.length === 1 && this.state.lines[0] === "";
276
- }
277
290
  isOnFirstVisualLine() {
278
291
  const visualLines = this.buildVisualLineMap(this.lastWidth);
279
292
  const currentVisualLine = this.findCurrentVisualLine(visualLines);
@@ -294,16 +307,32 @@ export class Editor {
294
307
  // Capture state when first entering history browsing mode
295
308
  if (this.historyIndex === -1 && newIndex >= 0) {
296
309
  this.pushUndoSnapshot();
310
+ this.historyDraft = structuredClone(this.state);
297
311
  }
298
312
  this.historyIndex = newIndex;
299
313
  if (this.historyIndex === -1) {
300
- // Returned to "current" state - clear editor
301
- this.setTextInternal("");
314
+ const draft = this.historyDraft;
315
+ this.historyDraft = null;
316
+ if (draft) {
317
+ this.state = draft;
318
+ this.preferredVisualCol = null;
319
+ this.snappedFromCursorCol = null;
320
+ this.scrollOffset = 0;
321
+ if (this.onChange)
322
+ this.onChange(this.getText());
323
+ }
324
+ else {
325
+ this.setTextInternal("");
326
+ }
302
327
  }
303
328
  else {
304
329
  this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end");
305
330
  }
306
331
  }
332
+ exitHistoryBrowsing() {
333
+ this.historyIndex = -1;
334
+ this.historyDraft = null;
335
+ }
307
336
  /** Internal setText that doesn't reset history state - used by navigateHistory */
308
337
  setTextInternal(text, cursorPlacement = "end") {
309
338
  const lines = text.split("\n");
@@ -368,8 +397,10 @@ export class Editor {
368
397
  result.push(horizontal.repeat(width));
369
398
  }
370
399
  // Render each visible layout line
371
- // Emit hardware cursor marker only when focused and not showing autocomplete
372
- const emitCursorMarker = this.focused && !this.autocompleteState;
400
+ // Emit hardware cursor marker when focused so TUI can position the
401
+ // hardware cursor for IME candidate-window placement even while
402
+ // autocomplete (e.g. slash-command menu) is visible.
403
+ const emitCursorMarker = this.focused;
373
404
  for (const layoutLine of visibleLines) {
374
405
  let displayText = layoutLine.text;
375
406
  let lineVisibleWidth = visibleWidth(layoutLine.text);
@@ -616,10 +647,7 @@ export class Editor {
616
647
  }
617
648
  // Arrow key navigation (with history support)
618
649
  if (kb.matches(data, "tui.editor.cursorUp")) {
619
- if (this.isEditorEmpty()) {
620
- this.navigateHistory(-1);
621
- }
622
- else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
650
+ if (this.isOnFirstVisualLine() && this.history.length > 0) {
623
651
  this.navigateHistory(-1);
624
652
  }
625
653
  else if (this.isOnFirstVisualLine()) {
@@ -795,7 +823,7 @@ export class Editor {
795
823
  setText(text) {
796
824
  this.cancelAutocomplete();
797
825
  this.lastAction = null;
798
- this.historyIndex = -1; // Exit history browsing mode
826
+ this.exitHistoryBrowsing();
799
827
  const normalized = this.normalizeText(text);
800
828
  // Push undo snapshot if content differs (makes programmatic changes undoable)
801
829
  if (this.getText() !== normalized) {
@@ -814,7 +842,7 @@ export class Editor {
814
842
  this.cancelAutocomplete();
815
843
  this.pushUndoSnapshot();
816
844
  this.lastAction = null;
817
- this.historyIndex = -1;
845
+ this.exitHistoryBrowsing();
818
846
  this.insertTextAtCursorInternal(text);
819
847
  }
820
848
  /**
@@ -867,7 +895,7 @@ export class Editor {
867
895
  }
868
896
  // All the editor methods from before...
869
897
  insertCharacter(char, skipUndoCoalescing) {
870
- this.historyIndex = -1; // Exit history browsing mode
898
+ this.exitHistoryBrowsing();
871
899
  // Undo coalescing (fish-style):
872
900
  // - Consecutive word chars coalesce into one undo unit
873
901
  // - Space captures state before itself (so undo removes space+following word together)
@@ -893,8 +921,8 @@ export class Editor {
893
921
  if (char === "/" && this.isAtStartOfMessage()) {
894
922
  this.tryTriggerAutocomplete();
895
923
  }
896
- // Auto-trigger for symbol-based completion like @ or # at token boundaries
897
- else if (char === "@" || char === "#") {
924
+ // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
925
+ else if (this.autocompleteTriggerCharacters.includes(char)) {
898
926
  const currentLine = this.state.lines[this.state.cursorLine] || "";
899
927
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
900
928
  const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
@@ -910,8 +938,8 @@ export class Editor {
910
938
  if (this.isInSlashCommandContext(textBeforeCursor)) {
911
939
  this.tryTriggerAutocomplete();
912
940
  }
913
- // Check if we're in a symbol-based completion context like @ or #
914
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
941
+ // Check if we're in a symbol-based completion context like @, #, or provider triggers
942
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
915
943
  this.tryTriggerAutocomplete();
916
944
  }
917
945
  }
@@ -922,7 +950,7 @@ export class Editor {
922
950
  }
923
951
  handlePaste(pastedText) {
924
952
  this.cancelAutocomplete();
925
- this.historyIndex = -1; // Exit history browsing mode
953
+ this.exitHistoryBrowsing();
926
954
  this.lastAction = null;
927
955
  this.pushUndoSnapshot();
928
956
  // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
@@ -980,7 +1008,7 @@ export class Editor {
980
1008
  }
981
1009
  addNewLine() {
982
1010
  this.cancelAutocomplete();
983
- this.historyIndex = -1; // Exit history browsing mode
1011
+ this.exitHistoryBrowsing();
984
1012
  this.lastAction = null;
985
1013
  this.pushUndoSnapshot();
986
1014
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1014,7 +1042,7 @@ export class Editor {
1014
1042
  this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
1015
1043
  this.pastes.clear();
1016
1044
  this.pasteCounter = 0;
1017
- this.historyIndex = -1;
1045
+ this.exitHistoryBrowsing();
1018
1046
  this.scrollOffset = 0;
1019
1047
  this.undoStack.clear();
1020
1048
  this.lastAction = null;
@@ -1024,7 +1052,7 @@ export class Editor {
1024
1052
  this.onSubmit(result);
1025
1053
  }
1026
1054
  handleBackspace() {
1027
- this.historyIndex = -1; // Exit history browsing mode
1055
+ this.exitHistoryBrowsing();
1028
1056
  this.lastAction = null;
1029
1057
  if (this.state.cursorCol > 0) {
1030
1058
  this.pushUndoSnapshot();
@@ -1065,8 +1093,8 @@ export class Editor {
1065
1093
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1066
1094
  this.tryTriggerAutocomplete();
1067
1095
  }
1068
- // Symbol-based completion context like @ or #
1069
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1096
+ // Symbol-based completion context like @, #, or provider triggers
1097
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1070
1098
  this.tryTriggerAutocomplete();
1071
1099
  }
1072
1100
  }
@@ -1206,7 +1234,7 @@ export class Editor {
1206
1234
  this.setCursorCol(currentLine.length);
1207
1235
  }
1208
1236
  deleteToStartOfLine() {
1209
- this.historyIndex = -1; // Exit history browsing mode
1237
+ this.exitHistoryBrowsing();
1210
1238
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1211
1239
  if (this.state.cursorCol > 0) {
1212
1240
  this.pushUndoSnapshot();
@@ -1234,7 +1262,7 @@ export class Editor {
1234
1262
  }
1235
1263
  }
1236
1264
  deleteToEndOfLine() {
1237
- this.historyIndex = -1; // Exit history browsing mode
1265
+ this.exitHistoryBrowsing();
1238
1266
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1239
1267
  if (this.state.cursorCol < currentLine.length) {
1240
1268
  this.pushUndoSnapshot();
@@ -1259,7 +1287,7 @@ export class Editor {
1259
1287
  }
1260
1288
  }
1261
1289
  deleteWordBackwards() {
1262
- this.historyIndex = -1; // Exit history browsing mode
1290
+ this.exitHistoryBrowsing();
1263
1291
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1264
1292
  // If at start of line, behave like backspace at column 0 (merge with previous line)
1265
1293
  if (this.state.cursorCol === 0) {
@@ -1295,7 +1323,7 @@ export class Editor {
1295
1323
  }
1296
1324
  }
1297
1325
  deleteWordForward() {
1298
- this.historyIndex = -1; // Exit history browsing mode
1326
+ this.exitHistoryBrowsing();
1299
1327
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1300
1328
  // If at end of line, merge with next line (delete the newline)
1301
1329
  if (this.state.cursorCol >= currentLine.length) {
@@ -1328,7 +1356,7 @@ export class Editor {
1328
1356
  }
1329
1357
  }
1330
1358
  handleForwardDelete() {
1331
- this.historyIndex = -1; // Exit history browsing mode
1359
+ this.exitHistoryBrowsing();
1332
1360
  this.lastAction = null;
1333
1361
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1334
1362
  if (this.state.cursorCol < currentLine.length) {
@@ -1364,8 +1392,8 @@ export class Editor {
1364
1392
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1365
1393
  this.tryTriggerAutocomplete();
1366
1394
  }
1367
- // Symbol-based completion context like @ or #
1368
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1395
+ // Symbol-based completion context like @, #, or provider triggers
1396
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1369
1397
  this.tryTriggerAutocomplete();
1370
1398
  }
1371
1399
  }
@@ -1551,7 +1579,7 @@ export class Editor {
1551
1579
  * Insert text at cursor position (used by yank operations).
1552
1580
  */
1553
1581
  insertYankedText(text) {
1554
- this.historyIndex = -1; // Exit history browsing mode
1582
+ this.exitHistoryBrowsing();
1555
1583
  const lines = text.split("\n");
1556
1584
  if (lines.length === 1) {
1557
1585
  // Single line - insert at cursor
@@ -1623,7 +1651,7 @@ export class Editor {
1623
1651
  this.undoStack.push(this.state);
1624
1652
  }
1625
1653
  undo() {
1626
- this.historyIndex = -1; // Exit history browsing mode
1654
+ this.exitHistoryBrowsing();
1627
1655
  const snapshot = this.undoStack.pop();
1628
1656
  if (!snapshot)
1629
1657
  return;
@@ -1784,14 +1812,25 @@ export class Editor {
1784
1812
  })();
1785
1813
  await this.autocompleteRequestTask;
1786
1814
  }
1815
+ setAutocompleteTriggerCharacters(triggerCharacters) {
1816
+ const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
1817
+ for (const character of triggerCharacters) {
1818
+ if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) {
1819
+ continue;
1820
+ }
1821
+ next.push(character);
1822
+ }
1823
+ this.autocompleteTriggerCharacters = next;
1824
+ this.autocompleteTriggerPattern = buildTriggerPattern(next);
1825
+ this.autocompleteDebouncePattern = buildDebouncePattern(next);
1826
+ }
1787
1827
  getAutocompleteDebounceMs(options) {
1788
1828
  if (options.explicitTab || options.force) {
1789
1829
  return 0;
1790
1830
  }
1791
1831
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1792
1832
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1793
- const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
1794
- return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1833
+ return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1795
1834
  }
1796
1835
  async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
1797
1836
  if (!this.autocompleteProvider)