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.
- package/dist/types/autocomplete.d.ts +7 -0
- package/dist/types/tui.d.ts +10 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +25 -1
- package/src/components/editor.ts +31 -7
- package/src/components/loader.ts +9 -5
- package/src/tui.ts +148 -0
- package/src/utils.ts +2 -1
|
@@ -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);
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
41
|
-
"jeopi-utils": "16.4.
|
|
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
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -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 =>
|
|
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
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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;
|
package/src/components/loader.ts
CHANGED
|
@@ -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
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
this.#ui.
|
|
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 (
|
|
407
|
+
if (WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
|
|
407
408
|
return "other";
|
|
408
409
|
}
|
|
409
410
|
|