@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 +6 -0
- package/dist/types/autocomplete.d.ts +1 -0
- package/dist/types/tui.d.ts +9 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +21 -77
- package/src/components/editor.ts +5 -3
- package/src/tui.ts +14 -0
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;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
40
|
-
"@gajae-code/utils": "0.12.
|
|
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
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
424
|
-
const
|
|
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 =
|
|
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:
|
|
406
|
+
prefix: commandText,
|
|
437
407
|
};
|
|
438
408
|
}
|
|
439
409
|
|
|
440
410
|
// Space found - complete command arguments
|
|
441
|
-
const commandName =
|
|
442
|
-
const argumentText =
|
|
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 =
|
|
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
|
-
//
|
|
526
|
-
|
|
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
|
-
|
|
964
|
-
if (
|
|
965
|
-
if (
|
|
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(
|
|
911
|
+
const matches = this.#getSlashCommandNameSuggestions(commandText.slice(1));
|
|
968
912
|
|
|
969
913
|
if (matches.length === 0) return null;
|
|
970
|
-
return { items: matches, prefix:
|
|
914
|
+
return { items: matches, prefix: commandText };
|
|
971
915
|
}
|
|
972
916
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -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.#
|
|
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
|
|
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 = [];
|