jeopi-tui 16.4.2 → 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/CHANGELOG.md +10 -0
- 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/components/markdown.ts +140 -27
- package/src/terminal.ts +21 -1
- package/src/tui.ts +167 -18
- package/src/utils.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.4.3] - 2026-07-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed ordinary navigation keys (arrows, page up/down, etc.) paying a full frame of input-render-grace latency meant only for the Ctrl+C/Esc double-press gesture window: the grace period now arms only when the incoming key is Ctrl+C or Esc, so idle-state keyboard navigation repaints immediately again. Ported from oh-my-pi (upstream `1822603b2`, TUI portion only — the accompanying Model Hub/model-browser keyboard-navigation changes are coupled to jeopi's not-yet-ported Model Hub feature).
|
|
10
|
+
- Fixed forced renders (tool finalization, `resetDisplay`, image reconciliation) landing during a resize drag preempting the alternate-screen viewport fast path: each one left the borrowed alt screen, erased native scrollback (ED3), and visibly replayed the whole transcript on the normal screen mid-drag — then the settle replayed it again. Forced intent now folds into the single authoritative settle paint. Ported from oh-my-pi (upstream `485d207a7`).
|
|
11
|
+
- Hid empty HTML comment separators in Markdown-rendered TUI output instead of showing `<!-- -->` literally. Ported from oh-my-pi (upstream `aeed4d10d`).
|
|
12
|
+
- Fixed unmanaged macOS stderr writes (libmalloc/framework diagnostics) corrupting the viewport: `ProcessTerminal` now suppresses fd 2 via the jeopi-utils stderr guard while it owns the terminal and restores it in `stop()` and the emergency-restore path. Ported from oh-my-pi (upstream `4eaca82fa` by @Kormákur).
|
|
13
|
+
- Fixed completed rows in transient `diff`/`patch`/`udiff` fences entering native terminal scrollback without semantic syntax colors: newline-complete rows are now highlighted incrementally while the final partial row remains lightweight; closed fences and blank completed rows preserve their final layout and styling. Ported from oh-my-pi (upstream `e41b32c87`, `936e83e3d`, `cf6d25f1b`).
|
|
14
|
+
|
|
5
15
|
## [16.2.25] - 2026-07-05
|
|
6
16
|
|
|
7
17
|
### Added
|
|
@@ -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
|
}
|
|
@@ -86,6 +86,7 @@ function createHtmlNormalizationState(): HtmlNormalizationState {
|
|
|
86
86
|
return { lists: [], openItems: [], itemHasContent: [] };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
|
|
89
90
|
const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li|span|text|code|hr|blockquote)\b(?:\s[^>]*)?\s*\/?>/gi;
|
|
90
91
|
// Block-level HTML that needs structural (not just textual) rendering: standalone
|
|
91
92
|
// `<hr>` becomes a rule and balanced `<blockquote>…</blockquote>` renders with
|
|
@@ -136,11 +137,12 @@ function normalizeHtmlForTerminal(
|
|
|
136
137
|
let output = "";
|
|
137
138
|
let lastIndex = 0;
|
|
138
139
|
let inCode = false;
|
|
140
|
+
const withoutComments = raw.replace(HTML_COMMENT_REGEX, "");
|
|
139
141
|
|
|
140
|
-
for (const match of
|
|
142
|
+
for (const match of withoutComments.matchAll(HTML_TAG_REGEX)) {
|
|
141
143
|
const tag = match[0];
|
|
142
144
|
const index = match.index ?? 0;
|
|
143
|
-
const textBeforeTag = normalizeHtmlEntitiesForTerminal(
|
|
145
|
+
const textBeforeTag = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex, index));
|
|
144
146
|
const name = htmlTagName(tag);
|
|
145
147
|
// Most tags handled here are block-level. Inline contexts — span, text, and
|
|
146
148
|
// the content inside a `<code>` run — keep their surrounding whitespace
|
|
@@ -238,7 +240,7 @@ function normalizeHtmlForTerminal(
|
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
|
|
241
|
-
const remainingText = normalizeHtmlEntitiesForTerminal(
|
|
243
|
+
const remainingText = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex));
|
|
242
244
|
markCurrentHtmlItemContent(state, remainingText);
|
|
243
245
|
return output + (inCode && codeHook ? codeHook(remainingText) : remainingText);
|
|
244
246
|
}
|
|
@@ -789,6 +791,12 @@ interface StreamPrefixLineCache extends RenderSignature {
|
|
|
789
791
|
lines: readonly string[];
|
|
790
792
|
}
|
|
791
793
|
|
|
794
|
+
interface StreamingDiffLineCache extends RenderSignature {
|
|
795
|
+
lang: string | undefined;
|
|
796
|
+
text: string;
|
|
797
|
+
lines: readonly string[];
|
|
798
|
+
}
|
|
799
|
+
|
|
792
800
|
export class Markdown implements Component {
|
|
793
801
|
#text: string;
|
|
794
802
|
#paddingX: number; // Left/right padding
|
|
@@ -817,6 +825,8 @@ export class Markdown implements Component {
|
|
|
817
825
|
#streamPrefixText?: string;
|
|
818
826
|
#streamPrefixTokens?: Token[];
|
|
819
827
|
#streamPrefixLineCache?: StreamPrefixLineCache;
|
|
828
|
+
#streamingDiffLineCache?: StreamingDiffLineCache;
|
|
829
|
+
#activeRenderSignature?: RenderSignature;
|
|
820
830
|
|
|
821
831
|
#ignoreTight = false;
|
|
822
832
|
|
|
@@ -1001,9 +1011,15 @@ export class Markdown implements Component {
|
|
|
1001
1011
|
|
|
1002
1012
|
// Parse markdown to HTML-like tokens
|
|
1003
1013
|
const tokens = this.#lexTokens(normalizedText);
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1014
|
+
let contentLines: string[];
|
|
1015
|
+
this.#activeRenderSignature = signature;
|
|
1016
|
+
try {
|
|
1017
|
+
contentLines = this.transientRenderCache
|
|
1018
|
+
? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
|
|
1019
|
+
: this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
|
|
1020
|
+
} finally {
|
|
1021
|
+
this.#activeRenderSignature = undefined;
|
|
1022
|
+
}
|
|
1007
1023
|
const emptyLines = this.#renderEmptyPaddingLines(signature);
|
|
1008
1024
|
|
|
1009
1025
|
// Combine top padding, content, and bottom padding
|
|
@@ -1177,6 +1193,120 @@ export class Markdown implements Component {
|
|
|
1177
1193
|
return contentLines;
|
|
1178
1194
|
}
|
|
1179
1195
|
|
|
1196
|
+
#renderCodeBodyLines(token: Token, codeIndent: string): string[] {
|
|
1197
|
+
const bodyLines: string[] = [];
|
|
1198
|
+
const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
|
|
1199
|
+
const lang = "lang" in token && typeof token.lang === "string" ? token.lang : undefined;
|
|
1200
|
+
const normalizedLang = lang?.toLowerCase();
|
|
1201
|
+
const canStreamDiff =
|
|
1202
|
+
this.transientRenderCache &&
|
|
1203
|
+
this.#theme.highlightCode &&
|
|
1204
|
+
(normalizedLang === "diff" || normalizedLang === "patch" || normalizedLang === "udiff");
|
|
1205
|
+
|
|
1206
|
+
if (this.#theme.highlightCode && !this.transientRenderCache) {
|
|
1207
|
+
for (const highlightedLine of this.#theme.highlightCode(tokenText, lang)) {
|
|
1208
|
+
bodyLines.push(`${codeIndent}${highlightedLine}`);
|
|
1209
|
+
}
|
|
1210
|
+
return bodyLines;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (canStreamDiff) {
|
|
1214
|
+
const closedFence = this.#codeTokenHasClosingFence(token);
|
|
1215
|
+
const lineEnd = tokenText.lastIndexOf("\n");
|
|
1216
|
+
if (closedFence || lineEnd >= 0) {
|
|
1217
|
+
const completedText = closedFence ? tokenText : tokenText.slice(0, lineEnd);
|
|
1218
|
+
for (const highlightedLine of this.#highlightStreamingDiffLines(completedText, lang)) {
|
|
1219
|
+
bodyLines.push(`${codeIndent}${highlightedLine}`);
|
|
1220
|
+
}
|
|
1221
|
+
if (!closedFence) {
|
|
1222
|
+
for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) {
|
|
1223
|
+
bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return bodyLines;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
for (const codeLine of tokenText.split("\n")) {
|
|
1231
|
+
bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
|
|
1232
|
+
}
|
|
1233
|
+
return bodyLines;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
#codeTokenHasClosingFence(token: Token): boolean {
|
|
1237
|
+
const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
|
|
1238
|
+
const firstLineEnd = raw.indexOf("\n");
|
|
1239
|
+
if (firstLineEnd < 0) return false;
|
|
1240
|
+
const openingLine = raw.slice(0, firstLineEnd);
|
|
1241
|
+
const openingTrimmed = openingLine.trimStart();
|
|
1242
|
+
const openingIndent = openingLine.length - openingTrimmed.length;
|
|
1243
|
+
if (openingIndent > 3) return false;
|
|
1244
|
+
const fenceChar = openingTrimmed.charAt(0);
|
|
1245
|
+
if (fenceChar !== "`" && fenceChar !== "~") return false;
|
|
1246
|
+
let fenceLength = 0;
|
|
1247
|
+
while (openingTrimmed.charAt(fenceLength) === fenceChar) fenceLength++;
|
|
1248
|
+
if (fenceLength < 3) return false;
|
|
1249
|
+
|
|
1250
|
+
let lineStart = firstLineEnd + 1;
|
|
1251
|
+
while (lineStart <= raw.length) {
|
|
1252
|
+
const lineEnd = raw.indexOf("\n", lineStart);
|
|
1253
|
+
const line = lineEnd >= 0 ? raw.slice(lineStart, lineEnd) : raw.slice(lineStart);
|
|
1254
|
+
const trimmed = line.trimStart();
|
|
1255
|
+
const indent = line.length - trimmed.length;
|
|
1256
|
+
let closingLength = 0;
|
|
1257
|
+
while (trimmed.charAt(closingLength) === fenceChar) closingLength++;
|
|
1258
|
+
if (indent <= 3 && closingLength >= fenceLength && trimmed.slice(closingLength).trim().length === 0) {
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
if (lineEnd < 0) break;
|
|
1262
|
+
lineStart = lineEnd + 1;
|
|
1263
|
+
}
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
#highlightStreamingDiffLines(completedText: string, lang: string | undefined): readonly string[] {
|
|
1268
|
+
const highlightCode = this.#theme.highlightCode;
|
|
1269
|
+
if (!highlightCode) return [];
|
|
1270
|
+
const signature = this.#activeRenderSignature;
|
|
1271
|
+
const cache = this.#streamingDiffLineCache;
|
|
1272
|
+
if (
|
|
1273
|
+
signature &&
|
|
1274
|
+
cache &&
|
|
1275
|
+
completedText.startsWith(cache.text) &&
|
|
1276
|
+
(cache.text.length === completedText.length || completedText.charCodeAt(cache.text.length) === 0x0a) &&
|
|
1277
|
+
cache.lang === lang &&
|
|
1278
|
+
cache.width === signature.width &&
|
|
1279
|
+
cache.paddingX === signature.paddingX &&
|
|
1280
|
+
cache.paddingY === signature.paddingY &&
|
|
1281
|
+
cache.codeBlockIndent === signature.codeBlockIndent &&
|
|
1282
|
+
cache.themeId === signature.themeId &&
|
|
1283
|
+
cache.defaultTextStyleId === signature.defaultTextStyleId &&
|
|
1284
|
+
cache.imageProtocol === signature.imageProtocol &&
|
|
1285
|
+
cache.hyperlinks === signature.hyperlinks &&
|
|
1286
|
+
cache.textSizing === signature.textSizing &&
|
|
1287
|
+
cache.bgColorProbe === signature.bgColorProbe &&
|
|
1288
|
+
cache.headingProbe === signature.headingProbe
|
|
1289
|
+
) {
|
|
1290
|
+
if (completedText.length === cache.text.length) return cache.lines;
|
|
1291
|
+
const lines = cache.lines.slice();
|
|
1292
|
+
const addedText = completedText.slice(cache.text.length === 0 ? 0 : cache.text.length + 1);
|
|
1293
|
+
for (const codeLine of addedText.split("\n")) {
|
|
1294
|
+
lines.push(...highlightCode(codeLine, lang));
|
|
1295
|
+
}
|
|
1296
|
+
this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
|
|
1297
|
+
return lines;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const lines: string[] = [];
|
|
1301
|
+
for (const codeLine of completedText.split("\n")) {
|
|
1302
|
+
lines.push(...highlightCode(codeLine, lang));
|
|
1303
|
+
}
|
|
1304
|
+
if (signature) {
|
|
1305
|
+
this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
|
|
1306
|
+
}
|
|
1307
|
+
return lines;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1180
1310
|
#renderEmptyPaddingLines(signature: RenderSignature): string[] {
|
|
1181
1311
|
const emptyLine = padding(signature.width);
|
|
1182
1312
|
const emptyLines: string[] = [];
|
|
@@ -1354,17 +1484,8 @@ export class Markdown implements Component {
|
|
|
1354
1484
|
|
|
1355
1485
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
1356
1486
|
lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
for (const hlLine of highlightedLines) {
|
|
1360
|
-
lines.push(`${codeIndent}${hlLine}`);
|
|
1361
|
-
}
|
|
1362
|
-
} else {
|
|
1363
|
-
// Split code by newlines and style each line
|
|
1364
|
-
const codeLines = token.text.split("\n");
|
|
1365
|
-
for (const codeLine of codeLines) {
|
|
1366
|
-
lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
|
|
1367
|
-
}
|
|
1487
|
+
for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
|
|
1488
|
+
lines.push(bodyLine);
|
|
1368
1489
|
}
|
|
1369
1490
|
lines.push(this.#theme.codeBlockBorder("```"));
|
|
1370
1491
|
if (nextTokenType && nextTokenType !== "space") {
|
|
@@ -1744,16 +1865,8 @@ export class Markdown implements Component {
|
|
|
1744
1865
|
// Code block in list item
|
|
1745
1866
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
1746
1867
|
lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
for (const hlLine of highlightedLines) {
|
|
1750
|
-
lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
|
|
1751
|
-
}
|
|
1752
|
-
} else {
|
|
1753
|
-
const codeLines = token.text.split("\n");
|
|
1754
|
-
for (const codeLine of codeLines) {
|
|
1755
|
-
lines.push({ text: `${codeIndent}${this.#theme.codeBlock(codeLine)}`, nested: false });
|
|
1756
|
-
}
|
|
1868
|
+
for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
|
|
1869
|
+
lines.push({ text: bodyLine, nested: false });
|
|
1757
1870
|
}
|
|
1758
1871
|
lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
|
|
1759
1872
|
} else if (isMathToken(token)) {
|
package/src/terminal.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
$env,
|
|
5
|
+
isBunTestRuntime,
|
|
6
|
+
isTerminalHeadless,
|
|
7
|
+
logger,
|
|
8
|
+
restoreTerminalStderr,
|
|
9
|
+
suppressTerminalStderr,
|
|
10
|
+
} from "jeopi-utils";
|
|
4
11
|
import { setKittyProtocolActive } from "./keys";
|
|
5
12
|
import { StdinBuffer } from "./stdin-buffer";
|
|
6
13
|
import {
|
|
@@ -261,6 +268,9 @@ function createConsoleCodepageGuard(): (() => void) | null {
|
|
|
261
268
|
*/
|
|
262
269
|
export function emergencyTerminalRestore(): void {
|
|
263
270
|
try {
|
|
271
|
+
// Crash paths must surface subsequent stderr (fatal reports) on the
|
|
272
|
+
// real terminal; no-op when the stderr guard is inactive.
|
|
273
|
+
restoreTerminalStderr();
|
|
264
274
|
const terminal = activeTerminal;
|
|
265
275
|
if (terminal) {
|
|
266
276
|
terminal.stop();
|
|
@@ -523,6 +533,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
523
533
|
activeTerminal = this;
|
|
524
534
|
terminalEverStarted = true;
|
|
525
535
|
|
|
536
|
+
// Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
|
|
537
|
+
// the viewport while we own the terminal; released in stop(). See
|
|
538
|
+
// stderr-guard in jeopi-utils (mirrors openai/codex#24459).
|
|
539
|
+
suppressTerminalStderr();
|
|
540
|
+
|
|
526
541
|
// Save previous state and enable raw mode
|
|
527
542
|
this.#wasRaw = process.stdin.isRaw || false;
|
|
528
543
|
if (process.stdin.setRawMode) {
|
|
@@ -1235,6 +1250,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
1235
1250
|
activeTerminal = null;
|
|
1236
1251
|
}
|
|
1237
1252
|
|
|
1253
|
+
// Release terminal ownership of fd 2 first so external programs,
|
|
1254
|
+
// suspend, and shutdown see the real stderr even if a later teardown
|
|
1255
|
+
// step throws.
|
|
1256
|
+
restoreTerminalStderr();
|
|
1257
|
+
|
|
1238
1258
|
if (this.#clearProgressTimer()) {
|
|
1239
1259
|
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
|
1240
1260
|
}
|
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 {
|
|
@@ -2155,12 +2303,12 @@ export class TUI extends Container {
|
|
|
2155
2303
|
}
|
|
2156
2304
|
|
|
2157
2305
|
#handleInput(data: string): void {
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
2160
|
-
//
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2306
|
+
// Ctrl+C/Esc use app-level double-press windows. Give those gestures one
|
|
2307
|
+
// frame to drain queued input before an ordinary repaint; delaying every
|
|
2308
|
+
// key would make idle navigation pay a full frame of latency.
|
|
2309
|
+
if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
|
|
2310
|
+
this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS;
|
|
2311
|
+
}
|
|
2164
2312
|
if (this.#inputListeners.size > 0) {
|
|
2165
2313
|
let current = data;
|
|
2166
2314
|
for (const listener of this.#inputListeners) {
|
|
@@ -2612,18 +2760,19 @@ export class TUI extends Container {
|
|
|
2612
2760
|
// alternate screen to repaint the whole transcript on the normal
|
|
2613
2761
|
// screen — then the next SIGWINCH re-enters the alt screen and paints
|
|
2614
2762
|
// only the tail, so the block flashes in for one frame and vanishes.
|
|
2615
|
-
// A
|
|
2616
|
-
//
|
|
2617
|
-
//
|
|
2618
|
-
//
|
|
2619
|
-
//
|
|
2620
|
-
//
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
)
|
|
2763
|
+
// A FORCED render mid-drag (tool finalization, resetDisplay, image
|
|
2764
|
+
// reconciliation) also stays on the fast path: preempting would leave
|
|
2765
|
+
// the borrowed alternate screen and run the geometry-rebuild full paint
|
|
2766
|
+
// on the normal screen — ED3 plus an O(history) replay that visibly
|
|
2767
|
+
// scrolls the whole transcript through the viewport, once per forced
|
|
2768
|
+
// render and once more at settle. The forced intent is not lost: the
|
|
2769
|
+
// fast path consumes neither #forceViewportRepaintOnNextRender nor
|
|
2770
|
+
// #clearScrollbackOnNextRender, and the settle's authoritative
|
|
2771
|
+
// requestRender(true) honors both — same fold-into-the-settle contract
|
|
2772
|
+
// as the multiplexer resize debounce. A visible overlay composites over
|
|
2773
|
+
// the transcript and needs the whole window, so it falls through
|
|
2774
|
+
// (overlay resizes are not on the drag-cost hot path).
|
|
2775
|
+
if (this.#resizeViewportActive && this.#hasEverRendered && this.#getTopmostVisibleOverlay() === undefined) {
|
|
2627
2776
|
this.#componentRenderTargets.clear();
|
|
2628
2777
|
this.#renderResizeViewport(width, height);
|
|
2629
2778
|
return;
|
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
|
|