@gajae-code/tui 0.12.19 → 0.12.20

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/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.20] - 2026-08-09
6
+
5
7
  ## [0.12.19] - 2026-08-08
6
8
 
7
9
  ## [0.12.18] - 2026-08-08
@@ -14,6 +16,10 @@
14
16
 
15
17
  - Native fuzzy matching and image encoding bindings now load only when their TUI feature is used instead of at module startup.
16
18
 
19
+ ### Fixed
20
+
21
+ - Slash-command autocomplete now uses the same prompt-start rule for suggestions and Enter-time completion, preserves inline file-path suggestions, and never rewrites slash-like text on later prompt lines.
22
+
17
23
  ## [0.12.15] - 2026-08-06
18
24
 
19
25
  ## [0.12.14] - 2026-08-06
@@ -1,6 +1,7 @@
1
1
  export declare function getSlashCommandMatchRank(query: string, commandName: string): number;
2
2
  export declare function isInsideInlineCodeSpan(text: string): boolean;
3
3
  export declare function extractSlashCommandTokenPrefix(text: string): string | null;
4
+ export declare function isSlashCommandPromptStart(lines: string[], cursorLine: number, textBeforeCursor: string): boolean;
4
5
  export interface AutocompleteItem {
5
6
  value: string;
6
7
  label: string;
@@ -242,6 +242,15 @@ export declare class Container implements ViewportAnchorProvider {
242
242
  removeChild(component: Component): void;
243
243
  /** Remove a child without disposing it (for detach-then-readd reuse). */
244
244
  detachChild(component: Component): void;
245
+ /**
246
+ * Non-disposing parentage query: is `component` still a live direct child?
247
+ *
248
+ * Callers that park a component here and later move it elsewhere must ask
249
+ * this instead of consulting their own bookkeeping — `clear()`/`dispose()`
250
+ * tear children down without notifying anyone. Disposal is terminal: a
251
+ * disposed child, or a child of a disposed container, is never live.
252
+ */
253
+ hasLiveChild(component: Component): boolean;
245
254
  clear(): void;
246
255
  /** Remove all children without disposing them (for detach-then-readd reuse). */
247
256
  detachAll(): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.12.19",
4
+ "version": "0.12.20",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -36,8 +36,8 @@
36
36
  "fmt": "biome format --write ."
37
37
  },
38
38
  "dependencies": {
39
- "@gajae-code/natives": "0.12.19",
40
- "@gajae-code/utils": "0.12.19",
39
+ "@gajae-code/natives": "0.12.20",
40
+ "@gajae-code/utils": "0.12.20",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -201,7 +201,6 @@ function normalizeSlashCommandText(value: string): string {
201
201
  .trim()
202
202
  .replace(/\s+/g, " ");
203
203
  }
204
- const NON_COMMAND_SLASH_PREFIX_PRECEDERS = new Set(["/", "\\", ":", ".", "~"]);
205
204
  function findOpenInlineCodeSpanStart(text: string): number | null {
206
205
  let openDelimiter: number | null = null;
207
206
 
@@ -231,21 +230,11 @@ export function isInsideInlineCodeSpan(text: string): boolean {
231
230
  }
232
231
 
233
232
  export function extractSlashCommandTokenPrefix(text: string): string | null {
234
- const slashIndex = text.lastIndexOf("/");
235
- if (slashIndex === -1) return null;
236
- if (isInsideInlineCodeSpan(text.slice(0, slashIndex + 1))) return null;
237
-
238
- const token = text.slice(slashIndex);
239
- if (/[\s]/.test(token)) return null;
240
-
241
- const charBeforeSlash = text[slashIndex - 1];
242
- if (charBeforeSlash && NON_COMMAND_SLASH_PREFIX_PRECEDERS.has(charBeforeSlash)) return null;
243
-
244
- let tokenStart = slashIndex;
245
- while (tokenStart > 0 && !/\s/.test(text[tokenStart - 1] ?? "")) tokenStart -= 1;
246
- if (text.slice(tokenStart, slashIndex).includes("/")) return null;
247
-
248
- return token;
233
+ if (!text.startsWith("/") || /[\s]/.test(text) || text.slice(1).includes("/")) return null;
234
+ return text;
235
+ }
236
+ export function isSlashCommandPromptStart(lines: string[], cursorLine: number, textBeforeCursor: string): boolean {
237
+ return lines.slice(0, cursorLine).every(line => line.trim() === "") && textBeforeCursor.trimStart().startsWith("/");
249
238
  }
250
239
  export interface AutocompleteItem {
251
240
  value: string;
@@ -369,26 +358,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
369
358
  .map(({ score: _score, priority: _priority, matchRank: _matchRank, index: _index, ...rest }) => rest);
370
359
  }
371
360
 
372
- #getInlineSlashCommandNameSuggestions(prefix: string): AutocompleteItem[] {
373
- if (prefix.length === 0) return this.#getSlashCommandNameSuggestions(prefix);
374
-
375
- const normalizedPrefix = normalizeSlashCommandText(prefix);
376
- return this.#getSlashCommandNameSuggestions(prefix).filter(item => {
377
- const lowerValue = item.value.toLowerCase();
378
- if (lowerValue.startsWith(prefix.toLowerCase())) return true;
379
- if (!normalizedPrefix) return true;
380
- return normalizeSlashCommandText(item.value).startsWith(normalizedPrefix);
381
- });
382
- }
383
-
384
- #extractSlashCommandPrefix(text: string): string | null {
385
- return extractSlashCommandTokenPrefix(text);
386
- }
387
-
388
- #isKnownCommandItem(item: AutocompleteItem): boolean {
389
- return this.#commands.some(cmd => this.#getCommandName(cmd) === item.value);
390
- }
391
-
392
361
  async getSuggestions(
393
362
  lines: string[],
394
363
  cursorLine: number,
@@ -420,12 +389,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
420
389
  }
421
390
 
422
391
  // Check for slash commands at the submitted-message start
423
- if (textBeforeCursor.startsWith("/")) {
424
- const spaceIndex = textBeforeCursor.indexOf(" ");
392
+ if (isSlashCommandPromptStart(lines, cursorLine, textBeforeCursor)) {
393
+ const commandText = textBeforeCursor.trimStart();
394
+ const spaceIndex = commandText.indexOf(" ");
425
395
 
426
396
  if (spaceIndex === -1) {
427
397
  // No space yet - complete command names
428
- const prefix = textBeforeCursor.slice(1); // Remove the "/"
398
+ const prefix = commandText.slice(1); // Remove the "/"
429
399
  const matches = this.#getSlashCommandNameSuggestions(prefix);
430
400
 
431
401
  if (matches.length === 0) return null;
@@ -433,13 +403,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
433
403
  return {
434
404
  items: matches,
435
405
  kind: "slash-command",
436
- prefix: textBeforeCursor,
406
+ prefix: commandText,
437
407
  };
438
408
  }
439
409
 
440
410
  // Space found - complete command arguments
441
- const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
442
- const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
411
+ const commandName = commandText.slice(1, spaceIndex); // Command without "/"
412
+ const argumentText = commandText.slice(spaceIndex + 1); // Text after space
443
413
 
444
414
  const command = this.#commands.find(cmd => this.#getCommandName(cmd) === commandName);
445
415
  if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
@@ -459,33 +429,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
459
429
  }
460
430
 
461
431
  const pathMatch = this.#extractPathPrefix(textBeforeCursor, false);
462
- let pathSuggestions: AutocompleteItem[] | null = null;
463
- const slashPrefix = this.#extractSlashCommandPrefix(textBeforeCursor);
464
- if (slashPrefix) {
465
- if (pathMatch === slashPrefix && slashPrefix.startsWith("/")) {
466
- pathSuggestions = await this.#getFileSuggestions(pathMatch);
467
- if (pathSuggestions.length > 0) {
468
- return {
469
- items: pathSuggestions,
470
- kind: "default",
471
- prefix: pathMatch,
472
- };
473
- }
474
- }
475
-
476
- const matches = this.#getInlineSlashCommandNameSuggestions(slashPrefix.slice(1));
477
- if (matches.length > 0) {
478
- return {
479
- items: matches,
480
- kind: "slash-command",
481
- prefix: slashPrefix,
482
- };
483
- }
484
- }
485
432
 
486
433
  // Check for file paths - triggered by Tab or if we detect a path pattern
487
434
  if (pathMatch !== null) {
488
- const suggestions = pathSuggestions ?? (await this.#getFileSuggestions(pathMatch));
435
+ const suggestions = await this.#getFileSuggestions(pathMatch);
489
436
  if (suggestions.length === 0) return null;
490
437
 
491
438
  // Check if we have an exact match that is a directory
@@ -522,12 +469,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
522
469
  const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
523
470
  const afterCursor = currentLine.slice(cursorCol);
524
471
 
525
- // Check if we're completing a slash command name. Start-of-line commands
526
- // execute on submit; inline slash tokens are completed as ordinary text.
527
- const isSlashCommand =
528
- prefix.startsWith("/") &&
529
- !prefix.slice(1).includes("/") &&
530
- (beforePrefix.trim() === "" || this.#isKnownCommandItem(item));
472
+ // Slash commands are completed only at the start of the prompt.
473
+ const isSlashCommand = prefix.startsWith("/") && !prefix.slice(1).includes("/") && beforePrefix.trim() === "";
531
474
  if (isSlashCommand) {
532
475
  // This is a command name completion
533
476
  const newLine = `${beforePrefix}/${item.value} ${afterCursor}`;
@@ -960,13 +903,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
960
903
  return command.getInlineHint(argumentText);
961
904
  }
962
905
  trySyncSlashCompletion(textBeforeCursor: string): { items: AutocompleteItem[]; prefix: string } | null {
963
- if (!textBeforeCursor.startsWith("/")) return null;
964
- if (textBeforeCursor.length <= 1) return null; // Bare "/" alone, don't auto-complete
965
- if (textBeforeCursor.includes(" ")) return null; // Only complete command name, not args
906
+ const commandText = textBeforeCursor.trimStart();
907
+ if (!commandText.startsWith("/")) return null;
908
+ if (commandText.length <= 1) return null; // Bare "/" alone, don't auto-complete
909
+ if (commandText.includes(" ")) return null; // Only complete command name, not args
966
910
 
967
- const matches = this.#getSlashCommandNameSuggestions(textBeforeCursor.slice(1));
911
+ const matches = this.#getSlashCommandNameSuggestions(commandText.slice(1));
968
912
 
969
913
  if (matches.length === 0) return null;
970
- return { items: matches, prefix: textBeforeCursor };
914
+ return { items: matches, prefix: commandText };
971
915
  }
972
916
  }
@@ -5,6 +5,7 @@ import {
5
5
  type CombinedAutocompleteProvider,
6
6
  extractSlashCommandTokenPrefix,
7
7
  isInsideInlineCodeSpan,
8
+ isSlashCommandPromptStart,
8
9
  } from "../autocomplete";
9
10
  import { BracketedPasteHandler } from "../bracketed-paste";
10
11
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
@@ -2838,13 +2839,14 @@ export class Editor implements Component, Focusable {
2838
2839
  #isInSubmittedSlashCommandContext(): boolean {
2839
2840
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2840
2841
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2841
- return this.#hasOnlyWhitespaceBeforeCursorLine() && beforeCursor.trimStart().startsWith("/");
2842
+ return isSlashCommandPromptStart(this.#state.lines, this.#state.cursorLine, beforeCursor);
2842
2843
  }
2843
2844
 
2844
2845
  #getSlashTokenBeforeCursor(): string | null {
2846
+ if (!this.#hasOnlyWhitespaceBeforeCursorLine()) return null;
2845
2847
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2846
2848
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2847
- return extractSlashCommandTokenPrefix(beforeCursor);
2849
+ return extractSlashCommandTokenPrefix(beforeCursor.trimStart());
2848
2850
  }
2849
2851
 
2850
2852
  #isInSlashTokenContext(): boolean {
@@ -2856,7 +2858,7 @@ export class Editor implements Component, Focusable {
2856
2858
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2857
2859
  if (!textBeforeCursor.endsWith(this.#autocompletePrefix)) return false;
2858
2860
  if (this.#autocompleteState !== "regular" || !this.#autocompletePrefix.startsWith("/")) return true;
2859
- return extractSlashCommandTokenPrefix(textBeforeCursor) === this.#autocompletePrefix;
2861
+ return this.#getSlashTokenBeforeCursor() === this.#autocompletePrefix;
2860
2862
  }
2861
2863
  #captureAutocompleteOrigin(): { docVersion: number; cursorLine: number; cursorCol: number } {
2862
2864
  return {
package/src/tui.ts CHANGED
@@ -534,6 +534,20 @@ export class Container implements ViewportAnchorProvider {
534
534
  }
535
535
  }
536
536
 
537
+ /**
538
+ * Non-disposing parentage query: is `component` still a live direct child?
539
+ *
540
+ * Callers that park a component here and later move it elsewhere must ask
541
+ * this instead of consulting their own bookkeeping — `clear()`/`dispose()`
542
+ * tear children down without notifying anyone. Disposal is terminal: a
543
+ * disposed child, or a child of a disposed container, is never live.
544
+ */
545
+ hasLiveChild(component: Component): boolean {
546
+ if (this.#disposed) return false;
547
+ if (component instanceof Container && component.#disposed) return false;
548
+ return this.children.includes(component);
549
+ }
550
+
537
551
  clear(): void {
538
552
  for (const child of this.children) child.dispose?.();
539
553
  this.children = [];