@gajae-code/tui 0.8.1 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.2] - 2026-07-06
6
+ ### Fixed
7
+
8
+ - Fixed backward jump-to-character (`ctrl+alt+]`) matching the character under the cursor when the cursor is at column 0: `lastIndexOf` clamps a negative start position to 0, so the jump stopped in place instead of skipping the cursor position and continuing into earlier lines.
9
+ - GJC-launched psmux panes are now treated as multiplexer sessions even when they do not expose `$TMUX`, so resize and forced redraw paths repaint the live viewport instead of replaying/clearing scrollback and leaving the bottom-pinned composer area above stale blank rows.
10
+
5
11
  ## [0.8.0] - 2026-07-04
6
12
 
7
13
  ### Fixed
@@ -1,4 +1,5 @@
1
1
  export declare function getSlashCommandMatchRank(query: string, commandName: string): number;
2
+ export declare function extractSlashCommandTokenPrefix(text: string): string | null;
2
3
  export interface AutocompleteItem {
3
4
  value: string;
4
5
  label: string;
@@ -1,4 +1,4 @@
1
- import type { AutocompleteProvider } from "../autocomplete";
1
+ import { type AutocompleteProvider } from "../autocomplete";
2
2
  import type { SymbolTheme } from "../symbols";
3
3
  import { type Component, type Focusable } from "../tui";
4
4
  import { type SelectListTheme } from "./select-list";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.8.1",
4
+ "version": "0.8.2",
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",
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@gajae-code/natives": "0.8.1",
42
- "@gajae-code/utils": "0.8.1",
41
+ "@gajae-code/natives": "0.8.2",
42
+ "@gajae-code/utils": "0.8.2",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -189,7 +189,20 @@ function normalizeSlashCommandText(value: string): string {
189
189
  .trim()
190
190
  .replace(/\s+/g, " ");
191
191
  }
192
+ const NON_COMMAND_SLASH_PREFIX_PRECEDERS = new Set(["/", "\\", ":", ".", "~"]);
192
193
 
194
+ export function extractSlashCommandTokenPrefix(text: string): string | null {
195
+ const slashIndex = text.lastIndexOf("/");
196
+ if (slashIndex === -1) return null;
197
+
198
+ const token = text.slice(slashIndex);
199
+ if (/[\s]/.test(token)) return null;
200
+
201
+ const charBeforeSlash = text[slashIndex - 1];
202
+ if (charBeforeSlash && NON_COMMAND_SLASH_PREFIX_PRECEDERS.has(charBeforeSlash)) return null;
203
+
204
+ return token;
205
+ }
193
206
  export interface AutocompleteItem {
194
207
  value: string;
195
208
  label: string;
@@ -272,6 +285,66 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
272
285
  this.#basePath = basePath;
273
286
  }
274
287
 
288
+ #getCommandName(cmd: SlashCommand | AutocompleteItem): string {
289
+ return "name" in cmd ? cmd.name : cmd.value;
290
+ }
291
+
292
+ #getSlashCommandNameSuggestions(prefix: string): AutocompleteItem[] {
293
+ const lowerPrefix = prefix.toLowerCase();
294
+
295
+ return this.#commands
296
+ .filter(cmd => {
297
+ const name = this.#getCommandName(cmd);
298
+ if (!name) return false;
299
+ if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
300
+ if (getSlashCommandMatchRank(lowerPrefix, name.toLowerCase()) < 4) return true;
301
+ const desc = cmd.description?.toLowerCase();
302
+ return desc ? fuzzyMatch(lowerPrefix, desc) : false;
303
+ })
304
+ .map((cmd, index) => {
305
+ const name = this.#getCommandName(cmd);
306
+ const lowerName = name?.toLowerCase() ?? "";
307
+ const lowerDesc = cmd.description?.toLowerCase() ?? "";
308
+ const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
309
+ const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
310
+ const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
311
+ const desc = cmd.description ?? "";
312
+ const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
313
+ const priority = "priority" in cmd && typeof cmd.priority === "number" ? cmd.priority : 0;
314
+ return {
315
+ value: name,
316
+ label: "name" in cmd ? cmd.name : cmd.label,
317
+ score: Math.max(nameScore, descScore),
318
+ priority,
319
+ matchRank: getSlashCommandMatchRank(lowerPrefix, lowerName),
320
+ index,
321
+ ...(fullDesc && { description: fullDesc }),
322
+ } as AutocompleteItem & { score: number; priority: number; matchRank: number; index: number };
323
+ })
324
+ .sort((a, b) => a.matchRank - b.matchRank || b.priority - a.priority || b.score - a.score || a.index - b.index)
325
+ .map(({ score: _score, priority: _priority, matchRank: _matchRank, index: _index, ...rest }) => rest);
326
+ }
327
+
328
+ #getInlineSlashCommandNameSuggestions(prefix: string): AutocompleteItem[] {
329
+ if (prefix.length === 0) return this.#getSlashCommandNameSuggestions(prefix);
330
+
331
+ const normalizedPrefix = normalizeSlashCommandText(prefix);
332
+ return this.#getSlashCommandNameSuggestions(prefix).filter(item => {
333
+ const lowerValue = item.value.toLowerCase();
334
+ if (lowerValue.startsWith(prefix.toLowerCase())) return true;
335
+ if (!normalizedPrefix) return true;
336
+ return normalizeSlashCommandText(item.value).startsWith(normalizedPrefix);
337
+ });
338
+ }
339
+
340
+ #extractSlashCommandPrefix(text: string): string | null {
341
+ return extractSlashCommandTokenPrefix(text);
342
+ }
343
+
344
+ #isKnownCommandItem(item: AutocompleteItem): boolean {
345
+ return this.#commands.some(cmd => this.#getCommandName(cmd) === item.value);
346
+ }
347
+
275
348
  async getSuggestions(
276
349
  lines: string[],
277
350
  cursorLine: number,
@@ -301,52 +374,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
301
374
  };
302
375
  }
303
376
 
304
- // Check for slash commands
377
+ // Check for slash commands at the submitted-message start
305
378
  if (textBeforeCursor.startsWith("/")) {
306
379
  const spaceIndex = textBeforeCursor.indexOf(" ");
307
380
 
308
381
  if (spaceIndex === -1) {
309
382
  // No space yet - complete command names
310
383
  const prefix = textBeforeCursor.slice(1); // Remove the "/"
311
- const lowerPrefix = prefix.toLowerCase();
312
-
313
- // Filter commands using fuzzy matching (subsequence match)
314
- const matches = this.#commands
315
- .filter(cmd => {
316
- const name = "name" in cmd ? cmd.name : cmd.value;
317
- if (!name) return false;
318
- // Match name, normalized slash-name aliases, or description.
319
- if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
320
- if (getSlashCommandMatchRank(lowerPrefix, name.toLowerCase()) < 4) return true;
321
- const desc = cmd.description?.toLowerCase();
322
- return desc ? fuzzyMatch(lowerPrefix, desc) : false;
323
- })
324
- .map((cmd, index) => {
325
- const name = "name" in cmd ? cmd.name : cmd.value;
326
- const lowerName = name?.toLowerCase() ?? "";
327
- const lowerDesc = cmd.description?.toLowerCase() ?? "";
328
- // Score name matches higher than description matches
329
- const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
330
- const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
331
- const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
332
- const desc = cmd.description ?? "";
333
- const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
334
- const priority = "priority" in cmd && typeof cmd.priority === "number" ? cmd.priority : 0;
335
- return {
336
- value: name,
337
- label: "name" in cmd ? cmd.name : cmd.label,
338
- score: Math.max(nameScore, descScore),
339
- priority,
340
- matchRank: getSlashCommandMatchRank(lowerPrefix, lowerName),
341
- index,
342
- ...(fullDesc && { description: fullDesc }),
343
- };
344
- })
345
- .sort(
346
- (a, b) =>
347
- a.matchRank - b.matchRank || b.priority - a.priority || b.score - a.score || a.index - b.index,
348
- )
349
- .map(({ score: _score, priority: _priority, matchRank: _matchRank, index: _index, ...rest }) => rest);
384
+ const matches = this.#getSlashCommandNameSuggestions(prefix);
350
385
 
351
386
  if (matches.length === 0) return null;
352
387
 
@@ -354,36 +389,54 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
354
389
  items: matches,
355
390
  prefix: textBeforeCursor,
356
391
  };
357
- } else {
358
- // Space found - complete command arguments
359
- const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
360
- const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
392
+ }
361
393
 
362
- const command = this.#commands.find(cmd => {
363
- const name = "name" in cmd ? cmd.name : cmd.value;
364
- return name === commandName;
365
- });
366
- if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
367
- return null; // No argument completion for this command
368
- }
394
+ // Space found - complete command arguments
395
+ const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
396
+ const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
397
+
398
+ const command = this.#commands.find(cmd => this.#getCommandName(cmd) === commandName);
399
+ if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
400
+ return null; // No argument completion for this command
401
+ }
402
+
403
+ const argumentSuggestions = await command.getArgumentCompletions(argumentText);
404
+ if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
405
+ return null;
406
+ }
407
+
408
+ return {
409
+ items: argumentSuggestions,
410
+ prefix: argumentText,
411
+ };
412
+ }
369
413
 
370
- const argumentSuggestions = await command.getArgumentCompletions(argumentText);
371
- if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
372
- return null;
414
+ const pathMatch = this.#extractPathPrefix(textBeforeCursor, false);
415
+ let pathSuggestions: AutocompleteItem[] | null = null;
416
+ const slashPrefix = this.#extractSlashCommandPrefix(textBeforeCursor);
417
+ if (slashPrefix) {
418
+ if (pathMatch === slashPrefix && slashPrefix.startsWith("/")) {
419
+ pathSuggestions = await this.#getFileSuggestions(pathMatch);
420
+ if (pathSuggestions.length > 0) {
421
+ return {
422
+ items: pathSuggestions,
423
+ prefix: pathMatch,
424
+ };
373
425
  }
426
+ }
374
427
 
428
+ const matches = this.#getInlineSlashCommandNameSuggestions(slashPrefix.slice(1));
429
+ if (matches.length > 0) {
375
430
  return {
376
- items: argumentSuggestions,
377
- prefix: argumentText,
431
+ items: matches,
432
+ prefix: slashPrefix,
378
433
  };
379
434
  }
380
435
  }
381
436
 
382
437
  // Check for file paths - triggered by Tab or if we detect a path pattern
383
- const pathMatch = this.#extractPathPrefix(textBeforeCursor, false);
384
-
385
438
  if (pathMatch !== null) {
386
- const suggestions = await this.#getFileSuggestions(pathMatch);
439
+ const suggestions = pathSuggestions ?? (await this.#getFileSuggestions(pathMatch));
387
440
  if (suggestions.length === 0) return null;
388
441
 
389
442
  // Check if we have an exact match that is a directory
@@ -418,9 +471,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
418
471
  const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
419
472
  const afterCursor = currentLine.slice(cursorCol);
420
473
 
421
- // Check if we're completing a slash command (prefix starts with "/" but NOT a file path)
422
- // Slash commands are at the start of the line and don't contain path separators after the first /
423
- const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/");
474
+ // Check if we're completing a slash command name. Start-of-line commands
475
+ // execute on submit; inline slash tokens are completed as ordinary text.
476
+ const isSlashCommand =
477
+ prefix.startsWith("/") &&
478
+ !prefix.slice(1).includes("/") &&
479
+ (beforePrefix.trim() === "" || this.#isKnownCommandItem(item));
424
480
  if (isSlashCommand) {
425
481
  // This is a command name completion
426
482
  const newLine = `${beforePrefix}/${item.value} ${afterCursor}`;
@@ -855,40 +911,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
855
911
  if (textBeforeCursor.length <= 1) return null; // Bare "/" alone, don't auto-complete
856
912
  if (textBeforeCursor.includes(" ")) return null; // Only complete command name, not args
857
913
 
858
- const prefix = textBeforeCursor.slice(1);
859
- const lowerPrefix = prefix.toLowerCase();
860
-
861
- const matches = this.#commands
862
- .filter(cmd => {
863
- const name = "name" in cmd ? cmd.name : cmd.value;
864
- if (!name) return false;
865
- if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
866
- if (getSlashCommandMatchRank(lowerPrefix, name.toLowerCase()) < 4) return true;
867
- const desc = cmd.description?.toLowerCase();
868
- return desc ? fuzzyMatch(lowerPrefix, desc) : false;
869
- })
870
- .map((cmd, index) => {
871
- const name = "name" in cmd ? cmd.name : cmd.value;
872
- const lowerName = name?.toLowerCase() ?? "";
873
- const lowerDesc = cmd.description?.toLowerCase() ?? "";
874
- const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
875
- const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
876
- const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
877
- const desc = cmd.description ?? "";
878
- const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
879
- const priority = "priority" in cmd && typeof cmd.priority === "number" ? cmd.priority : 0;
880
- return {
881
- value: name,
882
- label: "name" in cmd ? cmd.name : cmd.label,
883
- score: Math.max(nameScore, descScore),
884
- priority,
885
- matchRank: getSlashCommandMatchRank(lowerPrefix, lowerName),
886
- index,
887
- ...(fullDesc && { description: fullDesc }),
888
- } as AutocompleteItem & { score: number; priority: number; matchRank: number; index: number };
889
- })
890
- .sort((a, b) => a.matchRank - b.matchRank || b.priority - a.priority || b.score - a.score || a.index - b.index)
891
- .map(({ score: _score, priority: _priority, matchRank: _matchRank, index: _index, ...rest }) => rest);
914
+ const matches = this.#getSlashCommandNameSuggestions(textBeforeCursor.slice(1));
892
915
 
893
916
  if (matches.length === 0) return null;
894
917
  return { items: matches, prefix: textBeforeCursor };
@@ -1,5 +1,9 @@
1
1
  import { getProjectDir, logger } from "@gajae-code/utils";
2
- import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
2
+ import {
3
+ type AutocompleteProvider,
4
+ type CombinedAutocompleteProvider,
5
+ extractSlashCommandTokenPrefix,
6
+ } from "../autocomplete";
3
7
  import { BracketedPasteHandler } from "../bracketed-paste";
4
8
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
5
9
  import { extractPrintableText, matchesKey } from "../keys";
@@ -1728,9 +1732,8 @@ export class Editor implements Component, Focusable {
1728
1732
 
1729
1733
  // Check if we should trigger or update autocomplete
1730
1734
  if (!this.#autocompleteState) {
1731
- // Auto-trigger for "/" at the start of a submitted command.
1732
- // Inline skill autocomplete starts after the token becomes "/skill...".
1733
- if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
1735
+ // Auto-trigger for slash command tokens.
1736
+ if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInSlashTokenContext())) {
1734
1737
  this.#tryTriggerAutocomplete();
1735
1738
  }
1736
1739
  // Auto-trigger for "@" file reference (fuzzy search)
@@ -2599,6 +2602,12 @@ export class Editor implements Component, Focusable {
2599
2602
  : this.#state.cursorCol - 1
2600
2603
  : undefined;
2601
2604
 
2605
+ // Backward from column 0: lastIndexOf clamps a negative position to 0,
2606
+ // which would match the character under the cursor instead of skipping it
2607
+ if (!isForward && searchFrom !== undefined && searchFrom < 0) {
2608
+ continue;
2609
+ }
2610
+
2602
2611
  const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
2603
2612
 
2604
2613
  if (idx !== -1) {
@@ -2651,12 +2660,11 @@ export class Editor implements Component, Focusable {
2651
2660
  #getSlashTokenBeforeCursor(): string | null {
2652
2661
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2653
2662
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2654
- const match = beforeCursor.match(/(?:^|\s)(\/[^\s]*)$/);
2655
- return match?.[1] ?? null;
2663
+ return extractSlashCommandTokenPrefix(beforeCursor);
2656
2664
  }
2657
2665
 
2658
2666
  #isInSlashTokenContext(): boolean {
2659
- return this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true;
2667
+ return this.#getSlashTokenBeforeCursor() !== null;
2660
2668
  }
2661
2669
 
2662
2670
  #isSlashCommandNameAutocompleteSelection(): boolean {
@@ -2740,7 +2748,10 @@ export class Editor implements Component, Focusable {
2740
2748
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2741
2749
 
2742
2750
  // Check if we're in a slash command context
2743
- if (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) {
2751
+ if (
2752
+ (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) ||
2753
+ this.#isInSlashTokenContext()
2754
+ ) {
2744
2755
  this.#handleSlashCommandCompletion();
2745
2756
  } else {
2746
2757
  this.#forceFileAutocomplete(true);
package/src/tui.ts CHANGED
@@ -139,9 +139,29 @@ function isTermuxSession(): boolean {
139
139
  return Boolean(process.env.TERMUX_VERSION);
140
140
  }
141
141
 
142
+ const GJC_TMUX_LAUNCHED_ENV = "GJC_TMUX_LAUNCHED";
143
+ const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
144
+
145
+ function envIsEnabled(value: string | undefined): boolean {
146
+ const normalized = value?.trim().toLowerCase();
147
+ return normalized !== undefined && normalized.length > 0 && !DISABLED_ENV_VALUES.has(normalized);
148
+ }
149
+
150
+ function termLooksMultiplexed(value: string | undefined): boolean {
151
+ const term = value?.trim().toLowerCase() ?? "";
152
+ return term.startsWith("tmux") || term.startsWith("screen");
153
+ }
154
+
142
155
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
143
156
  function isMultiplexerSession(): boolean {
144
- return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
157
+ return Boolean(
158
+ envIsEnabled(Bun.env.TMUX) ||
159
+ envIsEnabled(Bun.env.TMUX_PANE) ||
160
+ envIsEnabled(Bun.env.STY) ||
161
+ envIsEnabled(Bun.env.ZELLIJ) ||
162
+ envIsEnabled(Bun.env[GJC_TMUX_LAUNCHED_ENV]) ||
163
+ termLooksMultiplexed(Bun.env.TERM),
164
+ );
145
165
  }
146
166
 
147
167
  function useLegacyMultiplexerFullRender(): boolean {