@oh-my-pi/pi-tui 16.4.3 → 16.4.5

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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.5] - 2026-07-11
6
+
7
+ ### Added
8
+
9
+ - Added `FuzzyText`, a prepared fuzzy-match handle that builds the search index once and matches many queries against it, optimizing performance for large corpora like session or transcript searches.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed an issue where the mid-prompt `/` autocomplete popup lingered indefinitely on non-path and non-skill tokens. Autocomplete matching is now properly gated to explicit skill namespaces, queries, and prefixes, preventing stale popups from incorrectly rewriting input on Tab or Enter.
14
+ - Fixed idle Loader animation driving the full TUI render pipeline on every spinner tick by directly rewriting the Loader's visible rows when geometry is unchanged, reducing idle render work while preserving fallback repaint paths ([#5192](https://github.com/can1357/oh-my-pi/issues/5192)).
15
+
5
16
  ## [16.4.1] - 2026-07-10
6
17
 
7
18
  ### Added
@@ -74,6 +74,20 @@ export interface AutocompleteProvider {
74
74
  }
75
75
  type CommandEntry = SlashCommand | AutocompleteItem;
76
76
  export declare function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number;
77
+ /**
78
+ * Whether a mid-prompt slash token (`prose … /tok`) is skill-shaped enough to
79
+ * surface `name` in the skill popup. Deliberately stricter than submitted
80
+ * slash-command matching: a stray `/word` in running prose must not keep the
81
+ * popup alive through fuzzy name/description hits, so a token only matches as
82
+ * - a prefix of the `skill:` namespace (incl. the bare `/` entry point),
83
+ * - an explicit `skill:…` query (full fuzzy name/description search), or
84
+ * - a prefix of the skill's bare name (`/hum` → `skill:humanizer`).
85
+ * Anything else yields no items, letting the caller fall through to path
86
+ * completion or close the popup. Shared with the editor's accept-time
87
+ * staleness guard so Tab/Enter never accepts a skill the refreshed popup
88
+ * would no longer show.
89
+ */
90
+ export declare function midPromptSkillTokenMatches(lowerToken: string, name: string, description?: string): boolean;
77
91
  export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
78
92
  #private;
79
93
  constructor(commands?: CommandEntry[], basePath?: string);
@@ -16,6 +16,22 @@ export interface FuzzyFilterResult<T> {
16
16
  score: number;
17
17
  }
18
18
  export declare function fuzzyMatch(query: string, text: string): FuzzyMatch;
19
+ /**
20
+ * A text prepared once for repeated fuzzy matching.
21
+ *
22
+ * `fuzzyMatch` builds a search index per call; the module cache only admits
23
+ * texts up to {@link MAX_CACHED_TEXT_LEN}, so long corpora (session or
24
+ * transcript search) rebuild the index on every keystroke — the dominant cost
25
+ * when a selector re-filters a stable candidate list as the user types. Build
26
+ * one `FuzzyText` per candidate and call {@link match} per query instead; the
27
+ * index lives exactly as long as the caller's reference.
28
+ */
29
+ export declare class FuzzyText {
30
+ #private;
31
+ constructor(text: string);
32
+ /** Match `query` (space-separated tokens; all must match) against the prepared text. */
33
+ match(query: string): FuzzyMatch;
34
+ }
19
35
  /**
20
36
  * Filter and sort items by fuzzy match quality (best matches first).
21
37
  * Supports space-separated tokens: all tokens must match.
@@ -394,4 +394,14 @@ export declare class TUI extends Container {
394
394
  * cheaper.
395
395
  */
396
396
  requestComponentRender(component: Component): void;
397
+ /**
398
+ * Rewrite a quiet, visible component segment directly.
399
+ *
400
+ * Loader-style animation changes one already-positioned segment at a fixed
401
+ * size. When the current frame geometry is still valid, rewrite just those
402
+ * rows and update the diff baseline instead of scheduling a full render
403
+ * cycle. Unsafe states fall back to `requestComponentRender()`, preserving
404
+ * the ordinary renderer as the correctness path.
405
+ */
406
+ requestDirectWrite(component: Component): void;
397
407
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.4.3",
4
+ "version": "16.4.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "16.4.3",
41
- "@oh-my-pi/pi-utils": "16.4.3",
40
+ "@oh-my-pi/pi-natives": "16.4.5",
41
+ "@oh-my-pi/pi-utils": "16.4.5",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -359,9 +359,40 @@ function hasPromptTextBeforeSlash(
359
359
  return textBeforeCursor.slice(0, slashStart).trim() !== "";
360
360
  }
361
361
 
362
+ const SKILL_NAMESPACE = "skill:";
363
+
364
+ /**
365
+ * Whether a mid-prompt slash token (`prose … /tok`) is skill-shaped enough to
366
+ * surface `name` in the skill popup. Deliberately stricter than submitted
367
+ * slash-command matching: a stray `/word` in running prose must not keep the
368
+ * popup alive through fuzzy name/description hits, so a token only matches as
369
+ * - a prefix of the `skill:` namespace (incl. the bare `/` entry point),
370
+ * - an explicit `skill:…` query (full fuzzy name/description search), or
371
+ * - a prefix of the skill's bare name (`/hum` → `skill:humanizer`).
372
+ * Anything else yields no items, letting the caller fall through to path
373
+ * completion or close the popup. Shared with the editor's accept-time
374
+ * staleness guard so Tab/Enter never accepts a skill the refreshed popup
375
+ * would no longer show.
376
+ */
377
+ export function midPromptSkillTokenMatches(lowerToken: string, name: string, description?: string): boolean {
378
+ if (SKILL_NAMESPACE.startsWith(lowerToken)) return true;
379
+ const lowerName = name.toLowerCase();
380
+ if (lowerToken.startsWith(SKILL_NAMESPACE)) {
381
+ if (scoreCommandTextMatch(lowerToken, lowerName) > 0) return true;
382
+ return !!description && scoreCommandTextMatch(lowerToken, description.toLowerCase()) > 0;
383
+ }
384
+ return lowerName.startsWith(SKILL_NAMESPACE) && lowerName.slice(SKILL_NAMESPACE.length).startsWith(lowerToken);
385
+ }
386
+
362
387
  function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: string): AutocompleteItem[] {
363
388
  return buildSlashCommandCompletions(
364
- commands.filter(cmd => getCommandName(cmd)?.startsWith("skill:")),
389
+ commands.filter(cmd => {
390
+ const name = getCommandName(cmd);
391
+ return (
392
+ name?.startsWith(SKILL_NAMESPACE) &&
393
+ midPromptSkillTokenMatches(lowerPrefix, name, getStaticCommandDescription(cmd))
394
+ );
395
+ }),
365
396
  lowerPrefix,
366
397
  );
367
398
  }
@@ -3,7 +3,7 @@ import {
3
3
  type AutocompleteProvider,
4
4
  findLeadingSlashCommandStart,
5
5
  findTrailingSlashCommandStart,
6
- scoreCommandTextMatch,
6
+ midPromptSkillTokenMatches,
7
7
  } from "../autocomplete";
8
8
  import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
9
9
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
@@ -2911,13 +2911,12 @@ export class Editor implements Component, Focusable {
2911
2911
  // Guard the timing window where the popup was built for an earlier
2912
2912
  // query (e.g. bare `/`) and the user typed further characters before
2913
2913
  // the 100 ms debounced refresh fired: accept the stale skill only
2914
- // when the current query would still surface it. `tmp` after a bare
2915
- // slash therefore falls through to file completion instead of
2916
- // rewriting the user's `/tmp` to `/skill:…`.
2914
+ // when the refreshed popup would still surface it (same gate as
2915
+ // buildMidPromptSkillCompletions). `tmp` after a bare slash
2916
+ // therefore falls through to file completion instead of rewriting
2917
+ // the user's `/tmp` to `/skill:…`.
2917
2918
  const lowerToken = token.slice(1).toLowerCase();
2918
- if (scoreCommandTextMatch(lowerToken, item.value.toLowerCase()) > 0) return true;
2919
- if (item.description && scoreCommandTextMatch(lowerToken, item.description.toLowerCase()) > 0)
2920
- return true;
2919
+ if (midPromptSkillTokenMatches(lowerToken, item.value, item.description)) return true;
2921
2920
  }
2922
2921
  }
2923
2922
  return false;
@@ -3050,11 +3049,6 @@ export class Editor implements Component, Focusable {
3050
3049
  await this.#tryTriggerAutocomplete();
3051
3050
  }
3052
3051
 
3053
- /*
3054
- https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
3055
- 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
3056
- 536643416/job/55932288317 havea look at .gi
3057
- */
3058
3052
  async #forceFileAutocomplete(explicitTab: boolean = false): Promise<void> {
3059
3053
  if (!this.#autocompleteProvider) return;
3060
3054
 
@@ -93,11 +93,15 @@ export class Loader extends Text {
93
93
  const frame = this.#frames[this.#currentFrame];
94
94
  const text = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
95
95
  if (this.setText(text) && this.#ui) {
96
- // Component-scoped: a spinner tick changes only this component, so
97
- // the TUI may reuse every other root subtree instead of re-walking
98
- // the whole tree (full repaints at 12.5 Hz made huge transcripts
99
- // lag as soon as the loader appeared).
100
- this.#ui.requestComponentRender(this);
96
+ // Direct write: a loader tick changes only this component, so the TUI
97
+ // can update the already-positioned rows without driving the full
98
+ // compose/prepare/diff pipeline. Lightweight test stubs may not carry
99
+ // the newer API; keep their legacy component-scoped path working.
100
+ if (typeof this.#ui.requestDirectWrite === "function") {
101
+ this.#ui.requestDirectWrite(this);
102
+ } else {
103
+ this.#ui.requestComponentRender(this);
104
+ }
101
105
  }
102
106
  }
103
107
  }
package/src/fuzzy.ts CHANGED
@@ -280,12 +280,11 @@ function prepareQuery(query: string): PreparedQuery | null {
280
280
  return { normalized, tokens: normalized.split(" "), compact: normalized.replaceAll(" ", "") };
281
281
  }
282
282
 
283
- function fuzzyMatchCore(pq: PreparedQuery | null, text: string): FuzzyMatch {
283
+ function fuzzyMatchCore(pq: PreparedQuery | null, index: SearchIndex): FuzzyMatch {
284
284
  if (pq === null) {
285
285
  return { matches: true, score: 0 };
286
286
  }
287
287
 
288
- const index = buildSearchIndex(text);
289
288
  if (index.words.length === 0) {
290
289
  return { matches: false, score: 0 };
291
290
  }
@@ -315,7 +314,32 @@ function fuzzyMatchCore(pq: PreparedQuery | null, text: string): FuzzyMatch {
315
314
  }
316
315
 
317
316
  export function fuzzyMatch(query: string, text: string): FuzzyMatch {
318
- return fuzzyMatchCore(prepareQuery(query), text);
317
+ const pq = prepareQuery(query);
318
+ if (pq === null) return { matches: true, score: 0 };
319
+ return fuzzyMatchCore(pq, buildSearchIndex(text));
320
+ }
321
+
322
+ /**
323
+ * A text prepared once for repeated fuzzy matching.
324
+ *
325
+ * `fuzzyMatch` builds a search index per call; the module cache only admits
326
+ * texts up to {@link MAX_CACHED_TEXT_LEN}, so long corpora (session or
327
+ * transcript search) rebuild the index on every keystroke — the dominant cost
328
+ * when a selector re-filters a stable candidate list as the user types. Build
329
+ * one `FuzzyText` per candidate and call {@link match} per query instead; the
330
+ * index lives exactly as long as the caller's reference.
331
+ */
332
+ export class FuzzyText {
333
+ readonly #index: SearchIndex;
334
+
335
+ constructor(text: string) {
336
+ this.#index = buildUncachedSearchIndex(text);
337
+ }
338
+
339
+ /** Match `query` (space-separated tokens; all must match) against the prepared text. */
340
+ match(query: string): FuzzyMatch {
341
+ return fuzzyMatchCore(prepareQuery(query), this.#index);
342
+ }
319
343
  }
320
344
 
321
345
  /**
@@ -327,10 +351,14 @@ export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => st
327
351
  return items.map(item => ({ item, score: 0 }));
328
352
  }
329
353
 
354
+ // A non-blank query that normalizes to empty (pure punctuation) matches
355
+ // everything with score 0, but still calls getText per item — consumers rely
356
+ // on its side effects (see fuzzy-cache.test.ts).
330
357
  const pq = prepareQuery(query);
331
358
  const results: FuzzyFilterResult<T>[] = [];
332
359
  for (const item of items) {
333
- const match = fuzzyMatchCore(pq, getText(item));
360
+ const text = getText(item);
361
+ const match = pq === null ? { matches: true, score: 0 } : fuzzyMatchCore(pq, buildSearchIndex(text));
334
362
  if (match.matches) {
335
363
  results.push({ item, score: match.score });
336
364
  }
package/src/tui.ts CHANGED
@@ -1835,6 +1835,159 @@ export class TUI extends Container {
1835
1835
  this.#requestOrdinaryRender();
1836
1836
  }
1837
1837
 
1838
+ /**
1839
+ * Rewrite a quiet, visible component segment directly.
1840
+ *
1841
+ * Loader-style animation changes one already-positioned segment at a fixed
1842
+ * size. When the current frame geometry is still valid, rewrite just those
1843
+ * rows and update the diff baseline instead of scheduling a full render
1844
+ * cycle. Unsafe states fall back to `requestComponentRender()`, preserving
1845
+ * the ordinary renderer as the correctness path.
1846
+ */
1847
+ requestDirectWrite(component: Component): void {
1848
+ if (this.#stopped) return;
1849
+ if (
1850
+ this.#renderRequested ||
1851
+ this.#postFullPaintSettleTimer !== undefined ||
1852
+ this.#postFullPaintSettleUntilMs > 0
1853
+ ) {
1854
+ this.requestComponentRender(component);
1855
+ return;
1856
+ }
1857
+
1858
+ const width = this.terminal.columns;
1859
+ const height = this.terminal.rows;
1860
+ if (!this.#hasEverRendered || this.#resizeEventPending) {
1861
+ this.requestComponentRender(component);
1862
+ return;
1863
+ }
1864
+ if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) {
1865
+ this.requestComponentRender(component);
1866
+ return;
1867
+ }
1868
+ if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) {
1869
+ this.requestComponentRender(component);
1870
+ return;
1871
+ }
1872
+ if (this.overlayStack.length > 0 || this.#altActive || !this.#imageBudget.quiescent) {
1873
+ this.requestComponentRender(component);
1874
+ return;
1875
+ }
1876
+
1877
+ const children = this.children;
1878
+ const segments = this.#frameSegments;
1879
+ if (segments.length !== children.length) {
1880
+ this.requestComponentRender(component);
1881
+ return;
1882
+ }
1883
+ for (let i = 0; i < children.length; i++) {
1884
+ if (segments[i]!.component !== children[i]) {
1885
+ this.requestComponentRender(component);
1886
+ return;
1887
+ }
1888
+ }
1889
+
1890
+ const root = this.#resolveComponentRoot(component);
1891
+ if (root === null) {
1892
+ this.requestComponentRender(component);
1893
+ return;
1894
+ }
1895
+ const segmentIndex = segments.findIndex(segment => segment.component === root);
1896
+ if (segmentIndex === -1) {
1897
+ this.requestComponentRender(component);
1898
+ return;
1899
+ }
1900
+ const segment = segments[segmentIndex]!;
1901
+ const fullyLiveUncommittedSegment = segment.liveLocalStart === 0 && segment.start >= this.#committedRows;
1902
+ if (
1903
+ (segment.liveLocalStart !== undefined && !fullyLiveUncommittedSegment) ||
1904
+ segment.start < this.#committedRows
1905
+ ) {
1906
+ this.requestComponentRender(component);
1907
+ return;
1908
+ }
1909
+
1910
+ const windowTop = Math.max(this.#committedRows, this.#composedFrame.length - height, 0);
1911
+ if (windowTop !== this.#windowTopRow) {
1912
+ this.requestComponentRender(component);
1913
+ return;
1914
+ }
1915
+ const screenStart = segment.start - windowTop;
1916
+ if (screenStart < 0 || screenStart + segment.rowCount > height) {
1917
+ this.requestComponentRender(component);
1918
+ return;
1919
+ }
1920
+
1921
+ const nextLines = root.render(width);
1922
+ if (nextLines.length !== segment.rowCount) {
1923
+ this.requestComponentRender(component);
1924
+ return;
1925
+ }
1926
+ for (const line of nextLines) {
1927
+ if (line.includes(CURSOR_MARKER)) {
1928
+ this.requestComponentRender(component);
1929
+ return;
1930
+ }
1931
+ }
1932
+
1933
+ let firstChanged = -1;
1934
+ let lastChanged = -1;
1935
+ const previousWindow = this.#previousWindow;
1936
+ for (let i = 0; i < nextLines.length; i++) {
1937
+ const frameRow = segment.start + i;
1938
+ const raw = nextLines[i]!;
1939
+ const prepared = this.#prepareLine(raw, width);
1940
+ this.#composedFrame[frameRow] = raw;
1941
+ this.#preparedMeta[frameRow] = prepared;
1942
+ this.#preparedFrame[frameRow] = prepared.line;
1943
+ if (previousWindow[screenStart + i] === prepared.line) continue;
1944
+ previousWindow[screenStart + i] = prepared.line;
1945
+ if (firstChanged === -1) firstChanged = i;
1946
+ lastChanged = i;
1947
+ }
1948
+ segments[segmentIndex] = { ...segment, lines: nextLines };
1949
+ this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length);
1950
+ this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start);
1951
+
1952
+ let cursorPos: { row: number; col: number } | null = null;
1953
+ for (let i = this.#frameCursorMarkers.length - 1; i >= 0; i--) {
1954
+ const marker = this.#frameCursorMarkers[i]!;
1955
+ if (marker.row >= windowTop) {
1956
+ cursorPos = marker;
1957
+ break;
1958
+ }
1959
+ }
1960
+
1961
+ if (firstChanged === -1) {
1962
+ this.#writeCursorPosition(cursorPos, this.#composedFrame.length);
1963
+ this.#previousWidth = width;
1964
+ this.#previousHeight = height;
1965
+ return;
1966
+ }
1967
+
1968
+ const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - windowTop));
1969
+ const targetScreenRow = screenStart + firstChanged;
1970
+ const rowDelta = targetScreenRow - currentScreenRow;
1971
+ let buffer = this.#paintBeginSequence;
1972
+ if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
1973
+ else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
1974
+ buffer += "\r";
1975
+ for (let i = firstChanged; i <= lastChanged; i++) {
1976
+ if (i > firstChanged) buffer += "\r\n";
1977
+ buffer += this.#lineRewriteSequence(this.#preparedFrame[segment.start + i] ?? "", width);
1978
+ }
1979
+ const cursorControl = this.#cursorControlSequence(
1980
+ cursorPos,
1981
+ this.#composedFrame.length,
1982
+ segment.start + lastChanged,
1983
+ );
1984
+ buffer += cursorControl.seq;
1985
+ buffer += this.#paintEndSequence;
1986
+ this.terminal.write(buffer);
1987
+ this.#windowTopRow = windowTop;
1988
+ this.#commit(this.#composedFrame, previousWindow, width, height, cursorControl);
1989
+ }
1990
+
1838
1991
  /** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
1839
1992
  #requestOrdinaryRender(): void {
1840
1993
  // Coalesce non-forced renders inside the post-full-paint ConPTY settle