@prometheus-ai/tui 0.5.4 → 0.5.8

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 (55) hide show
  1. package/dist/types/autocomplete.d.ts +3 -1
  2. package/dist/types/components/box.d.ts +1 -1
  3. package/dist/types/components/editor.d.ts +35 -2
  4. package/dist/types/components/image.d.ts +22 -3
  5. package/dist/types/components/input.d.ts +6 -1
  6. package/dist/types/components/loader.d.ts +9 -2
  7. package/dist/types/components/markdown.d.ts +3 -1
  8. package/dist/types/components/scroll-view.d.ts +23 -1
  9. package/dist/types/components/select-list.d.ts +19 -1
  10. package/dist/types/components/settings-list.d.ts +87 -7
  11. package/dist/types/components/spacer.d.ts +1 -1
  12. package/dist/types/components/tab-bar.d.ts +37 -4
  13. package/dist/types/components/text.d.ts +2 -2
  14. package/dist/types/components/truncated-text.d.ts +1 -1
  15. package/dist/types/fuzzy.d.ts +10 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/keybindings.d.ts +5 -3
  18. package/dist/types/keys.d.ts +1 -1
  19. package/dist/types/kill-ring.d.ts +0 -7
  20. package/dist/types/kitty-graphics.d.ts +16 -31
  21. package/dist/types/loop-watchdog.d.ts +39 -0
  22. package/dist/types/mouse.d.ts +41 -0
  23. package/dist/types/stdin-buffer.d.ts +17 -0
  24. package/dist/types/terminal-capabilities.d.ts +74 -18
  25. package/dist/types/terminal.d.ts +34 -36
  26. package/dist/types/tui.d.ts +191 -79
  27. package/dist/types/utils.d.ts +5 -2
  28. package/package.json +4 -4
  29. package/src/autocomplete.ts +79 -65
  30. package/src/components/box.ts +43 -63
  31. package/src/components/editor.ts +471 -136
  32. package/src/components/image.ts +85 -9
  33. package/src/components/input.ts +12 -3
  34. package/src/components/loader.ts +35 -21
  35. package/src/components/markdown.ts +174 -53
  36. package/src/components/scroll-view.ts +63 -2
  37. package/src/components/select-list.ts +233 -38
  38. package/src/components/settings-list.ts +626 -64
  39. package/src/components/spacer.ts +9 -5
  40. package/src/components/tab-bar.ts +153 -28
  41. package/src/components/text.ts +6 -2
  42. package/src/components/truncated-text.ts +10 -2
  43. package/src/fuzzy.ts +214 -59
  44. package/src/index.ts +3 -1
  45. package/src/keybindings.ts +72 -14
  46. package/src/keys.ts +1 -1
  47. package/src/kill-ring.ts +5 -0
  48. package/src/kitty-graphics.ts +2 -101
  49. package/src/loop-watchdog.ts +106 -0
  50. package/src/mouse.ts +55 -0
  51. package/src/stdin-buffer.ts +291 -81
  52. package/src/terminal-capabilities.ts +206 -168
  53. package/src/terminal.ts +367 -110
  54. package/src/tui.ts +2102 -1729
  55. package/src/utils.ts +92 -60
@@ -1,11 +1,12 @@
1
1
  import { ImageBudget } from "./components/image";
2
- import type { Terminal } from "./terminal";
2
+ import { type Terminal } from "./terminal";
3
3
  import { visibleWidth } from "./utils";
4
4
  type InputListenerResult = {
5
5
  consume?: boolean;
6
6
  data?: string;
7
7
  } | undefined;
8
8
  type InputListener = (data: string) => InputListenerResult;
9
+ type StartListener = () => void;
9
10
  export interface RenderTimer {
10
11
  cancel(): void;
11
12
  }
@@ -17,16 +18,32 @@ export interface RenderScheduler {
17
18
  export interface TUIOptions {
18
19
  renderScheduler?: RenderScheduler;
19
20
  }
21
+ export interface TUIStartOptions {
22
+ /** Clear saved native scrollback before the first paint. */
23
+ clearScrollback?: boolean;
24
+ }
20
25
  /**
21
26
  * Component interface - all components must implement this
27
+ *
28
+ * Render contract: the returned array (and its rows) belongs to the component.
29
+ * Callers MUST NOT mutate it — components are allowed to return a cached array
30
+ * and will return the exact same reference for as long as their rendered
31
+ * content is unchanged. Conversely, a component MUST return a fresh array
32
+ * reference whenever its content changed; reference equality across two
33
+ * render() calls is the engine's proof that the rows are byte-identical
34
+ * (containers memoize their concatenation on it, and the TUI derives the
35
+ * frame's stable prefix from it). A component that mutates a previously
36
+ * returned array in place must implement {@link RenderStablePrefix} to declare
37
+ * which leading rows survived.
22
38
  */
23
39
  export interface Component {
24
40
  /**
25
- * Render the component to lines for the given viewport width
26
- * @param width - Current viewport width
27
- * @returns Array of strings, each representing a line
41
+ * Render the component to an array of physical rows at the given width.
42
+ * The result is component-owned and `readonly` to the caller; an unchanged
43
+ * component may (and should) return the same array reference it returned
44
+ * last time.
28
45
  */
29
- render(width: number): string[];
46
+ render(width: number): readonly string[];
30
47
  /**
31
48
  * Optional handler for keyboard input when component has focus
32
49
  */
@@ -37,32 +54,108 @@ export interface Component {
37
54
  */
38
55
  wantsKeyRelease?: boolean;
39
56
  /**
40
- * Invalidate any cached rendering state.
57
+ * Optional hook to invalidate any cached rendering state.
41
58
  * Called when theme changes or when component needs to re-render from scratch.
42
59
  */
43
- invalidate(): void;
60
+ invalidate?(): void;
61
+ /**
62
+ * Optional teardown. Called when the component is permanently removed from
63
+ * the live tree (e.g. a transcript reset). Release timers, intervals, and
64
+ * subscriptions here. Must be idempotent. Containers propagate dispose to
65
+ * their children; leaf components without resources may omit it.
66
+ */
67
+ dispose?(): void;
44
68
  }
45
69
  /**
46
- * Optional component seam for native-scrollback pinning. A component that
47
- * renders a stable prefix followed by a live/transient suffix reports the local
48
- * line index where that suffix begins after each render. TUI treats that suffix
49
- * and every root child rendered below it — as not yet safe to commit to native
50
- * scrollback on ED3-risk terminals whose viewport position is unobservable.
70
+ * Component seam for append-only native-scrollback commits. A component that
71
+ * renders a finalized prefix followed by a live/mutating suffix reports the
72
+ * local line index where that suffix begins after each render. The engine
73
+ * commits rows to native scrollback only up to that boundary; everything
74
+ * below repaints in place inside the visible window and never enters history
75
+ * until it finalizes.
51
76
  *
52
77
  * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
53
- * inside that live suffix: the line index up to which the live region is
54
- * append-only (its earlier rows never re-layout, only new rows append at the
55
- * bottom a streaming assistant message). Rows in `[liveRegionStart,
56
- * commitSafeEnd)` that scroll above the viewport are safe to commit to native
57
- * scrollback even though they are technically live, because they will never
58
- * change. Without this, a single live block that alone overflows the viewport
59
- * loses its scrolled-off head (committed nowhere, repainted nowhere). Volatile
60
- * live blocks (tool previews that collapse) omit it, so their mutable rows stay
61
- * deferred. Defaults to `liveRegionStart` when absent.
78
+ * inside the live suffix: the line index up to which the live region is
79
+ * append-only (earlier rows never re-layout a streaming assistant message).
80
+ * Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
81
+ * technically live, because they will never change. Without it, a single live
82
+ * block that alone overflows the window would hold its scrolled-off head out
83
+ * of history until it finalizes. Volatile live blocks (tool previews that
84
+ * collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
85
+ * reports no seam at all commits everything that scrolls (shell semantics).
86
+ * `getNativeScrollbackSnapshotSafeEnd` optionally reports a still deeper
87
+ * boundary: the line index up to which the live region is *durable* — its rows
88
+ * may still change bytes later (a streaming markdown table re-aligning its
89
+ * columns every row), but their CURRENT snapshot is permanent content, so
90
+ * dropping them when they scroll above the window is forbidden. Unlike
91
+ * `commitSafeEnd` (byte-stable: offered rows are asserted never to re-layout and
92
+ * stay under the committed-prefix audit), rows committed under the snapshot end
93
+ * are audit-EXEMPT once they pass the window top — the engine appends their
94
+ * scroll-off snapshot and never recommits them, so later layout drift becomes a
95
+ * frozen stale row in history (duplication never loss) instead of either a
96
+ * dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
97
+ * tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
98
+ * `commitSafeEnd ?? liveRegionStart` when absent.
99
+ *
100
+ * When several root children report a seam in the same frame, the topmost
101
+ * one (and its commit-safe / snapshot-safe extension) defines the boundary:
102
+ * commits are prefix-only, so everything below the first seam is already
103
+ * excluded.
62
104
  */
63
105
  export interface NativeScrollbackLiveRegion {
64
106
  getNativeScrollbackLiveRegionStart(): number | undefined;
65
107
  getNativeScrollbackCommitSafeEnd?(): number | undefined;
108
+ getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
109
+ }
110
+ export interface NativeScrollbackCommittedRows {
111
+ setNativeScrollbackCommittedRows(rows: number): void;
112
+ }
113
+ /**
114
+ * Opt-in stability report for components that mutate their returned render
115
+ * array in place across frames (instead of returning a fresh array per
116
+ * change). The engine reads it right after the component's `render()` returns:
117
+ * the report counts the leading rows of the just-returned array that are
118
+ * byte-identical to the array state the reader last observed. The engine uses
119
+ * it to reuse the composed frame's prefix — skipping marker extraction, line
120
+ * preparation, and the committed-prefix audit for those rows.
121
+ *
122
+ * Contract:
123
+ * - Reading CONSUMES the report: it re-bases the baseline to the current
124
+ * array state. The accumulated count therefore covers every render since
125
+ * the previous read, so out-of-band `render()` calls between engine frames
126
+ * (an exporter walking the tree) can only lower the report, never inflate
127
+ * it past what the engine actually has.
128
+ * - An implementer that cannot prove stability for a frame must lower the
129
+ * accumulated count to 0 for that render.
130
+ * - Rows at or beyond the report may have been mutated in place; rows before
131
+ * it must be the identical string values at the identical indices.
132
+ */
133
+ export interface RenderStablePrefix {
134
+ getRenderStablePrefixRows(): number;
135
+ }
136
+ /**
137
+ * Opt-in fast path for composing only the visible tail of a tall component
138
+ * during a terminal resize. A drag emits a SIGWINCH burst, and the width
139
+ * changes on every event: a full compose re-lays-out (and, for markdown,
140
+ * re-lexes) the entire transcript per event — O(history) work that is
141
+ * discarded the instant the next event arrives. While the resize is in flight
142
+ * the engine paints only the viewport, so it asks each tall root child for at
143
+ * most `maxRows` rows from the bottom of its render at `width` and skips
144
+ * composing everything above the fold. The authoritative full paint replays
145
+ * once the drag settles (see {@link TUI} resize handling).
146
+ *
147
+ * Contract:
148
+ * - Returns the BOTTOM rows of the component's full render at `width`, in
149
+ * top-to-bottom order, capped at `maxRows` (fewer when the component is
150
+ * shorter). The rows MUST be byte-identical to the corresponding tail of
151
+ * what `render(width)` would have returned, modulo a one-row separator at
152
+ * the very top edge (a transient frame the settle paint overwrites).
153
+ * - MUST NOT mutate any persistent full-compose state: the next `render()`
154
+ * (the settle paint) has to reconcile exactly as if the tail render never
155
+ * happened. Warming pure per-width render caches is fine and desirable.
156
+ */
157
+ export interface ViewportTailProvider {
158
+ renderViewportTail(width: number, maxRows: number): readonly string[];
66
159
  }
67
160
  /**
68
161
  * Interface for components that can receive focus and display a cursor.
@@ -85,22 +178,6 @@ export interface Focusable {
85
178
  export interface RenderRequestOptions {
86
179
  /** Clear terminal scrollback for intentional transcript replacement. */
87
180
  clearScrollback?: boolean;
88
- /**
89
- * Bypass the unknown-Windows-viewport deferral for this render so the
90
- * caller's intentional live UI mutation reaches the terminal even when
91
- * `Terminal#isNativeViewportAtBottom()` cannot answer.
92
- *
93
- * Use only for renders driven by direct user interaction (autocomplete
94
- * updates, IME, etc.). Any background/offscreen transcript change that
95
- * coalesces into the same frame WILL also bypass the deferral and reach
96
- * native scrollback — that is the trade-off, and the reason ordinary
97
- * `requestRender()` calls must continue to omit this flag.
98
- */
99
- allowUnknownViewportMutation?: boolean;
100
- }
101
- /** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
102
- export interface NativeScrollbackRefreshOptions {
103
- allowUnknownViewport?: boolean;
104
181
  }
105
182
  /** Type guard to check if a component implements Focusable */
106
183
  export declare function isFocusable(component: Component | null): component is Component & Focusable;
@@ -156,6 +233,15 @@ export interface OverlayOptions {
156
233
  * Called each render cycle with current terminal dimensions.
157
234
  */
158
235
  visible?: (termWidth: number, termHeight: number) => boolean;
236
+ /**
237
+ * Borrow the terminal's alternate screen buffer for this overlay's lifetime
238
+ * (vim/less idiom). While the topmost visible overlay sets this, the engine
239
+ * paints only the modal on the alt screen and emits no ED3 / scrollback
240
+ * bytes, so the transcript on the normal screen stays untouched and is not
241
+ * scrollable behind the modal. Defaults off — all other overlays are
242
+ * unchanged and still draw over the transcript on the normal screen.
243
+ */
244
+ fullscreen?: boolean;
159
245
  }
160
246
  /**
161
247
  * Handle returned by showOverlay for controlling the overlay
@@ -172,13 +258,40 @@ export interface OverlayHandle {
172
258
  * Container - a component that contains other components
173
259
  */
174
260
  export declare class Container implements Component {
261
+ #private;
175
262
  children: Component[];
176
263
  addChild(component: Component): void;
177
264
  removeChild(component: Component): void;
178
265
  clear(): void;
179
266
  invalidate(): void;
180
- render(width: number): string[];
267
+ /**
268
+ * Propagate teardown to children. Call when the container's children are
269
+ * being permanently discarded (not when they are detached for reuse — use
270
+ * {@link clear} for that). Idempotent per child via each child's own dispose.
271
+ */
272
+ dispose(): void;
273
+ render(width: number): readonly string[];
181
274
  }
275
+ /**
276
+ * Decide whether `frame` still aligns with the committed prefix, and where to
277
+ * re-anchor the commit index when it does not. Returns the resync row index,
278
+ * or -1 when no resync is needed.
279
+ *
280
+ * The detector exploits the asymmetry between the two mutation classes: an
281
+ * in-place edit or restyle of committed rows disturbs only the touched rows
282
+ * (alignment below them is intact — the stale copy in history is the
283
+ * long-accepted artifact), while any insertion or deletion shifts EVERY row
284
+ * below it, including the rows just above the commit boundary. So the prefix
285
+ * *tail* is sampled (up to 8 non-blank rows within the last 24, compared
286
+ * SGR-stripped so theme changes stay quiet, tolerating one mismatch for a
287
+ * legitimate single-row edit): aligned ⇒ no resync; misaligned ⇒ resync at
288
+ * the first non-equivalent row, recommitting from there — duplication, never
289
+ * loss. Highly repetitive tails (identical filler rows) can mask a shift, in
290
+ * which case the skipped rows are content-identical to the committed ones —
291
+ * observationally harmless. Exported for the render-stress harness, whose
292
+ * shadow commit ledger must mirror the engine's law exactly.
293
+ */
294
+ export declare function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[], auditLimit?: number): number;
182
295
  /**
183
296
  * TUI - Main class for managing terminal UI with differential rendering
184
297
  */
@@ -194,8 +307,16 @@ export declare class TUI extends Container {
194
307
  hidden: boolean;
195
308
  }[];
196
309
  constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions);
197
- render(width: number): string[];
310
+ render(width: number): readonly string[];
198
311
  get fullRedraws(): number;
312
+ /**
313
+ * Transient viewport-only paints emitted by the non-multiplexer resize fast
314
+ * path. These never touch native scrollback or the commit ledger, so they
315
+ * are counted apart from {@link fullRedraws}.
316
+ */
317
+ get resizeViewportPaints(): number;
318
+ /** Whether a non-multiplexer resize drag is currently in flight. */
319
+ get resizeViewportActive(): boolean;
199
320
  /** Shared budget that caps how many inline images render as live graphics. */
200
321
  get imageBudget(): ImageBudget;
201
322
  /**
@@ -206,44 +327,16 @@ export declare class TUI extends Container {
206
327
  setMaxInlineImages(cap: number): void;
207
328
  getShowHardwareCursor(): boolean;
208
329
  setShowHardwareCursor(enabled: boolean): void;
209
- getClearOnShrink(): boolean;
210
- /**
211
- * Set whether to trigger full re-render when content shrinks.
212
- * When true (default), empty rows are cleared when content shrinks.
213
- * When false, empty rows remain (reduces redraws on slower terminals).
214
- */
215
- setClearOnShrink(enabled: boolean): void;
216
330
  /**
217
331
  * Whether DEC 2026 synchronized-output wrappers are currently emitted around
218
- * paints. Starts from conservative terminal/env detection and is force-disabled
219
- * at runtime if the terminal reports mode 2026 unsupported via DECRQM.
332
+ * paints. Starts from conservative terminal/env detection and is reconciled at
333
+ * runtime against the terminal's DECRQM mode-2026 report enabled on a
334
+ * positive report, disabled on a negative one.
220
335
  */
221
336
  get synchronizedOutput(): boolean;
222
- /**
223
- * When enabled, live render frames rebuild native scrollback on offscreen and
224
- * structural changes even when the viewport position is unobservable (POSIX,
225
- * where `isNativeViewportAtBottom()` is `undefined`), instead of deferring to a
226
- * non-destructive repaint. This trades the anti-yank guarantee for a clean,
227
- * duplicate-free history and is meant for windows where output above the fold
228
- * is actively re-rendering — e.g. a tool whose result is still streaming and
229
- * re-laying-out rows that have already scrolled into history. A terminal that
230
- * reports a *known*-scrolled viewport still defers, as does native Windows
231
- * (the viewport is never observable there and ConPTY hosts erase host
232
- * scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
233
- * rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
234
- * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
235
- * rebuilds are unaffected.
236
- *
237
- * Disabling stays active through one already-requested frame: the event batch
238
- * that ends a foreground stream both removes its UI rows (loader/status
239
- * teardown — a shrink) and clears this flag before the throttled render timer
240
- * fires. If the flag dropped immediately, that teardown frame would hit the
241
- * ED3-risk idle deferral and freeze on screen (stale spinner) until the next
242
- * keystroke. When no render is pending, disable immediately so a later
243
- * unrelated content mutation does not inherit foreground-stream privileges.
244
- */
245
- setEagerNativeScrollbackRebuild(enabled: boolean): void;
246
337
  setFocus(component: Component | null): void;
338
+ /** Component currently receiving keyboard input, if any. */
339
+ getFocused(): Component | null;
247
340
  /**
248
341
  * Show an overlay component with configurable positioning and sizing.
249
342
  * Returns a handle to control the overlay's visibility.
@@ -254,22 +347,41 @@ export declare class TUI extends Container {
254
347
  /** Check if there are any visible overlays */
255
348
  hasOverlay(): boolean;
256
349
  invalidate(): void;
257
- start(): void;
350
+ start(options?: TUIStartOptions): void;
351
+ addStartListener(listener: StartListener): () => void;
258
352
  addInputListener(listener: InputListener): () => void;
259
353
  removeInputListener(listener: InputListener): void;
260
354
  stop(): void;
261
- /**
262
- * Rebuild native terminal scrollback if live rendering deferred a history rewrite.
263
- * Callers should only invoke this at checkpoints where the user is expected to be
264
- * at the terminal bottom, such as after submitting a new prompt.
265
- */
266
- refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean;
267
355
  /**
268
356
  * Force an immediate full replay of the current frame, including native
269
357
  * scrollback. This is the keyboard-accessible equivalent of the resize reset:
270
358
  * no queued diff frame or terminal scrollback probe can downgrade it to a
271
359
  * viewport-only repaint.
360
+ *
361
+ * Invalidates every component first so the replay reflects current state. A
362
+ * geometry-driven reset thaws frozen scrollback snapshots implicitly (the new
363
+ * width misses every cached snapshot), but a same-width reset would otherwise
364
+ * replay stale snapshots — leaving host-frozen blocks (e.g. a transcript whose
365
+ * committed rows are immutable on ED3-risk terminals) showing pre-mutation
366
+ * content. Invalidation is the generic signal those containers use to retire
367
+ * their snapshots, which is exactly what a user-driven display reset wants.
272
368
  */
273
369
  resetDisplay(): void;
274
370
  requestRender(force?: boolean, options?: RenderRequestOptions): void;
371
+ /**
372
+ * Schedule a render on behalf of `component` after a self-contained change
373
+ * (spinner frame, blink) that cannot have affected any other component.
374
+ *
375
+ * When every request since the last frame is component-scoped and the
376
+ * frame is otherwise quiet — no resize or geometry change, no overlays, no
377
+ * live inline images, no forced repaint, unchanged root child list — the
378
+ * next compose re-renders only the root subtrees containing the requesting
379
+ * components and reuses the previous frame's rows (and seam reports) for
380
+ * every other root child, skipping the full component-tree walk that makes
381
+ * long transcripts expensive to repaint at animation rate. Any concurrent
382
+ * full request or unsafe condition downgrades the frame to a normal full
383
+ * compose, so this is never less correct than `requestRender()` — only
384
+ * cheaper.
385
+ */
386
+ requestComponentRender(component: Component): void;
275
387
  }
@@ -33,9 +33,12 @@ export declare function padding(n: number): string;
33
33
  * Get the shared grapheme segmenter instance.
34
34
  */
35
35
  export declare function getSegmenter(): Intl.Segmenter;
36
- export declare function visibleWidthRaw(str: string): number;
37
36
  /**
38
- * Calculate the visible width of a string in terminal columns.
37
+ * Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
38
+ *
39
+ * `Bun.stringWidth` does the heavy lifting (UAX#11 width tables + ANSI/OSC
40
+ * stripping); this adds the two corrections it omits — tabs (expanded to
41
+ * `tabWidth` cells) and OSC 66 text-sizing payloads (scaled by `s=`).
39
42
  */
40
43
  export declare function visibleWidth(str: string): number;
41
44
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@prometheus-ai/tui",
4
- "version": "0.5.4",
4
+ "version": "0.5.8",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://prometheus.trivlab.com",
7
7
  "author": "Uttam Trivedi",
@@ -34,10 +34,10 @@
34
34
  "fmt": "biome format --write ."
35
35
  },
36
36
  "dependencies": {
37
- "@prometheus-ai/natives": "0.5.4",
38
- "@prometheus-ai/utils": "0.5.4",
37
+ "@prometheus-ai/natives": "0.5.8",
38
+ "@prometheus-ai/utils": "0.5.8",
39
39
  "lru-cache": "11.5.1",
40
- "marked": "^18.0.4"
40
+ "marked": "^18.0.5"
41
41
  },
42
42
  "devDependencies": {
43
43
  "chalk": "^5.6.2",
@@ -160,6 +160,7 @@ type Awaitable<T> = T | Promise<T>;
160
160
 
161
161
  export interface SlashCommand {
162
162
  name: string;
163
+ aliases?: string[];
163
164
  description?: string;
164
165
  argumentHint?: string;
165
166
  // Function to get argument completions for this command
@@ -210,9 +211,81 @@ export interface AutocompleteProvider {
210
211
  trySyncInlineReplace?(textBeforeCursor: string): { replaceLen: number; insert: string } | null;
211
212
  }
212
213
 
214
+ type CommandEntry = SlashCommand | AutocompleteItem;
215
+
216
+ function getCommandName(cmd: CommandEntry): string | undefined {
217
+ return "name" in cmd ? cmd.name : cmd.value;
218
+ }
219
+
220
+ function getCommandAliases(cmd: CommandEntry): string[] {
221
+ if (!("aliases" in cmd) || !Array.isArray(cmd.aliases)) return [];
222
+ return cmd.aliases.filter(alias => typeof alias === "string" && alias.length > 0);
223
+ }
224
+
225
+ function commandMatchesNameOrAlias(cmd: CommandEntry, commandName: string): boolean {
226
+ const name = getCommandName(cmd);
227
+ if (name === commandName) return true;
228
+ return getCommandAliases(cmd).includes(commandName);
229
+ }
230
+
231
+ function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number {
232
+ if (lowerPrefix.length === 0) return 1;
233
+ if (lowerPrefix === lowerTarget) return 1000;
234
+ // Flat score for every prefix match so same-prefix commands keep registry
235
+ // order under the stable sort. A length penalty here would rank the shorter
236
+ // name first (e.g. `/set` → `setup` above `settings`), silently changing the
237
+ // command that the sync-completion path applies on Enter.
238
+ if (lowerTarget.startsWith(lowerPrefix)) return 900;
239
+ return fuzzyMatch(lowerPrefix, lowerTarget) ? fuzzyScore(lowerPrefix, lowerTarget) : 0;
240
+ }
241
+
242
+ function buildSlashCommandCompletions(commands: CommandEntry[], lowerPrefix: string): AutocompleteItem[] {
243
+ return commands
244
+ .flatMap(cmd => {
245
+ const name = getCommandName(cmd);
246
+ if (!name) return [];
247
+ const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
248
+ const desc = cmd.description ?? "";
249
+ const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
250
+ const candidates: Array<AutocompleteItem & { score: number }> = [];
251
+
252
+ const nameScore = scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
253
+ const lowerDesc = desc.toLowerCase();
254
+ const descScore =
255
+ lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
256
+ const primaryScore = Math.max(nameScore, descScore);
257
+ if (primaryScore > 0) {
258
+ candidates.push({
259
+ value: name,
260
+ label: "name" in cmd ? cmd.name : cmd.label,
261
+ score: primaryScore,
262
+ ...(fullDesc && { description: fullDesc }),
263
+ });
264
+ }
265
+
266
+ if (lowerPrefix.length > 0) {
267
+ for (const alias of getCommandAliases(cmd)) {
268
+ if (alias === name) continue;
269
+ const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
270
+ if (aliasScore === 0) continue;
271
+ candidates.push({
272
+ value: alias,
273
+ label: alias,
274
+ score: aliasScore,
275
+ ...(fullDesc && { description: fullDesc }),
276
+ });
277
+ }
278
+ }
279
+
280
+ return candidates;
281
+ })
282
+ .sort((a, b) => b.score - a.score)
283
+ .map(({ score: _, ...rest }) => rest);
284
+ }
285
+
213
286
  // Combined provider that handles both slash commands and file paths.
214
287
  export class CombinedAutocompleteProvider implements AutocompleteProvider {
215
- #commands: (SlashCommand | AutocompleteItem)[];
288
+ #commands: CommandEntry[];
216
289
  #basePath: string;
217
290
  // Intentionally separate from prometheus-natives cache: this cache is a local,
218
291
  // per-directory readdir fast-path for prefix completions. Global fuzzy
@@ -220,7 +293,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
220
293
  #dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
221
294
  readonly #DIR_CACHE_TTL = 2000; // 2 seconds
222
295
 
223
- constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = getProjectDir()) {
296
+ constructor(commands: CommandEntry[] = [], basePath: string = getProjectDir()) {
224
297
  this.#commands = commands;
225
298
  this.#basePath = basePath;
226
299
  }
@@ -274,35 +347,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
274
347
  const prefix = textBeforeCursor.slice(1); // Remove the "/"
275
348
  const lowerPrefix = prefix.toLowerCase();
276
349
 
277
- // Filter commands using fuzzy matching (subsequence match)
278
- const matches = this.#commands
279
- .filter(cmd => {
280
- const name = "name" in cmd ? cmd.name : cmd.value;
281
- if (!name) return false;
282
- // Match name or description
283
- if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
284
- const desc = cmd.description?.toLowerCase();
285
- return desc ? fuzzyMatch(lowerPrefix, desc) : false;
286
- })
287
- .map(cmd => {
288
- const name = "name" in cmd ? cmd.name : cmd.value;
289
- const lowerName = name?.toLowerCase() ?? "";
290
- const lowerDesc = cmd.description?.toLowerCase() ?? "";
291
- // Score name matches higher than description matches
292
- const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
293
- const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
294
- const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
295
- const desc = cmd.description ?? "";
296
- const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
297
- return {
298
- value: name,
299
- label: "name" in cmd ? cmd.name : cmd.label,
300
- score: Math.max(nameScore, descScore),
301
- ...(fullDesc && { description: fullDesc }),
302
- };
303
- })
304
- .sort((a, b) => b.score - a.score)
305
- .map(({ score: _, ...rest }) => rest);
350
+ const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix);
306
351
 
307
352
  if (matches.length === 0) return null;
308
353
 
@@ -315,10 +360,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
315
360
  const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
316
361
  const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
317
362
 
318
- const command = this.#commands.find(cmd => {
319
- const name = "name" in cmd ? cmd.name : cmd.value;
320
- return name === commandName;
321
- });
363
+ const command = this.#commands.find(cmd => commandMatchesNameOrAlias(cmd, commandName));
322
364
  if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
323
365
  return null; // No argument completion for this command
324
366
  }
@@ -819,10 +861,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
819
861
  const commandName = textBeforeCursor.slice(1, spaceIndex);
820
862
  const argumentText = textBeforeCursor.slice(spaceIndex + 1);
821
863
 
822
- const command = this.#commands.find(cmd => {
823
- const name = "name" in cmd ? cmd.name : cmd.value;
824
- return name === commandName;
825
- });
864
+ const command = this.#commands.find(cmd => commandMatchesNameOrAlias(cmd, commandName));
826
865
 
827
866
  if (!command || !("getInlineHint" in command) || !command.getInlineHint) {
828
867
  return null;
@@ -838,32 +877,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
838
877
  const prefix = textBeforeCursor.slice(1);
839
878
  const lowerPrefix = prefix.toLowerCase();
840
879
 
841
- const matches = this.#commands
842
- .filter(cmd => {
843
- const name = "name" in cmd ? cmd.name : cmd.value;
844
- if (!name) return false;
845
- if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
846
- const desc = cmd.description?.toLowerCase();
847
- return desc ? fuzzyMatch(lowerPrefix, desc) : false;
848
- })
849
- .map(cmd => {
850
- const name = "name" in cmd ? cmd.name : cmd.value;
851
- const lowerName = name?.toLowerCase() ?? "";
852
- const lowerDesc = cmd.description?.toLowerCase() ?? "";
853
- const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
854
- const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
855
- const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
856
- const desc = cmd.description ?? "";
857
- const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
858
- return {
859
- value: name,
860
- label: "name" in cmd ? cmd.name : cmd.label,
861
- score: Math.max(nameScore, descScore),
862
- ...(fullDesc && { description: fullDesc }),
863
- } as AutocompleteItem & { score: number };
864
- })
865
- .sort((a, b) => b.score - a.score)
866
- .map(({ score: _, ...rest }) => rest);
880
+ const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix);
867
881
 
868
882
  if (matches.length === 0) return null;
869
883
  return { items: matches, prefix: textBeforeCursor };