@oh-my-pi/pi-tui 17.4.2 → 18.0.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.0] - 2026-08-22
6
+
7
+ ### Breaking Changes
8
+
9
+ - Changed native macOS spelling and completion functions to return Promises.
10
+ - Updated `EditorTextAssistProvider.tryAutocorrect` signature to receive editor state instead of raw text.
11
+ - Updated `Editor.decorateText` signature to provide line and column context instead of raw text.
12
+
13
+ ### Added
14
+
15
+ - Added `EditorTextAssistProvider` with spelling suggestion support (`ctrl+.`), word replacement choices, and async autocorrection handling.
16
+ - Added `Terminal.pendingOutputBytes` and an output-backpressure render gate to drop stale frames on slow terminals.
17
+ - Added `deferInput` startup option and `enableInput()` across `Terminal`, `TUI`, and `TUIStartOptions` to improve startup responsiveness.
18
+ - Added icon support and customizable theming to autocomplete and select lists.
19
+ - Added `MarkdownTheme.createHighlightStream` for incremental syntax highlighting of completed lines in streaming Markdown code blocks.
20
+ - Added `maxDescriptionRows` option to `SelectList` layouts to truncate wrapped descriptions with an ellipsis.
21
+ - Added `commandUsage` ranking callback support to `CombinedAutocompleteProvider` to prioritize frequently used slash commands.
22
+ - Added `Editor.viewportRowsProvider` to constrain autocomplete dropdowns within the available terminal height.
23
+ - Added `Editor.setTheme()` to dynamically change themes without recreating the editor or losing draft content.
24
+
25
+ ### Changed
26
+
27
+ - Adjusted word completion to skip appending a trailing space when the following character is punctuation.
28
+ - Increased default autocomplete dropdown height from 5 to 10 items.
29
+
30
+ ### Fixed
31
+
32
+ - Fixed TUI freezing during large repaints on slow or occluded terminals by moving stdout writes to an off-thread writer.
33
+
34
+ ## [17.4.4] - 2026-08-22
35
+
36
+ ### Added
37
+
38
+ - Added `setResizeScrollback()` / `ResizeScrollbackMode` (`PI_TUI_RESIZE_SCROLLBACK` env initializer) controlling what a settled in-place width resize does to native scrollback, which the host rewraps naively at the old width: `append` replays the transcript at the settled width below the old-wrap history, `rebuild` clears pane history first (ED3) so it holds exactly one current-width copy, and `preserve` repaints the viewport only with zero history growth. The raw engine defaults to `preserve`; the coding agent's `tui.resizeScrollback` setting (default `append`) governs interactive sessions.
39
+
40
+ ### Fixed
41
+
42
+ - `visibleWidth` now measures APC sequences (Kitty graphics commands, cursor markers) as zero cells instead of counting their payload as printable text, matching the native width engine.
43
+ - Kitty Unicode-placeholder rows with long styled prefixes (e.g. bordered thumbnail cards) are recognized as image lines again, keeping them on the verbatim render path instead of SGR coalescing/truncation.
44
+ - Fixed multiplexer width-epoch resolution failing for every real component tree, which forced the conservative full-transcript replay (and one duplicated transcript copy in pane history) on every settled width resize: leading children without a width-epoch revision are no longer validated by width-dependent row counts (reflow is not mutation — identity plus the revision, when reported, is the stability proof), and `Markdown` now reports a width-independent mutation revision so it can sit above an epoch source ([#8193](https://github.com/can1357/oh-my-pi/issues/8193), [#7026](https://github.com/can1357/oh-my-pi/issues/7026)).
45
+
5
46
  ## [17.4.2] - 2026-08-21
6
47
 
7
48
  ### Added
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
 
@@ -10,6 +10,8 @@ export interface AutocompleteItem {
10
10
  value: string;
11
11
  label: string;
12
12
  description?: string;
13
+ /** Optional type-indicator glyph rendered in an aligned column before the label */
14
+ icon?: string;
13
15
  /** Dim hint text shown inline after cursor when this item is selected */
14
16
  hint?: string;
15
17
  }
@@ -18,6 +20,8 @@ export interface SlashCommand {
18
20
  name: string;
19
21
  aliases?: string[];
20
22
  description?: string;
23
+ /** Optional type-indicator glyph shown before the command name in autocomplete */
24
+ icon?: string;
21
25
  argumentHint?: string;
22
26
  /** Whether the command consumes argument text after the command name. False means the full input stays normal prompt text once args are present. */
23
27
  allowArgs?: boolean;
@@ -73,6 +77,11 @@ export interface AutocompleteProvider {
73
77
  shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
74
78
  }
75
79
  type CommandEntry = SlashCommand | AutocompleteItem;
80
+ /** Optional behaviors for {@link CombinedAutocompleteProvider}. */
81
+ export interface CombinedAutocompleteOptions {
82
+ /** Usage count per command name; higher counts rank earlier among equal text-match scores. */
83
+ commandUsage?: (name: string) => number;
84
+ }
76
85
  export declare function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number;
77
86
  /**
78
87
  * Whether a mid-prompt slash token (`prose … /tok`) is skill-shaped enough to
@@ -90,7 +99,7 @@ export declare function scoreCommandTextMatch(lowerPrefix: string, lowerTarget:
90
99
  export declare function midPromptSkillTokenMatches(lowerToken: string, name: string, description?: string): boolean;
91
100
  export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
92
101
  #private;
93
- constructor(commands?: CommandEntry[], basePath?: string);
102
+ constructor(commands?: CommandEntry[], basePath?: string, options?: CombinedAutocompleteOptions);
94
103
  getSuggestions(lines: string[], cursorLine: number, cursorCol: number): Promise<{
95
104
  items: AutocompleteItem[];
96
105
  prefix: string;
@@ -23,6 +23,38 @@ interface HistoryStorage {
23
23
  add(prompt: string, cwd?: string): Promise<void>;
24
24
  getRecent(limit: number): HistoryEntry[];
25
25
  }
26
+ /** A synchronous replacement immediately before the editor cursor. */
27
+ export interface EditorInlineReplacement {
28
+ /** UTF-16 code units to remove immediately before the cursor. */
29
+ replaceLen: number;
30
+ /** Literal text inserted where the removed suffix started. */
31
+ insert: string;
32
+ }
33
+ /** Replacement candidates and the current-line span they replace. */
34
+ export interface EditorWordReplacements {
35
+ line: number;
36
+ startCol: number;
37
+ endCol: number;
38
+ items: readonly string[];
39
+ }
40
+ /** Source location for one visual text segment passed to `decorateText`. */
41
+ export interface EditorTextDecorationContext {
42
+ line: number;
43
+ startCol: number;
44
+ endCol: number;
45
+ }
46
+ /**
47
+ * Optional prose assistance kept separate from command/file autocomplete.
48
+ * Hosts independently decide whether word completion and autocorrection are enabled.
49
+ */
50
+ export interface EditorTextAssistProvider {
51
+ /** Return ghost-text suffix for the partial word at the cursor, or `null`. */
52
+ getWordCompletion?(lines: string[], cursorLine: number, cursorCol: number): string | null;
53
+ /** Return a correction after one single-character insertion, or `null`. */
54
+ tryAutocorrect?(lines: string[], cursorLine: number, cursorCol: number): EditorInlineReplacement | null | Promise<EditorInlineReplacement | null>;
55
+ /** Return replacement candidates for the misspelled word at the cursor. */
56
+ getWordReplacements?(lines: string[], cursorLine: number, cursorCol: number): EditorWordReplacements | null | Promise<EditorWordReplacements | null>;
57
+ }
26
58
  export declare class Editor implements Component, Focusable {
27
59
  #private;
28
60
  /** Focusable interface - set by TUI when focus changes */
@@ -34,9 +66,13 @@ export declare class Editor implements Component, Focusable {
34
66
  /** Optional hook that decorates displayed user text after source-text layout.
35
67
  * Width-changing output is allowed on lines without the cursor; it is truncated
36
68
  * to the content width rather than reflowed. Cursor glyphs and inline hints are excluded. */
37
- decorateText: ((text: string) => string) | undefined;
69
+ decorateText: ((text: string, context: EditorTextDecorationContext) => string) | undefined;
38
70
  borderColor: (str: string) => string;
39
71
  onAutocompleteUpdate?: () => void;
72
+ /** Called after an async text-assist result mutates the document outside an input event, so hosts can schedule a repaint. */
73
+ onTextAssistApplied?: () => void;
74
+ /** Terminal height source for clamping the autocomplete dropdown. Hosts wire this to their Terminal's rows. */
75
+ viewportRowsProvider?: () => number;
40
76
  /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
41
77
  * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
42
78
  * landing on any character of a token removes the whole token instead of corrupting it into
@@ -56,7 +92,10 @@ export declare class Editor implements Component, Focusable {
56
92
  onAutocompleteCancel?: () => void;
57
93
  disableSubmit: boolean;
58
94
  constructor(theme: EditorTheme);
95
+ setTheme(theme: EditorTheme): void;
59
96
  setAutocompleteProvider(provider: AutocompleteProvider): void;
97
+ /** Install prose assistance without changing command/file autocomplete. */
98
+ setTextAssistProvider(provider: EditorTextAssistProvider | undefined): void;
60
99
  /**
61
100
  * Set custom content for the top border (e.g., status line).
62
101
  * Pass undefined to use the default plain border.
@@ -26,6 +26,14 @@ export interface DefaultTextStyle {
26
26
  /** Underline text */
27
27
  underline?: boolean;
28
28
  }
29
+ /**
30
+ * Stateful incremental code highlighter carrying parser state across pushes.
31
+ * Produced per streaming fence by {@link MarkdownTheme.createHighlightStream}.
32
+ */
33
+ export interface HighlightStreamSession {
34
+ /** Highlight the next chunk and advance parser state. */
35
+ push(chunk: string): string;
36
+ }
29
37
  /**
30
38
  * Theme functions for markdown elements.
31
39
  * Each function takes text and returns styled text with ANSI codes.
@@ -46,6 +54,14 @@ export interface MarkdownTheme {
46
54
  strikethrough: (text: string) => string;
47
55
  underline: (text: string) => string;
48
56
  highlightCode?: (code: string, lang?: string) => string[];
57
+ /**
58
+ * Create a stateful incremental highlighter for one streaming code fence.
59
+ * `push` receives newline-terminated complete lines (only the final push
60
+ * may omit the trailing newline) and must return highlighted ANSI text for
61
+ * exactly the pushed chunk, byte-identical to highlighting the concatenated
62
+ * text through `highlightCode`. Return null when `lang` is unsupported.
63
+ */
64
+ createHighlightStream?: (lang?: string) => HighlightStreamSession | null;
49
65
  /**
50
66
  * Resolve a mermaid ASCII rendering by fenced block source text.
51
67
  * Return null to fall back to fenced code rendering.
@@ -58,6 +74,7 @@ export declare class Markdown implements Component, NativeScrollbackCommittedRow
58
74
  setIgnoreTight(ignore: boolean): this;
59
75
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
60
76
  setText(text: string): boolean;
77
+ getNativeScrollbackWidthEpochRevision(): number;
61
78
  invalidate(): void;
62
79
  get transientRenderCache(): boolean;
63
80
  set transientRenderCache(value: boolean);
@@ -5,6 +5,8 @@ export interface SelectItem {
5
5
  value: string;
6
6
  label: string;
7
7
  description?: string;
8
+ /** Optional type-indicator glyph rendered in an aligned column before the label */
9
+ icon?: string;
8
10
  /** Dim hint text shown inline after cursor when this item is selected */
9
11
  hint?: string;
10
12
  }
@@ -15,6 +17,8 @@ export interface SelectListTheme {
15
17
  scrollInfo: (text: string) => string;
16
18
  noMatch: (text: string) => string;
17
19
  symbols: SymbolTheme;
20
+ /** Style for the type-icon column on unselected rows. Defaults to plain text. */
21
+ icon?: (text: string) => string;
18
22
  /** Hover band applied to the full row under the mouse pointer. */
19
23
  hovered?: (text: string) => string;
20
24
  }
@@ -39,6 +43,11 @@ export interface SelectListLayoutOptions {
39
43
  * wrap unevenly.
40
44
  */
41
45
  wrapDescription?: boolean;
46
+ /**
47
+ * Cap wrapped descriptions at this many visual rows; the last kept row is
48
+ * ellipsized. Only meaningful with `wrapDescription`.
49
+ */
50
+ maxDescriptionRows?: number;
42
51
  }
43
52
  export declare class SelectList implements Component, MouseRoutable {
44
53
  #private;
@@ -25,6 +25,7 @@ export interface Keybindings {
25
25
  "tui.editor.yank": true;
26
26
  "tui.editor.yankPop": true;
27
27
  "tui.editor.undo": true;
28
+ "tui.editor.spellingSuggestions": true;
28
29
  "tui.input.newLine": true;
29
30
  "tui.input.submit": true;
30
31
  "tui.input.tab": true;
@@ -129,6 +130,10 @@ export declare const TUI_KEYBINDINGS: {
129
130
  readonly defaultKeys: ["ctrl+-", "ctrl+_"];
130
131
  readonly description: "Undo";
131
132
  };
133
+ readonly "tui.editor.spellingSuggestions": {
134
+ readonly defaultKeys: "ctrl+.";
135
+ readonly description: "Show spelling replacements";
136
+ };
132
137
  readonly "tui.input.newLine": {
133
138
  readonly defaultKeys: ["shift+enter", "ctrl+j"];
134
139
  readonly description: "Insert newline";
@@ -58,10 +58,30 @@ export declare function setAltScreenActive(active: boolean): void;
58
58
  export declare function emergencyTerminalRestore(): void;
59
59
  /** Terminal-reported appearance (dark/light mode). */
60
60
  export type TerminalAppearance = "dark" | "light";
61
+ /** Options for {@link Terminal.start}. */
62
+ export interface TerminalStartOptions {
63
+ /**
64
+ * Paint-only start: skip raw mode, stdin ownership, and every probe that
65
+ * elicits a response on stdin. The host tty keeps cooked-mode line editing
66
+ * (kernel echo lands at the hardware cursor), and typed bytes stay queued
67
+ * in the kernel until {@link Terminal.enableInput} takes ownership and
68
+ * replays them through `onInput`. Used for the startup prepaint so typing
69
+ * echoes even while module loading blocks the event loop.
70
+ */
71
+ deferInput?: boolean;
72
+ }
61
73
  /** Identity of an accepted explicit terminal appearance refresh request. */
62
74
  export type TerminalAppearanceRequestToken = number;
63
75
  export interface Terminal {
64
- start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
76
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void, options?: TerminalStartOptions): void;
77
+ /**
78
+ * Take ownership of stdin after a `deferInput` start: enable raw mode,
79
+ * attach input handlers, and run the capability probes start() skipped.
80
+ * Bytes the user typed in cooked mode meanwhile are replayed through
81
+ * `onInput`. No-op when input was never deferred. Optional so custom
82
+ * Terminals built against older pi-tui versions keep working.
83
+ */
84
+ enableInput?(): void;
65
85
  stop(): void;
66
86
  /**
67
87
  * Drain stdin before exiting to prevent Kitty key release events from
@@ -73,6 +93,14 @@ export interface Terminal {
73
93
  write(data: string): void;
74
94
  get columns(): number;
75
95
  get rows(): number;
96
+ /**
97
+ * Output bytes accepted but not yet delivered to the terminal, when the
98
+ * implementation can report it. The renderer skips composing new frames
99
+ * while this backlog is deep, so a slow terminal receives only fresh
100
+ * frames instead of a queue of stale ones. Optional so custom Terminals
101
+ * built against older pi-tui versions keep working.
102
+ */
103
+ readonly pendingOutputBytes?: number;
76
104
  get kittyProtocolActive(): boolean;
77
105
  get kittyEnableSequence(): string | null;
78
106
  readonly keyboardEnhancementEnterSequence?: string | null;
@@ -158,11 +186,13 @@ export declare class ProcessTerminal implements Terminal {
158
186
  */
159
187
  refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void;
160
188
  onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
161
- start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
189
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void, options?: TerminalStartOptions): void;
190
+ enableInput(): void;
162
191
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
163
192
  stop(): void;
164
193
  write(data: string): void;
165
194
  get columns(): number;
195
+ get pendingOutputBytes(): number;
166
196
  get rows(): number;
167
197
  moveBy(lines: number): void;
168
198
  hideCursor(force?: boolean): void;
@@ -21,6 +21,12 @@ export interface TUIOptions {
21
21
  export interface TUIStartOptions {
22
22
  /** Clear saved native scrollback before the first paint. */
23
23
  clearScrollback?: boolean;
24
+ /**
25
+ * Paint without owning stdin: the terminal stays in cooked mode (kernel
26
+ * echo + line editing at the hardware cursor) until {@link TUI.enableInput}
27
+ * switches to raw input and replays the kernel-buffered keystrokes.
28
+ */
29
+ deferInput?: boolean;
24
30
  }
25
31
  /**
26
32
  * Component interface - all components must implement this
@@ -200,6 +206,26 @@ export interface RenderRequestOptions {
200
206
  /** Clear terminal scrollback for intentional transcript replacement. */
201
207
  clearScrollback?: boolean;
202
208
  }
209
+ /**
210
+ * What a settled in-place width resize (multiplexer pane or an in-place-latched
211
+ * direct terminal) does to native scrollback, which the host rewrapped at the
212
+ * old width:
213
+ * - `append`: replay the transcript at the current width below the old-wrap
214
+ * history — one fresh copy per settled resize, nothing destroyed.
215
+ * - `rebuild`: clear native history first (ED3) and replay — history holds the
216
+ * transcript exactly once at the current width. Requires a host that honors
217
+ * an inner ED3 (tmux does; GNU screen ignores it, degrading to `append`),
218
+ * and erases pre-session pane history.
219
+ * - `preserve`: repaint the viewport only — zero history growth; scrollback
220
+ * keeps the old-width wrap until content next scrolls off.
221
+ *
222
+ * The raw engine defaults to `preserve` (append-only native scrollback, the
223
+ * engine's baseline contract); `PI_TUI_RESIZE_SCROLLBACK` overrides that
224
+ * initial value. The coding agent applies its `tui.resizeScrollback` setting
225
+ * (default `append`) on top at startup, so interactive sessions refresh
226
+ * stale-width history out of the box.
227
+ */
228
+ export type ResizeScrollbackMode = "rebuild" | "append" | "preserve";
203
229
  /** Type guard to check if a component implements Focusable */
204
230
  export declare function isFocusable(component: Component | null): component is Component & Focusable;
205
231
  /**
@@ -407,6 +433,16 @@ export declare class TUI extends Container {
407
433
  * duplicate blocks when a block's final form replaces its live preview.
408
434
  */
409
435
  setScrollbackRebuild(enabled: boolean): void;
436
+ /**
437
+ * Get how a settled in-place width resize refreshes native scrollback.
438
+ */
439
+ getResizeScrollback(): ResizeScrollbackMode;
440
+ /**
441
+ * Set how a settled in-place width resize refreshes native scrollback
442
+ * (see {@link ResizeScrollbackMode}; engine default `preserve` — the coding
443
+ * agent applies its `tui.resizeScrollback` setting, default `append`).
444
+ */
445
+ setResizeScrollback(mode: ResizeScrollbackMode): void;
410
446
  getShowHardwareCursor(): boolean;
411
447
  setShowHardwareCursor(enabled: boolean): void;
412
448
  /**
@@ -430,6 +466,13 @@ export declare class TUI extends Container {
430
466
  hasOverlay(): boolean;
431
467
  invalidate(): void;
432
468
  start(options?: TUIStartOptions): void;
469
+ /**
470
+ * Take ownership of stdin after a `deferInput` start: raw mode, input
471
+ * handlers, and the response-eliciting capability probes start() skipped.
472
+ * Keystrokes typed in cooked mode meanwhile arrive through the normal input
473
+ * path. Idempotent; no-op when input was never deferred.
474
+ */
475
+ enableInput(): void;
433
476
  addStartListener(listener: StartListener): () => void;
434
477
  addInputListener(listener: InputListener): () => void;
435
478
  removeInputListener(listener: InputListener): void;
@@ -42,8 +42,9 @@ export declare function getSegmenter(): Intl.Segmenter;
42
42
  * Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
43
43
  *
44
44
  * `Bun.stringWidth` does the heavy lifting (UAX#11 width tables + ANSI/OSC
45
- * stripping); this adds the two corrections it omits — tabs (expanded to
46
- * `tabWidth` cells) and OSC 66 text-sizing payloads (scaled by `s=`).
45
+ * stripping); this adds the corrections it omits — tabs (expanded to
46
+ * `tabWidth` cells), OSC 66 text-sizing payloads (scaled by `s=`), and APC
47
+ * sequences (counted as printable by Bun, actually zero cells).
47
48
  */
48
49
  export declare function visibleWidth(str: string): number;
49
50
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.4.2",
4
+ "version": "18.0.0",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,11 +37,11 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "17.4.2",
41
- "@oh-my-pi/pi-utils": "17.4.2"
40
+ "@oh-my-pi/pi-natives": "18.0.0",
41
+ "@oh-my-pi/pi-utils": "18.0.0"
42
42
  },
43
43
  "devDependencies": {
44
- "ghostty-web": "^0.4.0"
44
+ "kitty-vt-wasm": "^0.2.0"
45
45
  },
46
46
  "engines": {
47
47
  "bun": ">=1.3.14"
@@ -171,6 +171,8 @@ export interface AutocompleteItem {
171
171
  value: string;
172
172
  label: string;
173
173
  description?: string;
174
+ /** Optional type-indicator glyph rendered in an aligned column before the label */
175
+ icon?: string;
174
176
  /** Dim hint text shown inline after cursor when this item is selected */
175
177
  hint?: string;
176
178
  }
@@ -181,6 +183,8 @@ export interface SlashCommand {
181
183
  name: string;
182
184
  aliases?: string[];
183
185
  description?: string;
186
+ /** Optional type-indicator glyph shown before the command name in autocomplete */
187
+ icon?: string;
184
188
  argumentHint?: string;
185
189
  /** Whether the command consumes argument text after the command name. False means the full input stays normal prompt text once args are present. */
186
190
  allowArgs?: boolean;
@@ -249,6 +253,11 @@ export interface AutocompleteProvider {
249
253
  }
250
254
 
251
255
  type CommandEntry = SlashCommand | AutocompleteItem;
256
+ /** Optional behaviors for {@link CombinedAutocompleteProvider}. */
257
+ export interface CombinedAutocompleteOptions {
258
+ /** Usage count per command name; higher counts rank earlier among equal text-match scores. */
259
+ commandUsage?: (name: string) => number;
260
+ }
252
261
 
253
262
  function getCommandName(cmd: CommandEntry): string | undefined {
254
263
  return "name" in cmd ? cmd.name : cmd.value;
@@ -287,64 +296,79 @@ export function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string):
287
296
  return fuzzyMatch(lowerPrefix, lowerTarget) ? fuzzyScore(lowerPrefix, lowerTarget) : 0;
288
297
  }
289
298
 
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 }),
299
+ function buildSlashCommandCompletions(
300
+ commands: CommandEntry[],
301
+ lowerPrefix: string,
302
+ commandUsage?: (name: string) => number,
303
+ ): AutocompleteItem[] {
304
+ return (
305
+ commands
306
+ .flatMap(cmd => {
307
+ const name = getCommandName(cmd);
308
+ if (!name) return [];
309
+ const usage = commandUsage?.(name) ?? 0;
310
+ const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
311
+ const staticDesc = getStaticCommandDescription(cmd);
312
+ let fullDescMemo: string | undefined;
313
+ let fullDescComputed = false;
314
+ // Resolve the (possibly live) display description lazily, only once a
315
+ // candidate actually matches — getAutocompleteDescription reads live
316
+ // session state and must not run for every command on each keystroke.
317
+ const resolveFullDesc = (): string | undefined => {
318
+ if (!fullDescComputed) {
319
+ const displayDesc = getAutocompleteCommandDescription(cmd);
320
+ fullDescMemo = hint ? (displayDesc ? `${hint} - ${displayDesc}` : hint) : displayDesc;
321
+ fullDescComputed = true;
322
+ }
323
+ return fullDescMemo;
326
324
  };
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;
325
+ let best: (AutocompleteItem & { score: number; usage: number }) | undefined;
326
+
327
+ const isSkillCommand = name.startsWith("skill:");
328
+ const nameScore =
329
+ lowerPrefix.length === 0 && isSkillCommand
330
+ ? 950
331
+ : scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
332
+ const lowerDesc = staticDesc.toLowerCase();
333
+ const descScore =
334
+ lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
335
+ const primaryScore = Math.max(nameScore, descScore);
336
+ if (primaryScore > 0) {
334
337
  const fullDesc = resolveFullDesc();
335
338
  best = {
336
- value: alias,
337
- label: alias,
338
- score: aliasScore,
339
+ value: name,
340
+ label: "name" in cmd ? cmd.name : cmd.label,
341
+ score: primaryScore,
342
+ usage,
343
+ ...(cmd.icon && { icon: cmd.icon }),
339
344
  ...(fullDesc && { description: fullDesc }),
340
345
  };
341
346
  }
342
- }
343
347
 
344
- return best ? [best] : [];
345
- })
346
- .sort((a, b) => b.score - a.score)
347
- .map(({ score: _, ...rest }) => rest);
348
+ if (lowerPrefix.length > 0) {
349
+ for (const alias of getCommandAliases(cmd)) {
350
+ if (alias === name) continue;
351
+ const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
352
+ if (aliasScore === 0 || (best && aliasScore <= best.score)) continue;
353
+ const fullDesc = resolveFullDesc();
354
+ best = {
355
+ value: alias,
356
+ label: alias,
357
+ score: aliasScore,
358
+ usage,
359
+ ...(cmd.icon && { icon: cmd.icon }),
360
+ ...(fullDesc && { description: fullDesc }),
361
+ };
362
+ }
363
+ }
364
+
365
+ return best ? [best] : [];
366
+ })
367
+ // Equal text-match scores fall back to usage frequency, then to the
368
+ // stable registry order.
369
+ .sort((a, b) => b.score - a.score || b.usage - a.usage)
370
+ .map(({ score: _, usage: _usage, ...rest }) => rest)
371
+ );
348
372
  }
349
373
 
350
374
  function hasPromptTextBeforeSlash(
@@ -401,15 +425,21 @@ function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: s
401
425
  export class CombinedAutocompleteProvider implements AutocompleteProvider {
402
426
  #commands: CommandEntry[];
403
427
  #basePath: string;
428
+ #commandUsage?: (name: string) => number;
404
429
  // Intentionally separate from pi-natives cache: this cache is a local,
405
430
  // per-directory readdir fast-path for prefix completions. Global fuzzy
406
431
  // discovery continues to use native fuzzyFind + shared scan cache.
407
432
  #dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
408
433
  readonly #DIR_CACHE_TTL = 2000; // 2 seconds
409
434
 
410
- constructor(commands: CommandEntry[] = [], basePath: string = getProjectDir()) {
435
+ constructor(
436
+ commands: CommandEntry[] = [],
437
+ basePath: string = getProjectDir(),
438
+ options?: CombinedAutocompleteOptions,
439
+ ) {
411
440
  this.#commands = commands;
412
441
  this.#basePath = basePath;
442
+ this.#commandUsage = options?.commandUsage;
413
443
  }
414
444
 
415
445
  async getSuggestions(
@@ -444,7 +474,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
444
474
 
445
475
  const matches = isMidPromptSkillLookup
446
476
  ? buildMidPromptSkillCompletions(this.#commands, lowerPrefix)
447
- : buildSlashCommandCompletions(this.#commands, lowerPrefix);
477
+ : buildSlashCommandCompletions(this.#commands, lowerPrefix, this.#commandUsage);
448
478
 
449
479
  if (matches.length > 0) {
450
480
  return {
@@ -1069,7 +1099,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1069
1099
  const prefix = commandText.slice(1);
1070
1100
  const lowerPrefix = prefix.toLowerCase();
1071
1101
 
1072
- const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix);
1102
+ const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix, this.#commandUsage);
1073
1103
 
1074
1104
  if (matches.length === 0) return null;
1075
1105
  // Mirror `getSuggestions`: preserve leading whitespace so the editor's