jeopi-tui 16.4.3 → 16.4.4

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.
@@ -73,6 +73,13 @@ export interface AutocompleteProvider {
73
73
  shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
74
74
  }
75
75
  type CommandEntry = SlashCommand | AutocompleteItem;
76
+ /**
77
+ * Whether a mid-prompt slash token is constrained enough to surface a skill.
78
+ * Mid-prompt prose must not keep the popup alive through fuzzy description
79
+ * matches; only namespace prefixes, explicit `skill:` queries, and bare-name
80
+ * prefixes qualify.
81
+ */
82
+ export declare function midPromptSkillTokenMatches(lowerToken: string, name: string, description?: string): boolean;
76
83
  export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
77
84
  #private;
78
85
  constructor(commands?: CommandEntry[], basePath?: string);
@@ -420,4 +420,14 @@ export declare class TUI extends Container {
420
420
  * cheaper.
421
421
  */
422
422
  requestComponentRender(component: Component): void;
423
+ /**
424
+ * Rewrite a quiet, visible component segment directly.
425
+ *
426
+ * Loader-style animation changes one already-positioned segment at a fixed
427
+ * size. When the current frame geometry is still valid, rewrite just those
428
+ * rows and update the diff baseline instead of scheduling a full render
429
+ * cycle. Unsafe states fall back to `requestComponentRender()`, preserving
430
+ * the ordinary renderer as the correctness path.
431
+ */
432
+ requestDirectWrite(component: Component): void;
423
433
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-tui",
4
- "version": "16.4.3",
4
+ "version": "16.4.4",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "jeopi-natives": "16.4.3",
41
- "jeopi-utils": "16.4.3",
40
+ "jeopi-natives": "16.4.4",
41
+ "jeopi-utils": "16.4.4",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -370,9 +370,33 @@ function hasPromptTextBeforeSlash(
370
370
  return textBeforeCursor.slice(0, slashStart).trim() !== "";
371
371
  }
372
372
 
373
+ const SKILL_NAMESPACE = "skill:";
374
+
375
+ /**
376
+ * Whether a mid-prompt slash token is constrained enough to surface a skill.
377
+ * Mid-prompt prose must not keep the popup alive through fuzzy description
378
+ * matches; only namespace prefixes, explicit `skill:` queries, and bare-name
379
+ * prefixes qualify.
380
+ */
381
+ export function midPromptSkillTokenMatches(lowerToken: string, name: string, description?: string): boolean {
382
+ if (SKILL_NAMESPACE.startsWith(lowerToken)) return true;
383
+ const lowerName = name.toLowerCase();
384
+ if (lowerToken.startsWith(SKILL_NAMESPACE)) {
385
+ if (scoreCommandTextMatch(lowerToken, lowerName) > 0) return true;
386
+ return !!description && scoreCommandTextMatch(lowerToken, description.toLowerCase()) > 0;
387
+ }
388
+ return lowerName.startsWith(SKILL_NAMESPACE) && lowerName.slice(SKILL_NAMESPACE.length).startsWith(lowerToken);
389
+ }
390
+
373
391
  function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: string): AutocompleteItem[] {
374
392
  return buildSlashCommandCompletions(
375
- commands.filter(cmd => getCommandName(cmd)?.startsWith("skill:")),
393
+ commands.filter(cmd => {
394
+ const name = getCommandName(cmd);
395
+ return (
396
+ name?.startsWith(SKILL_NAMESPACE) &&
397
+ midPromptSkillTokenMatches(lowerPrefix, name, getStaticCommandDescription(cmd))
398
+ );
399
+ }),
376
400
  lowerPrefix,
377
401
  );
378
402
  }
@@ -3,6 +3,7 @@ import {
3
3
  type AutocompleteProvider,
4
4
  findLeadingSlashCommandStart,
5
5
  findTrailingSlashCommandStart,
6
+ midPromptSkillTokenMatches,
6
7
  } from "../autocomplete";
7
8
  import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
8
9
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
@@ -21,7 +22,7 @@ import {
21
22
  truncateToWidth,
22
23
  visibleWidth,
23
24
  } from "../utils";
24
- import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
25
+ import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
25
26
 
26
27
  const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
27
28
  overflowSearch: false,
@@ -1116,10 +1117,21 @@ export class Editor implements Component, Focusable {
1116
1117
  this.onAutocompleteUpdate?.();
1117
1118
  return;
1118
1119
  }
1119
-
1120
- // If Tab was pressed, always apply the selection
1120
+ // If Tab was pressed, apply the selection only while it still maps to
1121
+ // the live buffer. This matters for debounced mid-prompt skill refreshes.
1121
1122
  if (kb.matches(data, "tui.input.tab")) {
1122
1123
  const selected = this.#autocompleteList.getSelectedItem();
1124
+ const shouldGuardSkillCompletion =
1125
+ selected?.value.startsWith("skill:") &&
1126
+ findTrailingSlashCommandStart(this.#autocompletePrefix) !== null;
1127
+ if (shouldGuardSkillCompletion) {
1128
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1129
+ const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1130
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1131
+ this.#cancelAutocomplete();
1132
+ return;
1133
+ }
1134
+ }
1123
1135
  if (selected && this.#autocompleteProvider) {
1124
1136
  const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
1125
1137
  const result = this.#autocompleteProvider.applyCompletion(
@@ -1149,7 +1161,6 @@ export class Editor implements Component, Focusable {
1149
1161
  }
1150
1162
  return;
1151
1163
  }
1152
-
1153
1164
  // If Enter was pressed on a slash command, apply completion and submit
1154
1165
  if (
1155
1166
  (kb.matches(data, "tui.input.submit") || data === "\n") &&
@@ -1158,11 +1169,11 @@ export class Editor implements Component, Focusable {
1158
1169
  // Check for stale autocomplete state due to debounce
1159
1170
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1160
1171
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1161
- if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
1172
+ const selected = this.#autocompleteList.getSelectedItem();
1173
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1162
1174
  // Autocomplete is stale - cancel and fall through to normal submission
1163
1175
  this.#cancelAutocomplete();
1164
1176
  } else {
1165
- const selected = this.#autocompleteList.getSelectedItem();
1166
1177
  if (selected && this.#autocompleteProvider) {
1167
1178
  const result = this.#autocompleteProvider.applyCompletion(
1168
1179
  this.#state.lines,
@@ -2844,8 +2855,21 @@ export class Editor implements Component, Focusable {
2844
2855
  return this.#isInSubmittedSlashCommandContext() || this.#isInMidPromptSkillSlashContext();
2845
2856
  }
2846
2857
 
2847
- #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string): boolean {
2858
+ #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string, item?: SelectItem | null): boolean {
2848
2859
  if (currentTextBeforeCursor === this.#autocompletePrefix) return true;
2860
+
2861
+ if (item?.value.startsWith("skill:") && findTrailingSlashCommandStart(this.#autocompletePrefix) !== null) {
2862
+ const currentTrailingStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
2863
+ if (currentTrailingStart !== null) {
2864
+ const token = currentTextBeforeCursor.slice(currentTrailingStart);
2865
+ if (!token.includes(" ") && !token.slice(1).includes("/")) {
2866
+ const lowerToken = token.slice(1).toLowerCase();
2867
+ if (midPromptSkillTokenMatches(lowerToken, item.value, item.description)) return true;
2868
+ }
2869
+ }
2870
+ return false;
2871
+ }
2872
+
2849
2873
  if (findTrailingSlashCommandStart(this.#autocompletePrefix) !== 0) return false;
2850
2874
  const slashStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
2851
2875
  return slashStart !== null && currentTextBeforeCursor.slice(slashStart) === this.#autocompletePrefix;
@@ -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/tui.ts CHANGED
@@ -1903,6 +1903,154 @@ export class TUI extends Container {
1903
1903
  this.#componentRenderTargets.add(component);
1904
1904
  this.#requestOrdinaryRender();
1905
1905
  }
1906
+ /**
1907
+ * Rewrite a quiet, visible component segment directly.
1908
+ *
1909
+ * Loader-style animation changes one already-positioned segment at a fixed
1910
+ * size. When the current frame geometry is still valid, rewrite just those
1911
+ * rows and update the diff baseline instead of scheduling a full render
1912
+ * cycle. Unsafe states fall back to `requestComponentRender()`, preserving
1913
+ * the ordinary renderer as the correctness path.
1914
+ */
1915
+ requestDirectWrite(component: Component): void {
1916
+ if (this.#stopped) return;
1917
+ if (
1918
+ this.#renderRequested ||
1919
+ this.#postFullPaintSettleTimer !== undefined ||
1920
+ this.#postFullPaintSettleUntilMs > 0
1921
+ ) {
1922
+ this.requestComponentRender(component);
1923
+ return;
1924
+ }
1925
+
1926
+ const width = this.terminal.columns;
1927
+ const height = this.terminal.rows;
1928
+ if (!this.#hasEverRendered || this.#resizeEventPending) {
1929
+ this.requestComponentRender(component);
1930
+ return;
1931
+ }
1932
+ if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) {
1933
+ this.requestComponentRender(component);
1934
+ return;
1935
+ }
1936
+ if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) {
1937
+ this.requestComponentRender(component);
1938
+ return;
1939
+ }
1940
+ if (this.overlayStack.length > 0 || this.#altActive || !this.#imageBudget.quiescent) {
1941
+ this.requestComponentRender(component);
1942
+ return;
1943
+ }
1944
+
1945
+ const children = this.children;
1946
+ const segments = this.#frameSegments;
1947
+ if (segments.length !== children.length) {
1948
+ this.requestComponentRender(component);
1949
+ return;
1950
+ }
1951
+ for (let i = 0; i < children.length; i++) {
1952
+ if (segments[i]!.component !== children[i]) {
1953
+ this.requestComponentRender(component);
1954
+ return;
1955
+ }
1956
+ }
1957
+
1958
+ const root = this.#resolveComponentRoot(component);
1959
+ if (root === null) {
1960
+ this.requestComponentRender(component);
1961
+ return;
1962
+ }
1963
+ const segmentIndex = segments.findIndex(segment => segment.component === root);
1964
+ if (segmentIndex === -1) {
1965
+ this.requestComponentRender(component);
1966
+ return;
1967
+ }
1968
+ const segment = segments[segmentIndex]!;
1969
+ if (segment.liveLocalStart !== undefined || segment.start < this.#committedRows) {
1970
+ this.requestComponentRender(component);
1971
+ return;
1972
+ }
1973
+
1974
+ const windowTop = Math.max(this.#committedRows, this.#composedFrame.length - height, 0);
1975
+ if (windowTop !== this.#windowTopRow) {
1976
+ this.requestComponentRender(component);
1977
+ return;
1978
+ }
1979
+ const screenStart = segment.start - windowTop;
1980
+ if (screenStart < 0 || screenStart + segment.rowCount > height) {
1981
+ this.requestComponentRender(component);
1982
+ return;
1983
+ }
1984
+
1985
+ const nextLines = root.render(width);
1986
+ if (nextLines.length !== segment.rowCount) {
1987
+ this.requestComponentRender(component);
1988
+ return;
1989
+ }
1990
+ for (const line of nextLines) {
1991
+ if (line.includes(CURSOR_MARKER)) {
1992
+ this.requestComponentRender(component);
1993
+ return;
1994
+ }
1995
+ }
1996
+
1997
+ let firstChanged = -1;
1998
+ let lastChanged = -1;
1999
+ const previousWindow = this.#previousWindow;
2000
+ for (let i = 0; i < nextLines.length; i++) {
2001
+ const frameRow = segment.start + i;
2002
+ const raw = nextLines[i]!;
2003
+ const prepared = this.#prepareLine(raw, width);
2004
+ this.#composedFrame[frameRow] = raw;
2005
+ this.#preparedMeta[frameRow] = prepared;
2006
+ this.#preparedFrame[frameRow] = prepared.line;
2007
+ if (previousWindow[screenStart + i] === prepared.line) continue;
2008
+ previousWindow[screenStart + i] = prepared.line;
2009
+ if (firstChanged === -1) firstChanged = i;
2010
+ lastChanged = i;
2011
+ }
2012
+ segments[segmentIndex] = { ...segment, lines: nextLines };
2013
+ this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length);
2014
+ this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start);
2015
+
2016
+ let cursorPos: { row: number; col: number } | null = null;
2017
+ for (let i = this.#frameCursorMarkers.length - 1; i >= 0; i--) {
2018
+ const marker = this.#frameCursorMarkers[i]!;
2019
+ if (marker.row >= windowTop) {
2020
+ cursorPos = marker;
2021
+ break;
2022
+ }
2023
+ }
2024
+
2025
+ if (firstChanged === -1) {
2026
+ this.#writeCursorPosition(cursorPos, this.#composedFrame.length);
2027
+ this.#previousWidth = width;
2028
+ this.#previousHeight = height;
2029
+ return;
2030
+ }
2031
+
2032
+ const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - windowTop));
2033
+ const targetScreenRow = screenStart + firstChanged;
2034
+ const rowDelta = targetScreenRow - currentScreenRow;
2035
+ let buffer = this.#paintBeginSequence;
2036
+ if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
2037
+ else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
2038
+ buffer += "\r";
2039
+ for (let i = firstChanged; i <= lastChanged; i++) {
2040
+ if (i > firstChanged) buffer += "\r\n";
2041
+ buffer += this.#lineRewriteSequence(this.#preparedFrame[segment.start + i] ?? "", width);
2042
+ }
2043
+ const cursorControl = this.#cursorControlSequence(
2044
+ cursorPos,
2045
+ this.#composedFrame.length,
2046
+ segment.start + lastChanged,
2047
+ );
2048
+ buffer += cursorControl.seq;
2049
+ buffer += this.#paintEndSequence;
2050
+ this.terminal.write(buffer);
2051
+ this.#windowTopRow = windowTop;
2052
+ this.#commit(this.#composedFrame, previousWindow, width, height, cursorControl);
2053
+ }
1906
2054
 
1907
2055
  /** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
1908
2056
  #requestOrdinaryRender(): void {
package/src/utils.ts CHANGED
@@ -394,6 +394,7 @@ export function getWordNavKind(grapheme: string): WordNavKind {
394
394
  const ch = firstCodePointChar(grapheme);
395
395
  if (!ch) return "other";
396
396
  if (WORD_NAV_RE_WHITESPACE.test(ch)) return "whitespace";
397
+ if (ch === "_") return "word";
397
398
  if (WORD_NAV_RE_PUNCT.test(ch) || WORD_NAV_RE_SYMBOL.test(ch)) return "delimiter";
398
399
  if (
399
400
  WORD_NAV_RE_HAN.test(ch) ||
@@ -403,7 +404,7 @@ export function getWordNavKind(grapheme: string): WordNavKind {
403
404
  ) {
404
405
  return "cjk";
405
406
  }
406
- if (ch === "_" || WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
407
+ if (WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
407
408
  return "other";
408
409
  }
409
410