@linxiraos/pi-tui 1.0.9 → 1.1.0

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +1 -2268
  2. package/LICENSE +23 -0
  3. package/README.md +1 -1
  4. package/package.json +68 -69
  5. package/src/autocomplete.ts +147 -61
  6. package/src/components/composer/borderless.ts +37 -0
  7. package/src/components/composer/box.ts +85 -0
  8. package/src/components/composer/claude.ts +39 -0
  9. package/src/components/composer/field.ts +47 -0
  10. package/src/components/composer/index.ts +9 -0
  11. package/src/components/composer/pi.ts +37 -0
  12. package/src/components/composer/rail.ts +48 -0
  13. package/src/components/composer/registry.ts +46 -0
  14. package/src/components/composer/rule.ts +55 -0
  15. package/src/components/composer/types.ts +98 -0
  16. package/src/components/editor.ts +564 -259
  17. package/src/components/image.ts +4 -6
  18. package/src/components/loader.ts +1 -9
  19. package/src/components/markdown.ts +487 -423
  20. package/src/components/select-list.ts +67 -15
  21. package/src/components/settings-list.ts +43 -11
  22. package/src/components/text.ts +8 -18
  23. package/src/desktop-notify.ts +1 -1
  24. package/src/fuzzy.ts +63 -6
  25. package/src/index.ts +1 -0
  26. package/src/keybindings.ts +5 -0
  27. package/src/keys.ts +1 -1
  28. package/src/stdin-buffer.ts +130 -20
  29. package/src/terminal-capabilities.ts +36 -6
  30. package/src/terminal.ts +148 -23
  31. package/src/tui.ts +655 -3458
  32. package/src/utils.ts +65 -61
  33. package/dist/types/autocomplete.d.ts +0 -116
  34. package/dist/types/bracketed-paste.d.ts +0 -51
  35. package/dist/types/components/box.d.ts +0 -31
  36. package/dist/types/components/cancellable-loader.d.ts +0 -21
  37. package/dist/types/components/editor.d.ts +0 -166
  38. package/dist/types/components/image.d.ts +0 -165
  39. package/dist/types/components/input.d.ts +0 -25
  40. package/dist/types/components/loader.d.ts +0 -25
  41. package/dist/types/components/markdown.d.ts +0 -92
  42. package/dist/types/components/scroll-view.d.ts +0 -62
  43. package/dist/types/components/select-list.d.ts +0 -69
  44. package/dist/types/components/settings-list.d.ts +0 -123
  45. package/dist/types/components/spacer.d.ts +0 -11
  46. package/dist/types/components/tab-bar.d.ts +0 -89
  47. package/dist/types/components/text.d.ts +0 -28
  48. package/dist/types/components/truncated-text.d.ts +0 -10
  49. package/dist/types/deccara.d.ts +0 -49
  50. package/dist/types/desktop-notify.d.ts +0 -52
  51. package/dist/types/editor-component.d.ts +0 -38
  52. package/dist/types/fuzzy.d.ts +0 -48
  53. package/dist/types/index.d.ts +0 -32
  54. package/dist/types/keybindings.d.ts +0 -197
  55. package/dist/types/keys.d.ts +0 -210
  56. package/dist/types/kill-ring.d.ts +0 -20
  57. package/dist/types/kitty-graphics.d.ts +0 -76
  58. package/dist/types/latex-block.d.ts +0 -8
  59. package/dist/types/latex-to-unicode.d.ts +0 -50
  60. package/dist/types/loop-watchdog.d.ts +0 -49
  61. package/dist/types/mouse.d.ts +0 -67
  62. package/dist/types/stdin-buffer.d.ts +0 -60
  63. package/dist/types/symbols.d.ts +0 -25
  64. package/dist/types/terminal-capabilities.d.ts +0 -324
  65. package/dist/types/terminal.d.ts +0 -175
  66. package/dist/types/tmux.d.ts +0 -6
  67. package/dist/types/ttyid.d.ts +0 -9
  68. package/dist/types/tui.d.ts +0 -480
  69. package/dist/types/utils.d.ts +0 -112
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mario Zechner
4
+ Copyright (c) 2025-2026 Can Bölük
5
+ Copyright (c) 2026 Stencil Labs, Inc.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md CHANGED
@@ -539,7 +539,7 @@ interface Terminal {
539
539
  **Built-in implementations:**
540
540
 
541
541
  - `ProcessTerminal` - Uses `process.stdin/stdout`
542
- - `VirtualTerminal` - For testing (uses ghostty-web)
542
+ - `VirtualTerminal` - For testing (uses kitty-vt-wasm)
543
543
 
544
544
  ## Utilities
545
545
 
package/package.json CHANGED
@@ -1,70 +1,69 @@
1
1
  {
2
- "type": "module",
3
- "name": "@linxiraos/pi-tui",
4
- "version": "1.0.9",
5
- "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
- "homepage": "https://linxira-os.github.io",
7
- "author": "Can Boluk",
8
- "contributors": [
9
- "Mario Zechner"
10
- ],
11
- "license": "MIT",
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
15
- "directory": "packages/tui"
16
- },
17
- "bugs": {
18
- "url": "https://github.com/can1357/oh-my-pi/issues"
19
- },
20
- "keywords": [
21
- "tui",
22
- "terminal",
23
- "ui",
24
- "text-editor",
25
- "differential-rendering",
26
- "typescript",
27
- "cli"
28
- ],
29
- "main": "./src/index.ts",
30
- "types": "./dist/types/index.d.ts",
31
- "scripts": {
32
- "check": "biome check . && bun run check:types",
33
- "check:types": "tsgo -p tsconfig.json --noEmit",
34
- "lint": "biome lint .",
35
- "test": "bun test --parallel test/*.test.ts",
36
- "fix": "biome check --write --unsafe .",
37
- "fmt": "biome format --write ."
38
- },
39
- "dependencies": {
40
- "@linxiraos/pi-natives": "1.0.9",
41
- "@linxiraos/pi-utils": "1.0.9"
42
- },
43
- "devDependencies": {
44
- "ghostty-web": "^0.4.0"
45
- },
46
- "engines": {
47
- "bun": ">=1.3.14"
48
- },
49
- "files": [
50
- "src",
51
- "README.md",
52
- "CHANGELOG.md",
53
- "dist/types"
54
- ],
55
- "exports": {
56
- ".": {
57
- "types": "./dist/types/index.d.ts",
58
- "import": "./src/index.ts"
59
- },
60
- "./*": {
61
- "types": "./dist/types/*.d.ts",
62
- "import": "./src/*.ts"
63
- },
64
- "./components/*": {
65
- "types": "./dist/types/components/*.d.ts",
66
- "import": "./src/components/*.ts"
67
- },
68
- "./*.js": "./src/*.ts"
69
- }
70
- }
2
+ "type": "module",
3
+ "name": "@linxiraos/pi-tui",
4
+ "version": "1.1.0",
5
+ "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
+ "homepage": "https://omp.sh",
7
+ "author": "Stencil Labs, Inc.",
8
+ "contributors": [
9
+ "Mario Zechner"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/can1357/oh-my-pi.git",
15
+ "directory": "packages/tui"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/can1357/oh-my-pi/issues"
19
+ },
20
+ "keywords": [
21
+ "tui",
22
+ "terminal",
23
+ "ui",
24
+ "text-editor",
25
+ "differential-rendering",
26
+ "typescript",
27
+ "cli"
28
+ ],
29
+ "main": "./src/index.ts",
30
+ "types": "./src/index.ts",
31
+ "scripts": {
32
+ "check": "biome check . && bun run check:types",
33
+ "check:types": "tsgo -p tsconfig.json --noEmit",
34
+ "lint": "biome lint .",
35
+ "test": "bun test --parallel test/*.test.ts",
36
+ "fix": "biome check --write --unsafe .",
37
+ "fmt": "biome format --write ."
38
+ },
39
+ "dependencies": {
40
+ "@linxiraos/pi-natives": "catalog:",
41
+ "@linxiraos/pi-utils": "catalog:"
42
+ },
43
+ "devDependencies": {
44
+ "kitty-vt-wasm": "catalog:"
45
+ },
46
+ "engines": {
47
+ "bun": ">=1.3.14"
48
+ },
49
+ "files": [
50
+ "src",
51
+ "README.md",
52
+ "CHANGELOG.md"
53
+ ],
54
+ "exports": {
55
+ ".": {
56
+ "types": "./src/index.ts",
57
+ "import": "./src/index.ts"
58
+ },
59
+ "./*": {
60
+ "types": "./src/*.ts",
61
+ "import": "./src/*.ts"
62
+ },
63
+ "./components/*": {
64
+ "types": "./src/components/*.ts",
65
+ "import": "./src/components/*.ts"
66
+ },
67
+ "./*.js": "./src/*.ts"
68
+ }
69
+ }
@@ -9,6 +9,7 @@ const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
9
9
  function buildAutocompleteFuzzyDiscoveryProfile(
10
10
  query: string,
11
11
  basePath: string,
12
+ signal?: AbortSignal,
12
13
  ): {
13
14
  query: string;
14
15
  path: string;
@@ -16,6 +17,7 @@ function buildAutocompleteFuzzyDiscoveryProfile(
16
17
  hidden: boolean;
17
18
  gitignore: boolean;
18
19
  cache: boolean;
20
+ signal?: AbortSignal;
19
21
  } {
20
22
  return {
21
23
  query,
@@ -24,6 +26,7 @@ function buildAutocompleteFuzzyDiscoveryProfile(
24
26
  hidden: true,
25
27
  gitignore: true,
26
28
  cache: true,
29
+ ...(signal ? { signal } : {}),
27
30
  };
28
31
  }
29
32
 
@@ -171,6 +174,8 @@ export interface AutocompleteItem {
171
174
  value: string;
172
175
  label: string;
173
176
  description?: string;
177
+ /** Optional type-indicator glyph rendered in an aligned column before the label */
178
+ icon?: string;
174
179
  /** Dim hint text shown inline after cursor when this item is selected */
175
180
  hint?: string;
176
181
  }
@@ -181,6 +186,8 @@ export interface SlashCommand {
181
186
  name: string;
182
187
  aliases?: string[];
183
188
  description?: string;
189
+ /** Optional type-indicator glyph shown before the command name in autocomplete */
190
+ icon?: string;
184
191
  argumentHint?: string;
185
192
  /** Whether the command consumes argument text after the command name. False means the full input stays normal prompt text once args are present. */
186
193
  allowArgs?: boolean;
@@ -194,11 +201,12 @@ export interface SlashCommand {
194
201
  }
195
202
 
196
203
  export interface AutocompleteProvider {
197
- /** Get autocomplete suggestions for current text/cursor position */
204
+ /** Get autocomplete suggestions for current text/cursor position. Expensive providers SHOULD stop when `signal` aborts. */
198
205
  getSuggestions(
199
206
  lines: string[],
200
207
  cursorLine: number,
201
208
  cursorCol: number,
209
+ signal?: AbortSignal,
202
210
  ): Promise<{
203
211
  items: AutocompleteItem[];
204
212
  prefix: string; // What we're matching against (e.g., "/" or "src/")
@@ -237,11 +245,13 @@ export interface AutocompleteProvider {
237
245
  * Force file-path completion (called on Tab). Returns matched items plus the
238
246
  * full prefix, or null when no path token sits before the cursor. Present on
239
247
  * file-aware providers; absent on slash-only ones.
248
+ * Expensive providers SHOULD stop when `signal` aborts.
240
249
  */
241
250
  getForceFileSuggestions?(
242
251
  lines: string[],
243
252
  cursorLine: number,
244
253
  cursorCol: number,
254
+ signal?: AbortSignal,
245
255
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null>;
246
256
 
247
257
  /** Whether a Tab press should attempt file completion at the cursor. */
@@ -249,6 +259,11 @@ export interface AutocompleteProvider {
249
259
  }
250
260
 
251
261
  type CommandEntry = SlashCommand | AutocompleteItem;
262
+ /** Optional behaviors for {@link CombinedAutocompleteProvider}. */
263
+ export interface CombinedAutocompleteOptions {
264
+ /** Usage count per command name; higher counts rank earlier among equal text-match scores. */
265
+ commandUsage?: (name: string) => number;
266
+ }
252
267
 
253
268
  function getCommandName(cmd: CommandEntry): string | undefined {
254
269
  return "name" in cmd ? cmd.name : cmd.value;
@@ -287,64 +302,79 @@ export function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string):
287
302
  return fuzzyMatch(lowerPrefix, lowerTarget) ? fuzzyScore(lowerPrefix, lowerTarget) : 0;
288
303
  }
289
304
 
290
- function buildSlashCommandCompletions(commands: CommandEntry[], lowerPrefix: string): AutocompleteItem[] {
291
- return commands
292
- .flatMap(cmd => {
293
- const name = getCommandName(cmd);
294
- if (!name) return [];
295
- const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
296
- const staticDesc = getStaticCommandDescription(cmd);
297
- let fullDescMemo: string | undefined;
298
- let fullDescComputed = false;
299
- // Resolve the (possibly live) display description lazily, only once a
300
- // candidate actually matches — getAutocompleteDescription reads live
301
- // session state and must not run for every command on each keystroke.
302
- const resolveFullDesc = (): string | undefined => {
303
- if (!fullDescComputed) {
304
- const displayDesc = getAutocompleteCommandDescription(cmd);
305
- fullDescMemo = hint ? (displayDesc ? `${hint} - ${displayDesc}` : hint) : displayDesc;
306
- fullDescComputed = true;
307
- }
308
- return fullDescMemo;
309
- };
310
- let best: (AutocompleteItem & { score: number }) | undefined;
311
-
312
- const isSkillCommand = name.startsWith("skill:");
313
- const nameScore =
314
- lowerPrefix.length === 0 && isSkillCommand ? 950 : scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
315
- const lowerDesc = staticDesc.toLowerCase();
316
- const descScore =
317
- lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
318
- const primaryScore = Math.max(nameScore, descScore);
319
- if (primaryScore > 0) {
320
- const fullDesc = resolveFullDesc();
321
- best = {
322
- value: name,
323
- label: "name" in cmd ? cmd.name : cmd.label,
324
- score: primaryScore,
325
- ...(fullDesc && { description: fullDesc }),
305
+ function buildSlashCommandCompletions(
306
+ commands: CommandEntry[],
307
+ lowerPrefix: string,
308
+ commandUsage?: (name: string) => number,
309
+ ): AutocompleteItem[] {
310
+ return (
311
+ commands
312
+ .flatMap(cmd => {
313
+ const name = getCommandName(cmd);
314
+ if (!name) return [];
315
+ const usage = commandUsage?.(name) ?? 0;
316
+ const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
317
+ const staticDesc = getStaticCommandDescription(cmd);
318
+ let fullDescMemo: string | undefined;
319
+ let fullDescComputed = false;
320
+ // Resolve the (possibly live) display description lazily, only once a
321
+ // candidate actually matches — getAutocompleteDescription reads live
322
+ // session state and must not run for every command on each keystroke.
323
+ const resolveFullDesc = (): string | undefined => {
324
+ if (!fullDescComputed) {
325
+ const displayDesc = getAutocompleteCommandDescription(cmd);
326
+ fullDescMemo = hint ? (displayDesc ? `${hint} - ${displayDesc}` : hint) : displayDesc;
327
+ fullDescComputed = true;
328
+ }
329
+ return fullDescMemo;
326
330
  };
327
- }
328
-
329
- if (lowerPrefix.length > 0) {
330
- for (const alias of getCommandAliases(cmd)) {
331
- if (alias === name) continue;
332
- const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
333
- if (aliasScore === 0 || (best && aliasScore <= best.score)) continue;
331
+ let best: (AutocompleteItem & { score: number; usage: number }) | undefined;
332
+
333
+ const isSkillCommand = name.startsWith(SKILL_NAMESPACE);
334
+ const nameScore =
335
+ lowerPrefix.length === 0 && isSkillCommand
336
+ ? 950
337
+ : scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
338
+ const lowerDesc = staticDesc.toLowerCase();
339
+ const descScore =
340
+ lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
341
+ const primaryScore = Math.max(nameScore, descScore);
342
+ if (primaryScore > 0) {
334
343
  const fullDesc = resolveFullDesc();
335
344
  best = {
336
- value: alias,
337
- label: alias,
338
- score: aliasScore,
345
+ value: name,
346
+ label: "name" in cmd ? cmd.name : cmd.label,
347
+ score: primaryScore,
348
+ usage,
349
+ ...(cmd.icon && { icon: cmd.icon }),
339
350
  ...(fullDesc && { description: fullDesc }),
340
351
  };
341
352
  }
342
- }
343
353
 
344
- return best ? [best] : [];
345
- })
346
- .sort((a, b) => b.score - a.score)
347
- .map(({ score: _, ...rest }) => rest);
354
+ if (lowerPrefix.length > 0) {
355
+ for (const alias of getCommandAliases(cmd)) {
356
+ if (alias === name) continue;
357
+ const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
358
+ if (aliasScore === 0 || (best && aliasScore <= best.score)) continue;
359
+ const fullDesc = resolveFullDesc();
360
+ best = {
361
+ value: alias,
362
+ label: alias,
363
+ score: aliasScore,
364
+ usage,
365
+ ...(cmd.icon && { icon: cmd.icon }),
366
+ ...(fullDesc && { description: fullDesc }),
367
+ };
368
+ }
369
+ }
370
+
371
+ return best ? [best] : [];
372
+ })
373
+ // Equal text-match scores fall back to usage frequency, then to the
374
+ // stable registry order.
375
+ .sort((a, b) => b.score - a.score || b.usage - a.usage)
376
+ .map(({ score: _, usage: _usage, ...rest }) => rest)
377
+ );
348
378
  }
349
379
 
350
380
  function hasPromptTextBeforeSlash(
@@ -359,7 +389,35 @@ function hasPromptTextBeforeSlash(
359
389
  return textBeforeCursor.slice(0, slashStart).trim() !== "";
360
390
  }
361
391
 
362
- const SKILL_NAMESPACE = "skill:";
392
+ export const SKILL_NAMESPACE = "skill:";
393
+
394
+ /**
395
+ * Collapse `skill:*` commands into a single `/skill:` namespace row while the
396
+ * typed prefix has not committed to the namespace. Until the prefix starts
397
+ * with `skill:`, individual skills never list — a lone group entry (shown only
398
+ * while the prefix is still a prefix of `skill:`) keeps the `/` popup
399
+ * readable. Accepting the group inserts `/skill:` without a trailing space so
400
+ * the reopened popup expands to the individual skills.
401
+ */
402
+ function collapseSkillNamespace(commands: CommandEntry[], lowerPrefix: string): CommandEntry[] {
403
+ if (lowerPrefix.startsWith(SKILL_NAMESPACE)) return commands;
404
+ let skillCount = 0;
405
+ let skillIcon: string | undefined;
406
+ const rest = commands.filter(cmd => {
407
+ if (!getCommandName(cmd)?.startsWith(SKILL_NAMESPACE)) return true;
408
+ skillCount += 1;
409
+ skillIcon ??= cmd.icon;
410
+ return false;
411
+ });
412
+ if (skillCount === 0) return commands;
413
+ if (!SKILL_NAMESPACE.startsWith(lowerPrefix)) return rest;
414
+ rest.push({
415
+ name: SKILL_NAMESPACE,
416
+ description: `${skillCount} skill${skillCount === 1 ? "" : "s"}`,
417
+ ...(skillIcon && { icon: skillIcon }),
418
+ });
419
+ return rest;
420
+ }
363
421
 
364
422
  /**
365
423
  * Whether a mid-prompt slash token (`prose … /tok`) is skill-shaped enough to
@@ -401,22 +459,30 @@ function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: s
401
459
  export class CombinedAutocompleteProvider implements AutocompleteProvider {
402
460
  #commands: CommandEntry[];
403
461
  #basePath: string;
462
+ #commandUsage?: (name: string) => number;
404
463
  // Intentionally separate from pi-natives cache: this cache is a local,
405
464
  // per-directory readdir fast-path for prefix completions. Global fuzzy
406
465
  // discovery continues to use native fuzzyFind + shared scan cache.
407
466
  #dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
408
467
  readonly #DIR_CACHE_TTL = 2000; // 2 seconds
409
468
 
410
- constructor(commands: CommandEntry[] = [], basePath: string = getProjectDir()) {
469
+ constructor(
470
+ commands: CommandEntry[] = [],
471
+ basePath: string = getProjectDir(),
472
+ options?: CombinedAutocompleteOptions,
473
+ ) {
411
474
  this.#commands = commands;
412
475
  this.#basePath = basePath;
476
+ this.#commandUsage = options?.commandUsage;
413
477
  }
414
478
 
415
479
  async getSuggestions(
416
480
  lines: string[],
417
481
  cursorLine: number,
418
482
  cursorCol: number,
483
+ signal?: AbortSignal,
419
484
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
485
+ if (signal?.aborted) return null;
420
486
  const currentLine = lines[cursorLine] || "";
421
487
  const textBeforeCursor = currentLine.slice(0, cursorCol);
422
488
 
@@ -444,7 +510,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
444
510
 
445
511
  const matches = isMidPromptSkillLookup
446
512
  ? buildMidPromptSkillCompletions(this.#commands, lowerPrefix)
447
- : buildSlashCommandCompletions(this.#commands, lowerPrefix);
513
+ : buildSlashCommandCompletions(
514
+ collapseSkillNamespace(this.#commands, lowerPrefix),
515
+ lowerPrefix,
516
+ this.#commandUsage,
517
+ );
448
518
 
449
519
  if (matches.length > 0) {
450
520
  return {
@@ -508,7 +578,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
508
578
  }
509
579
  const suggestions =
510
580
  rawPrefix.length > 0
511
- ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix })
581
+ ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix, signal })
512
582
  : await this.#getFileSuggestions("@");
513
583
  if (suggestions.length === 0 && rawPrefix.length > 0) {
514
584
  const fallback = await this.#getFileSuggestions(atPrefix);
@@ -601,14 +671,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
601
671
  const slashPrefix = textBeforeCursor.slice(leadingSlashStart);
602
672
  if (!slashPrefix.includes(" ") && !slashPrefix.slice(1).includes("/")) {
603
673
  const beforeSlash = currentLine.slice(0, leadingSlashStart);
604
- const newLine = `${beforeSlash}/${item.value} ${afterCursor}`;
674
+ // The collapsed `/skill:` namespace row completes to the namespace
675
+ // itself: no trailing space, so completion continues with the
676
+ // individual skills instead of finishing a command token.
677
+ const insert = item.value === SKILL_NAMESPACE ? `/${item.value}` : `/${item.value} `;
678
+ const newLine = `${beforeSlash}${insert}${afterCursor}`;
605
679
  const newLines = [...lines];
606
680
  newLines[cursorLine] = newLine;
607
681
 
608
682
  return {
609
683
  lines: newLines,
610
684
  cursorLine,
611
- cursorCol: beforeSlash.length + item.value.length + 2, // +2 for "/" and space
685
+ cursorCol: beforeSlash.length + insert.length,
612
686
  };
613
687
  }
614
688
  }
@@ -951,12 +1025,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
951
1025
  }
952
1026
  }
953
1027
 
954
- async #getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): Promise<AutocompleteItem[]> {
1028
+ async #getFuzzyFileSuggestions(
1029
+ query: string,
1030
+ options: { isQuotedPrefix: boolean; signal?: AbortSignal },
1031
+ ): Promise<AutocompleteItem[]> {
955
1032
  try {
956
1033
  const scopedQuery = await this.#resolveScopedFuzzyQuery(query);
1034
+ if (options.signal?.aborted) return [];
957
1035
  const searchPath = scopedQuery?.baseDir ?? this.#basePath;
958
1036
  const fuzzyQuery = scopedQuery?.query ?? query;
959
- const result = await fuzzyFind(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath));
1037
+ const result = await fuzzyFind(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath, options.signal));
960
1038
  const lowerQuery = fuzzyQuery.toLowerCase();
961
1039
  const filteredMatches = result.matches.filter(entry => {
962
1040
  const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
@@ -999,7 +1077,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
999
1077
  lines: string[],
1000
1078
  cursorLine: number,
1001
1079
  cursorCol: number,
1080
+ signal?: AbortSignal,
1002
1081
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
1082
+ if (signal?.aborted) return null;
1003
1083
  const currentLine = lines[cursorLine] || "";
1004
1084
  const textBeforeCursor = currentLine.slice(0, cursorCol);
1005
1085
 
@@ -1069,7 +1149,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1069
1149
  const prefix = commandText.slice(1);
1070
1150
  const lowerPrefix = prefix.toLowerCase();
1071
1151
 
1072
- const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix);
1152
+ // The `/skill:` namespace row is excluded here: the sync path submits
1153
+ // immediately after applying, and the bare namespace is not a command.
1154
+ const matches = buildSlashCommandCompletions(
1155
+ collapseSkillNamespace(this.#commands, lowerPrefix),
1156
+ lowerPrefix,
1157
+ this.#commandUsage,
1158
+ ).filter(item => item.value !== SKILL_NAMESPACE);
1073
1159
 
1074
1160
  if (matches.length === 0) return null;
1075
1161
  // Mirror `getSuggestions`: preserve leading whitespace so the editor's
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Chrome-free composer: a bare `❯ ` prompt with no rules or borders. Also the
3
+ * effective style whenever a host calls `setBorderVisible(false)` (hook
4
+ * editors, agents hub). The status bar renders as a plain standalone bottom
5
+ * bar with both segment groups.
6
+ */
7
+ import type { ComposerRowContext, ComposerStyle } from "./types";
8
+
9
+ export const borderlessComposerStyle: ComposerStyle = {
10
+ id: "borderless",
11
+ sideBorders: false,
12
+ verticalChrome: 0,
13
+ statusAttachment: "none",
14
+ bottomBar: "full",
15
+ bottomBarGap: false,
16
+ defaultPromptGutter: "❯ ",
17
+
18
+ defaultPaddingX(): number {
19
+ return 0;
20
+ },
21
+
22
+ sideChromeWidth(): number {
23
+ return 0;
24
+ },
25
+
26
+ renderTop(): undefined {
27
+ return undefined;
28
+ },
29
+
30
+ renderRow(ctx: ComposerRowContext): string[] {
31
+ return [ctx.gutter + ctx.text + ctx.pad];
32
+ },
33
+
34
+ renderBottom(): undefined {
35
+ return undefined;
36
+ },
37
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The classic omp composer: rounded frame, status line embedded in the top
3
+ * border, and the last content row merged into the bottom border
4
+ * (`╰─ text … ─╯`), keeping a one-line prompt at two rows total.
5
+ */
6
+ import { padding, truncateToWidth, visibleWidth } from "../../utils";
7
+ import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
8
+
9
+ export const boxComposerStyle: ComposerStyle = {
10
+ id: "box",
11
+ sideBorders: true,
12
+ verticalChrome: 2,
13
+ statusAttachment: "top-border",
14
+ bottomBar: "none",
15
+ bottomBarGap: false,
16
+ defaultPromptGutter: undefined,
17
+
18
+ defaultPaddingX(themePaddingX: number | undefined): number {
19
+ return Math.max(0, themePaddingX ?? 2);
20
+ },
21
+
22
+ sideChromeWidth(paddingX: number): number {
23
+ return paddingX + 1;
24
+ },
25
+
26
+ renderTop(ctx: ComposerChromeContext): string {
27
+ const { box, paddingX, width, borderColor, topBorder } = ctx;
28
+ const topLeft = borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
29
+ const topRight = borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
30
+ const topFillWidth = Math.max(0, width - this.sideChromeWidth(paddingX) * 2);
31
+ if (!topBorder) {
32
+ return topLeft + borderColor(box.horizontal.repeat(topFillWidth)) + topRight;
33
+ }
34
+ const { content, width: statusWidth } = topBorder;
35
+ if (statusWidth <= topFillWidth) {
36
+ // Status fits - add fill after it
37
+ const fillWidth = topFillWidth - statusWidth;
38
+ return topLeft + content + borderColor(box.horizontal.repeat(fillWidth)) + topRight;
39
+ }
40
+ // Status too long - truncate it
41
+ const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
42
+ const truncatedWidth = visibleWidth(truncated);
43
+ const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
44
+ return topLeft + truncated + borderColor(box.horizontal.repeat(fillWidth)) + topRight;
45
+ },
46
+
47
+ renderRow(ctx: ComposerRowContext): string[] {
48
+ const { box, paddingX, width, borderColor, text, pad, isLastRow } = ctx;
49
+ // When the end-of-line cursor glyph (or a wide trailing grapheme) extends
50
+ // past the content width, shrink the right chrome by the exact overflow
51
+ // count: drop padding spaces first, then the trailing `─`, but never the
52
+ // corner/vertical bar itself.
53
+ const rightChromeCells = Math.max(1, paddingX + 1 - ctx.cursorOverflow);
54
+ if (isLastRow && ctx.imeSafeCursorTail) {
55
+ // Terminal frontends render IME marked text locally before committed
56
+ // bytes reach the application. Keep the end-of-input cursor row empty
57
+ // to its right so insertion cannot shift box chrome onto the next row.
58
+ const leftBorder = borderColor(`${box.vertical}${padding(paddingX)}`);
59
+ const bottomBorder = borderColor(
60
+ `${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`,
61
+ );
62
+ return [leftBorder + text, bottomBorder];
63
+ }
64
+ if (isLastRow) {
65
+ const bottomLeft = borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
66
+ const rightPad = Math.max(0, rightChromeCells - 2);
67
+ const includeHorizontal = rightChromeCells >= 2;
68
+ const bottomRightAdjusted = borderColor(
69
+ `${padding(rightPad)}${includeHorizontal ? box.horizontal : ""}${box.bottomRight}`,
70
+ );
71
+ return [`${bottomLeft}${text}${pad}${bottomRightAdjusted}`];
72
+ }
73
+ const leftBorder = borderColor(`${box.vertical}${padding(paddingX)}`);
74
+ // When the scrollbar is active, replace the right border vertical with a
75
+ // thumb glyph (█) inside the thumb range, keeping the track (│) elsewhere.
76
+ const rightGlyph = ctx.scrollbarThumb ? "█" : box.vertical;
77
+ const rightBorder = borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
78
+ return [leftBorder + text + pad + rightBorder];
79
+ },
80
+
81
+ renderBottom(): undefined {
82
+ // The bottom border is merged into the last content row.
83
+ return undefined;
84
+ },
85
+ };