@oh-my-pi/pi-tui 18.4.1 → 18.4.2
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 +14 -0
- package/dist/types/chrome/transcript-container.d.ts +15 -1
- package/dist/types/overlays/model-browser.d.ts +15 -0
- package/dist/types/overlays/rewind-selector.d.ts +2 -1
- package/dist/types/prompt/composer-attachments.d.ts +7 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
- package/dist/types/render/width-aware-text.d.ts +6 -0
- package/dist/types/terminal.d.ts +7 -0
- package/dist/types/theme/color.d.ts +1 -0
- package/dist/types/theme/session-color.d.ts +3 -3
- package/dist/types/theme/shimmer.d.ts +1 -1
- package/dist/types/theme/theme-class.d.ts +1 -1
- package/dist/types/tui.d.ts +3 -2
- package/package.json +9 -9
- package/src/chat/tool-execution.ts +24 -4
- package/src/chrome/transcript-container.ts +76 -14
- package/src/components/editor.ts +28 -6
- package/src/components/image.ts +15 -7
- package/src/components/loader.ts +37 -17
- package/src/overlays/agent-hub.ts +16 -6
- package/src/overlays/copy-selector.ts +5 -0
- package/src/overlays/model-browser.ts +106 -25
- package/src/overlays/rewind-selector.ts +10 -2
- package/src/prompt/composer-attachments.ts +54 -7
- package/src/prompt/composer.ts +5 -2
- package/src/prompt/custom-editor.ts +24 -6
- package/src/prompt/model-mention-autocomplete.ts +22 -10
- package/src/render/width-aware-text.ts +9 -0
- package/src/status-line/component.ts +213 -132
- package/src/status-line/segments.ts +34 -29
- package/src/terminal.ts +37 -11
- package/src/theme/color.ts +15 -0
- package/src/theme/session-color.ts +26 -4
- package/src/theme/shimmer.ts +55 -76
- package/src/theme/theme-class.ts +29 -12
- package/src/tui.ts +142 -112
- package/src/utils.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.4.2] - 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Optimized activity clock rendering by caching timestamp formatting strings
|
|
10
|
+
- Reduced CPU overhead during animated terminal redraws, transcript updates, inline-image bookkeeping, and status-line layout.
|
|
11
|
+
- Reduced per-keystroke CPU in `^` model-mention autocomplete and per-frame CPU while streaming (status line, live transcript blocks, tool cards, frame writes).
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Fixed the `@` completion popup swallowing Tab and cursor-movement keys while a narrowed filter matched nothing: with no candidate to accept they now fall through to their normal completion and cursor roles instead of being trapped ([#13046](https://github.com/can1357/oh-my-pi/pull/13046) by [@jchanghong023](https://github.com/jchanghong023)).
|
|
16
|
+
- Fixed the Esc-Esc rewind and fullscreen `/copy` selectors getting stuck at the oldest turn of a long session's recent tail; stepping past it now loads the earlier history
|
|
17
|
+
- Fixed the composer attachment band showing chip `#1` (and other prefix IDs) as still present when only `#10` remained in the prompt ([#13605](https://github.com/can1357/oh-my-pi/issues/13605))
|
|
18
|
+
|
|
5
19
|
## [18.4.1] - 2026-09-28
|
|
6
20
|
|
|
7
21
|
### Breaking Changes
|
|
@@ -96,6 +96,16 @@ export declare class TranscriptContainer extends Container {
|
|
|
96
96
|
* quit is pure write volume. An already offered replay batch stays valid.
|
|
97
97
|
*/
|
|
98
98
|
cancelReplay(): void;
|
|
99
|
+
/**
|
|
100
|
+
* Open one composed frame: every live-block measurement until this frame's
|
|
101
|
+
* {@link renderViewport} returns renders against `frame` and is taken once,
|
|
102
|
+
* so the retirement peek and the viewport share each block's render.
|
|
103
|
+
*
|
|
104
|
+
* Callers MUST call {@link renderViewport} with the same `frame` in the same
|
|
105
|
+
* synchronous composition, without mutating any transcript block in
|
|
106
|
+
* between: the shared rows are only as fresh as that first measurement.
|
|
107
|
+
*/
|
|
108
|
+
beginFrame(frame: AnimationFrame): void;
|
|
99
109
|
/**
|
|
100
110
|
* Total rows the live, un-emitted tail occupies at `width`.
|
|
101
111
|
*
|
|
@@ -107,7 +117,11 @@ export declare class TranscriptContainer extends Container {
|
|
|
107
117
|
liveRowCount(width: number, limit?: number): number;
|
|
108
118
|
/** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
|
|
109
119
|
getLastViewportSpans(): readonly TranscriptViewportSpan[];
|
|
110
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Render the live tail, constrained to the supplied transcript height.
|
|
122
|
+
* Closes the frame {@link beginFrame} opened; a different `frame` discards
|
|
123
|
+
* its measurements first.
|
|
124
|
+
*/
|
|
111
125
|
renderViewport(width: number, rows: number, frame: AnimationFrame): readonly string[];
|
|
112
126
|
/** Offers stable-head emission or the shortest finalized prefix needed under pressure. */
|
|
113
127
|
peekFinalizedBatch(width: number, capacity: number): HistoryBatch | undefined;
|
|
@@ -37,6 +37,11 @@ export interface ResolvedModelRoleValue {
|
|
|
37
37
|
}
|
|
38
38
|
/** Host-provided preferences and model-role resolution for the browser. */
|
|
39
39
|
export interface ModelBrowserSource extends ModelRoleLookup {
|
|
40
|
+
/**
|
|
41
|
+
* Changes whenever any preference this source reads or resolves against changes,
|
|
42
|
+
* except the storage-backed `mruOrder` and `modelPerf`. Keys derived-scope caches.
|
|
43
|
+
*/
|
|
44
|
+
readonly revision: number;
|
|
40
45
|
readonly defaultThinkingLevel: string;
|
|
41
46
|
readonly modelProviderOrder: readonly string[];
|
|
42
47
|
readonly knownRoleIds: readonly string[];
|
|
@@ -104,6 +109,16 @@ export interface SessionModelScope {
|
|
|
104
109
|
}
|
|
105
110
|
/** Build the session picker's current scope without creating an interactive browser. */
|
|
106
111
|
export declare function buildSessionModelScope(settings: ModelBrowserSource, registry: ModelBrowserRegistry, scopedModels: ReadonlyArray<Model>): SessionModelScope;
|
|
112
|
+
/**
|
|
113
|
+
* {@link buildSessionModelScope} for per-keystroke callers: returns the same
|
|
114
|
+
* scope until the source revision, MRU order, scoped models, or the registry's
|
|
115
|
+
* available models, catalog, or load error change.
|
|
116
|
+
*/
|
|
117
|
+
export declare class SessionModelScopeCache {
|
|
118
|
+
#private;
|
|
119
|
+
constructor(settings: ModelBrowserSource, registry: ModelBrowserRegistry);
|
|
120
|
+
get(scopedModels: ReadonlyArray<Model>): SessionModelScope;
|
|
121
|
+
}
|
|
107
122
|
/** User affinity used to order search matches within one relevance tier. */
|
|
108
123
|
export interface SearchAffinity {
|
|
109
124
|
/** `provider/id` (lowercased) → rank; configured-role models first, then MRU. */
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* variants at a fork and jump between user turns elsewhere, `f` opens a
|
|
23
23
|
* filter (typing narrows the current path to matching items; Esc leaves the
|
|
24
24
|
* filter with the selection kept), Enter rewinds to the outlined item, A loads
|
|
25
|
-
* earlier turns without changing selection
|
|
25
|
+
* earlier turns without changing selection (stepping above the oldest replayed
|
|
26
|
+
* turn loads them too), Esc cancels.
|
|
26
27
|
*/
|
|
27
28
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
28
29
|
import { type Component, type TUI } from "../index.js";
|
|
@@ -37,6 +37,13 @@ export declare function attachmentSgr(kind: ChipKind, n: number): string;
|
|
|
37
37
|
export declare const PLACEHOLDER_REGEX: RegExp;
|
|
38
38
|
/** Matches an expanded attachment marker, a compact attachment chip, or a skill chip. */
|
|
39
39
|
export declare const COMPOSER_TOKEN_REGEX: RegExp;
|
|
40
|
+
/**
|
|
41
|
+
* Attachment indices referenced by a composer buffer, per kind. Image/video count compact chips
|
|
42
|
+
* and expanded markers; paste counts compact chips only, since `[Paste #N]` markers number the
|
|
43
|
+
* base editor's separate paste buffer. Registered labels take precedence over theme glyphs so
|
|
44
|
+
* tokens created before a theme switch keep their kind; full numbers prevent `#1` matching `#10`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function referencedAttachments(text: string, recorded: ReadonlyMap<string, ChipKind>): Record<ChipKind, Set<number>>;
|
|
40
47
|
/** Add the registered model-chip labels to the composer placeholder matcher. */
|
|
41
48
|
export declare function composerTokenRegex(mentionLabels: Iterable<string>): RegExp;
|
|
42
49
|
/** Offsets image marker indices, including matching `attachment://` references. */
|
|
@@ -17,7 +17,10 @@ export declare function applyModelMentionCompletion(lines: string[], cursorLine:
|
|
|
17
17
|
cursorLine: number;
|
|
18
18
|
cursorCol: number;
|
|
19
19
|
};
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Create a session-scoped model candidate lookup using picker ordering. The
|
|
22
|
+
* scope and search affinity are reused across queries until their inputs change.
|
|
23
|
+
*/
|
|
21
24
|
export declare function createModelMentionSource(host: {
|
|
22
25
|
source: ModelBrowserSource;
|
|
23
26
|
registry: ModelBrowserRegistry;
|
|
@@ -19,5 +19,11 @@ export declare class WidthAwareText implements Component {
|
|
|
19
19
|
setCustomBgFn(customBgFn?: (text: string) => string): void;
|
|
20
20
|
setIgnoreTight(ignore: boolean): this;
|
|
21
21
|
invalidate(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Re-run the formatter on the next render because its inputs changed.
|
|
24
|
+
* Unlike {@link invalidate}, the inner `Text` keeps its wrap cache, so a
|
|
25
|
+
* reformat that yields the same string skips re-wrapping and re-tinting.
|
|
26
|
+
*/
|
|
27
|
+
reformat(): void;
|
|
22
28
|
render(width: number): readonly string[];
|
|
23
29
|
}
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -276,6 +276,13 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
276
276
|
stop(): void;
|
|
277
277
|
write(data: string): void;
|
|
278
278
|
get columns(): number;
|
|
279
|
+
/**
|
|
280
|
+
* With the output pump, a backlog bound at or below
|
|
281
|
+
* {@link STDOUT_BACKLOG_CLEAR_BYTES} is reported as-is instead of re-read:
|
|
282
|
+
* both consumers (the render gate and the stall watchdog) act only on a
|
|
283
|
+
* backlog above that level, so the bound already decides them and spares a
|
|
284
|
+
* native read on every frame.
|
|
285
|
+
*/
|
|
279
286
|
get pendingOutputBytes(): number;
|
|
280
287
|
get hostOwnsGridOnResize(): boolean;
|
|
281
288
|
get rows(): number;
|
|
@@ -5,6 +5,7 @@ export declare const FG_RESET = "\u001B[39m";
|
|
|
5
5
|
export declare const BG_RESET = "\u001B[49m";
|
|
6
6
|
/** Resolve theme color depth from the shared terminal capability model. */
|
|
7
7
|
export declare function detectColorMode(env?: NodeJS.ProcessEnv): ColorMode;
|
|
8
|
+
/** Convert a theme color to foreground SGR at the requested depth; throws for invalid colors. */
|
|
8
9
|
export declare function colorToAnsi(color: string, mode: ColorMode): string;
|
|
9
10
|
export declare function fgAnsi(color: string | number, mode: ColorMode): string;
|
|
10
11
|
export declare function bgAnsi(color: string | number, mode: ColorMode): string;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/** Theme-derived inputs for {@link getSessionAccentHex}; see `Theme.sessionAccentInputs`. */
|
|
2
2
|
export interface SessionAccentTheme {
|
|
3
3
|
/** Theme accent hex; the session accent adopts its OKLCH lightness and chroma. */
|
|
4
|
-
accentHex: string;
|
|
4
|
+
readonly accentHex: string;
|
|
5
5
|
/** Major theme color hexes checked for hue collision. */
|
|
6
|
-
colorHexes: string[];
|
|
6
|
+
readonly colorHexes: readonly string[];
|
|
7
7
|
/** WCAG luminance of the status-line surface on light themes; undefined on dark themes. */
|
|
8
|
-
surfaceLuminance?: number;
|
|
8
|
+
readonly surfaceLuminance?: number;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
11
|
* Derive a stable CSS hex accent color from a session name and the active theme.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme, ThemeColor } from "./theme.js";
|
|
2
2
|
type ShimmerTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
|
|
3
|
-
/** Sweep style for animated shimmer text; `disabled` renders every tier as the
|
|
3
|
+
/** Sweep style for animated shimmer text; `disabled` renders every tier as the mid color. */
|
|
4
4
|
export type ShimmerMode = "classic" | "kitt" | "disabled";
|
|
5
5
|
/** Select the shimmer sweep style. The host pushes its `display.shimmer` preference here. */
|
|
6
6
|
export declare function setShimmerMode(mode: ShimmerMode): void;
|
|
@@ -49,7 +49,7 @@ export declare class Theme {
|
|
|
49
49
|
* Theme-derived inputs for `getSessionAccentHex`: the accent hex whose
|
|
50
50
|
* OKLCH weight session accents adopt, the major colors they must not
|
|
51
51
|
* hue-collide with, and the light-theme surface luminance to contrast
|
|
52
|
-
* against.
|
|
52
|
+
* against. Built once per instance and frozen; callers share the object.
|
|
53
53
|
*/
|
|
54
54
|
get sessionAccentInputs(): SessionAccentTheme;
|
|
55
55
|
fg(color: ThemeColor, text: string): string;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -273,8 +273,9 @@ export declare class Container implements Component {
|
|
|
273
273
|
/**
|
|
274
274
|
* Merge runs of byte-adjacent SGR sequences (`CSI [0-9;:]* m`) into one. Only
|
|
275
275
|
* CSI-SGR sequences are touched; text, cursor moves, OSC, hyperlinks and image
|
|
276
|
-
* payloads pass through verbatim.
|
|
277
|
-
*
|
|
276
|
+
* payloads pass through verbatim. Isolated sequences require no parameter
|
|
277
|
+
* slices or arrays; output is built only at merged boundaries or empty resets
|
|
278
|
+
* that need normalization within an adjacent run.
|
|
278
279
|
*/
|
|
279
280
|
export declare function coalesceAdjacentSgr(line: string): string;
|
|
280
281
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.4.
|
|
4
|
+
"version": "18.4.2",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@oh-my-pi/omptype": "18.4.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.4.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.4.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.4.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.4.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.4.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.4.
|
|
44
|
+
"@oh-my-pi/omptype": "18.4.2",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "18.4.2",
|
|
46
|
+
"@oh-my-pi/pi-ai": "18.4.2",
|
|
47
|
+
"@oh-my-pi/pi-catalog": "18.4.2",
|
|
48
|
+
"@oh-my-pi/pi-natives": "18.4.2",
|
|
49
|
+
"@oh-my-pi/pi-utils": "18.4.2",
|
|
50
|
+
"@oh-my-pi/pi-wire": "18.4.2",
|
|
51
|
+
"@oh-my-pi/snapcompact": "18.4.2"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"kitty-vt-wasm": "^0.2.0"
|
|
@@ -285,6 +285,14 @@ export class ToolExecutionComponent extends Container {
|
|
|
285
285
|
// so a terminal resize re-shapes image-bearing results to rescale them without
|
|
286
286
|
// forcing the common image-free result to re-shape on every resize tick.
|
|
287
287
|
#renderedImageCount = 0;
|
|
288
|
+
// `stateBgKey|themeEpoch` of the tint last handed to #contentText. Re-tinting
|
|
289
|
+
// drops its wrap cache, so a rebuild whose tint is unchanged skips it and the
|
|
290
|
+
// inner Text re-wraps only when the reformatted card text actually differs.
|
|
291
|
+
#contentTextBgKey: string | undefined;
|
|
292
|
+
// Memoized #getTextOutput(), keyed by every input it reads: the result
|
|
293
|
+
// (versioned by #resultVersion), #showImages, and the image protocol.
|
|
294
|
+
#textOutput = "";
|
|
295
|
+
#textOutputKey: string | undefined;
|
|
288
296
|
#tool?: AgentTool;
|
|
289
297
|
#renderer?: ToolRenderer;
|
|
290
298
|
#ui: ToolExecutionUi;
|
|
@@ -1200,8 +1208,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
1200
1208
|
// Generic fallback (no custom/built-in renderer). WidthAwareText
|
|
1201
1209
|
// reformats at render time so output fills the actual terminal width
|
|
1202
1210
|
// instead of a fixed column cap.
|
|
1203
|
-
this.#
|
|
1204
|
-
this.#contentText.invalidate();
|
|
1211
|
+
this.#refreshContentText(stateBgKey, stateBgFn);
|
|
1205
1212
|
}
|
|
1206
1213
|
|
|
1207
1214
|
// Handle images (same for both custom and built-in)
|
|
@@ -1339,6 +1346,8 @@ export class ToolExecutionComponent extends Container {
|
|
|
1339
1346
|
|
|
1340
1347
|
#getTextOutput(): string {
|
|
1341
1348
|
if (!this.#result) return "";
|
|
1349
|
+
const key = `${this.#resultVersion}|${this.#showImages}|${TERMINAL.imageProtocol ?? "-"}`;
|
|
1350
|
+
if (key === this.#textOutputKey) return this.#textOutput;
|
|
1342
1351
|
|
|
1343
1352
|
const textBlocks = this.#result.content.filter(c => c.type === "text");
|
|
1344
1353
|
const imageBlocks = this.#getAllImageBlocks();
|
|
@@ -1360,9 +1369,21 @@ export class ToolExecutionComponent extends Container {
|
|
|
1360
1369
|
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
|
|
1361
1370
|
}
|
|
1362
1371
|
|
|
1372
|
+
this.#textOutputKey = key;
|
|
1373
|
+
this.#textOutput = output;
|
|
1363
1374
|
return output;
|
|
1364
1375
|
}
|
|
1365
1376
|
|
|
1377
|
+
/** Re-tint (only when the tint changed) and reformat the generic #contentText card. */
|
|
1378
|
+
#refreshContentText(stateBgKey: string, stateBgFn: (text: string) => string): void {
|
|
1379
|
+
const bgKey = `${stateBgKey}|${getThemeEpoch()}`;
|
|
1380
|
+
if (bgKey !== this.#contentTextBgKey) {
|
|
1381
|
+
this.#contentTextBgKey = bgKey;
|
|
1382
|
+
this.#contentText.setCustomBgFn(stateBgFn);
|
|
1383
|
+
}
|
|
1384
|
+
this.#contentText.reformat();
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1366
1387
|
/**
|
|
1367
1388
|
* Format the generic call/result card at `contentWidth`. Shared by the
|
|
1368
1389
|
* #contentText fallback and the benign-skip path so both render identically.
|
|
@@ -1406,8 +1427,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
1406
1427
|
*/
|
|
1407
1428
|
#renderBenignSkipCard(stateBgFn: (text: string) => string): void {
|
|
1408
1429
|
if (!this.#usesContentBox) {
|
|
1409
|
-
this.#
|
|
1410
|
-
this.#contentText.invalidate();
|
|
1430
|
+
this.#refreshContentText("toolPendingBg", stateBgFn);
|
|
1411
1431
|
return;
|
|
1412
1432
|
}
|
|
1413
1433
|
for (const box of this.#multiFileBoxes) {
|
|
@@ -174,6 +174,21 @@ export class TranscriptContainer extends Container {
|
|
|
174
174
|
#pinnedFrontier: { index: number; since: number; logged: boolean } | undefined;
|
|
175
175
|
/** Block spans of the last `renderViewport` output, for click hit-testing. */
|
|
176
176
|
#lastViewportSpans: TranscriptViewportSpan[] = [];
|
|
177
|
+
/**
|
|
178
|
+
* The composed frame {@link beginFrame} opened; `undefined` outside one.
|
|
179
|
+
* {@link renderViewport} closes it, so it never outlives the synchronous
|
|
180
|
+
* composition that opened it.
|
|
181
|
+
*/
|
|
182
|
+
#openFrame: AnimationFrame | undefined;
|
|
183
|
+
/**
|
|
184
|
+
* Full-allocation blank-trimmed renders of the blocks measured during the
|
|
185
|
+
* open frame, keyed by entry at {@link #frameRowsWidth}. A retirement peek
|
|
186
|
+
* and the viewport measure the same live blocks back to back inside one
|
|
187
|
+
* composition, with no block mutation possible in between; replaying the
|
|
188
|
+
* first measurement spares every block its second render per frame.
|
|
189
|
+
*/
|
|
190
|
+
#frameRows = new Map<TranscriptEntry, readonly string[]>();
|
|
191
|
+
#frameRowsWidth = 0;
|
|
177
192
|
override addChild(component: Component): void {
|
|
178
193
|
if (isToolActivityComponent(component)) component.setToolActivityVisible(this.#toolActivityVisible);
|
|
179
194
|
super.addChild(component);
|
|
@@ -300,6 +315,21 @@ export class TranscriptContainer extends Container {
|
|
|
300
315
|
this.#replayRequested = false;
|
|
301
316
|
}
|
|
302
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Open one composed frame: every live-block measurement until this frame's
|
|
320
|
+
* {@link renderViewport} returns renders against `frame` and is taken once,
|
|
321
|
+
* so the retirement peek and the viewport share each block's render.
|
|
322
|
+
*
|
|
323
|
+
* Callers MUST call {@link renderViewport} with the same `frame` in the same
|
|
324
|
+
* synchronous composition, without mutating any transcript block in
|
|
325
|
+
* between: the shared rows are only as fresh as that first measurement.
|
|
326
|
+
*/
|
|
327
|
+
beginFrame(frame: AnimationFrame): void {
|
|
328
|
+
this.#lastFrame = frame;
|
|
329
|
+
this.#frameRows.clear();
|
|
330
|
+
this.#openFrame = frame;
|
|
331
|
+
}
|
|
332
|
+
|
|
303
333
|
/**
|
|
304
334
|
* Total rows the live, un-emitted tail occupies at `width`.
|
|
305
335
|
*
|
|
@@ -322,8 +352,33 @@ export class TranscriptContainer extends Container {
|
|
|
322
352
|
|
|
323
353
|
/** One live block's un-emitted rows at `width`, rendered against its full-height allocation. */
|
|
324
354
|
#liveBlockRows(entry: TranscriptEntry, index: number, width: number): readonly string[] {
|
|
355
|
+
const rows = this.#measuredRows(entry, width);
|
|
356
|
+
const emitted = this.#projectedEmittedRowCount(entry, index, width);
|
|
357
|
+
return emitted === 0 ? rows : rows.slice(emitted);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* One block's blank-trimmed render at its full-height allocation. Inside an
|
|
362
|
+
* open frame the first measurement of each block is replayed to later ones.
|
|
363
|
+
*/
|
|
364
|
+
#measuredRows(entry: TranscriptEntry, width: number): readonly string[] {
|
|
325
365
|
this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
326
|
-
|
|
366
|
+
if (this.#openFrame === undefined) return this.#renderEntry(entry, width);
|
|
367
|
+
if (this.#frameRowsWidth !== width) {
|
|
368
|
+
this.#frameRows.clear();
|
|
369
|
+
this.#frameRowsWidth = width;
|
|
370
|
+
}
|
|
371
|
+
let rows = this.#frameRows.get(entry);
|
|
372
|
+
if (rows === undefined) {
|
|
373
|
+
rows = this.#renderEntry(entry, width);
|
|
374
|
+
this.#frameRows.set(entry, rows);
|
|
375
|
+
}
|
|
376
|
+
return rows;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#closeFrame(): void {
|
|
380
|
+
this.#openFrame = undefined;
|
|
381
|
+
this.#frameRows.clear();
|
|
327
382
|
}
|
|
328
383
|
|
|
329
384
|
/** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
|
|
@@ -349,9 +404,22 @@ export class TranscriptContainer extends Container {
|
|
|
349
404
|
this.#lastViewportSpans = spans;
|
|
350
405
|
}
|
|
351
406
|
|
|
352
|
-
/**
|
|
407
|
+
/**
|
|
408
|
+
* Render the live tail, constrained to the supplied transcript height.
|
|
409
|
+
* Closes the frame {@link beginFrame} opened; a different `frame` discards
|
|
410
|
+
* its measurements first.
|
|
411
|
+
*/
|
|
353
412
|
renderViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
|
|
413
|
+
if (frame !== this.#openFrame) this.#closeFrame();
|
|
354
414
|
this.#lastFrame = frame;
|
|
415
|
+
try {
|
|
416
|
+
return this.#composeViewport(width, rows, frame);
|
|
417
|
+
} finally {
|
|
418
|
+
this.#closeFrame();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#composeViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
|
|
355
423
|
this.#syncEntries();
|
|
356
424
|
this.#settleFinalized();
|
|
357
425
|
const live = this.#liveEntries();
|
|
@@ -522,7 +590,7 @@ export class TranscriptContainer extends Container {
|
|
|
522
590
|
// Only a render publishes a block's stable rows, so the head renders
|
|
523
591
|
// before its progressive-append eligibility is read.
|
|
524
592
|
const head = this.#entries[this.#frontier];
|
|
525
|
-
if (head !== undefined) this.#
|
|
593
|
+
if (head !== undefined) this.#measuredRows(head, width);
|
|
526
594
|
const appendHead =
|
|
527
595
|
policy === "pressure" &&
|
|
528
596
|
head?.mode === "appendOnly" &&
|
|
@@ -723,15 +791,10 @@ export class TranscriptContainer extends Container {
|
|
|
723
791
|
return this.#freezeStableRows(entry, rendered, "stable rows changed within a width epoch");
|
|
724
792
|
}
|
|
725
793
|
entry.stableRows = published;
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
if (
|
|
731
|
-
priorRows === undefined ||
|
|
732
|
-
priorRows.length !== stableRendered.length ||
|
|
733
|
-
!isRowPrefix(priorRows, stableRendered)
|
|
734
|
-
) {
|
|
794
|
+
// The prefix check above proves equal-length rows are byte-identical.
|
|
795
|
+
// Reuse our snapshot then; copy new rows so mutable renderer buffers
|
|
796
|
+
// cannot change the bytes checked on the next frame.
|
|
797
|
+
if (priorRender === undefined || priorRender.length !== stableRendered.length) {
|
|
735
798
|
entry.renderedStableByWidth.set(width, stableRendered.slice());
|
|
736
799
|
}
|
|
737
800
|
let perCount = entry.stableRowCountByWidth.get(width);
|
|
@@ -858,8 +921,7 @@ export class TranscriptContainer extends Container {
|
|
|
858
921
|
while (this.#frontier < this.#entries.length) {
|
|
859
922
|
const entry = this.#entries[this.#frontier]!;
|
|
860
923
|
if (entry.mode !== "appendOnly" || entry.state !== "settled") return;
|
|
861
|
-
this.#
|
|
862
|
-
const rendered = this.#renderEntry(entry, width);
|
|
924
|
+
const rendered = this.#measuredRows(entry, width);
|
|
863
925
|
if (entry.emitted !== entry.stableRows.length) return;
|
|
864
926
|
if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
|
|
865
927
|
this.#retireEntry(entry);
|
package/src/components/editor.ts
CHANGED
|
@@ -580,6 +580,8 @@ export class Editor implements Component, Focusable {
|
|
|
580
580
|
#wrapCache = new Map<string, WrapEntry>();
|
|
581
581
|
#wrapCacheWidth = -1;
|
|
582
582
|
#wrapCacheEpoch = -1;
|
|
583
|
+
/** Last `#getPromptGutter` result, keyed by (gutter string, clamped gutter width). */
|
|
584
|
+
#promptGutterCache: { source: string; firstLine: string; continuation: string; width: number } | undefined;
|
|
583
585
|
#paddingXOverride: number | undefined;
|
|
584
586
|
#maxHeight?: number;
|
|
585
587
|
#scrollOffset: number = 0;
|
|
@@ -1075,11 +1077,16 @@ export class Editor implements Component, Focusable {
|
|
|
1075
1077
|
if (!gutter) return undefined;
|
|
1076
1078
|
const gutterWidth = this.#getPromptGutterWidth(width, paddingX);
|
|
1077
1079
|
if (gutterWidth === 0) return undefined;
|
|
1078
|
-
|
|
1080
|
+
const cached = this.#promptGutterCache;
|
|
1081
|
+
if (cached !== undefined && cached.source === gutter && cached.width === gutterWidth) return cached;
|
|
1082
|
+
const next = {
|
|
1083
|
+
source: gutter,
|
|
1079
1084
|
firstLine: sliceByColumn(gutter, 0, gutterWidth, true),
|
|
1080
1085
|
continuation: padding(gutterWidth),
|
|
1081
1086
|
width: gutterWidth,
|
|
1082
1087
|
};
|
|
1088
|
+
this.#promptGutterCache = next;
|
|
1089
|
+
return next;
|
|
1083
1090
|
}
|
|
1084
1091
|
|
|
1085
1092
|
#getContentWidth(width: number, paddingX: number): number {
|
|
@@ -1682,9 +1689,16 @@ export class Editor implements Component, Focusable {
|
|
|
1682
1689
|
kb.matchesCanonical(canonical, "tui.select.pageUp") ||
|
|
1683
1690
|
kb.matchesCanonical(canonical, "tui.select.pageDown")
|
|
1684
1691
|
) {
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1692
|
+
// An `@` popup whose narrowing filter matched nothing holds no candidate;
|
|
1693
|
+
// let the key fall through instead of swallowing it.
|
|
1694
|
+
if (!this.#autocompleteList.getSelectedItem()) {
|
|
1695
|
+
this.#cancelAutocomplete();
|
|
1696
|
+
this.onAutocompleteUpdate?.();
|
|
1697
|
+
} else {
|
|
1698
|
+
this.#autocompleteList.handleInput(data);
|
|
1699
|
+
this.onAutocompleteUpdate?.();
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1688
1702
|
}
|
|
1689
1703
|
|
|
1690
1704
|
// If Tab was pressed, always apply the selection
|
|
@@ -1699,7 +1713,14 @@ export class Editor implements Component, Focusable {
|
|
|
1699
1713
|
this.#cancelAutocomplete();
|
|
1700
1714
|
return;
|
|
1701
1715
|
}
|
|
1702
|
-
if (selected
|
|
1716
|
+
if (!selected) {
|
|
1717
|
+
// An `@` popup whose narrowing filter matched nothing stays open with no
|
|
1718
|
+
// candidate (see #debouncedUpdateAutocomplete). Nothing to accept: cancel the
|
|
1719
|
+
// popup and fall through so Tab keeps its normal completion role and a right
|
|
1720
|
+
// arrow at end of line moves the cursor.
|
|
1721
|
+
this.#cancelAutocomplete();
|
|
1722
|
+
this.onAutocompleteUpdate?.();
|
|
1723
|
+
} else if (this.#autocompleteProvider) {
|
|
1703
1724
|
const shouldChainAutocomplete =
|
|
1704
1725
|
this.#isSlashCommandNameAutocompleteSelection() || isDirectoryCompletionValue(selected.value);
|
|
1705
1726
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
@@ -1725,7 +1746,8 @@ export class Editor implements Component, Focusable {
|
|
|
1725
1746
|
queueMicrotask(() => void this.#tryTriggerAutocomplete());
|
|
1726
1747
|
}
|
|
1727
1748
|
}
|
|
1728
|
-
|
|
1749
|
+
// Only an accepted candidate consumes the key; an empty list falls through.
|
|
1750
|
+
if (selected) return;
|
|
1729
1751
|
}
|
|
1730
1752
|
|
|
1731
1753
|
// If Enter was pressed on a submitted slash command (not an absolute-path
|
package/src/components/image.ts
CHANGED
|
@@ -85,7 +85,7 @@ interface SurfaceSplit {
|
|
|
85
85
|
* id so a partial pass reproduces the on-screen live/text split without a
|
|
86
86
|
* full, correctly-ordered walk.
|
|
87
87
|
*/
|
|
88
|
-
suppressedIds: Set<number>;
|
|
88
|
+
readonly suppressedIds: Set<number>;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function newSurfaceSplit(): SurfaceSplit {
|
|
@@ -97,7 +97,7 @@ function resetSurfaceSplit(split: SurfaceSplit): void {
|
|
|
97
97
|
split.onTerminal = 0;
|
|
98
98
|
split.planned = 0;
|
|
99
99
|
split.lastTotal = 0;
|
|
100
|
-
split.suppressedIds
|
|
100
|
+
if (split.suppressedIds.size > 0) split.suppressedIds.clear();
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
|
|
@@ -284,8 +284,8 @@ export class ImageBudget {
|
|
|
284
284
|
*/
|
|
285
285
|
beginPass(stable = false, altScreen = false): void {
|
|
286
286
|
this.#passIds.length = 0;
|
|
287
|
-
this.#passSuppression.clear();
|
|
288
|
-
this.#passIndex.clear();
|
|
287
|
+
if (this.#passSuppression.size > 0) this.#passSuppression.clear();
|
|
288
|
+
if (this.#passIndex.size > 0) this.#passIndex.clear();
|
|
289
289
|
this.#stablePass = stable;
|
|
290
290
|
this.#surface = altScreen ? "alt" : "screen";
|
|
291
291
|
this.#split = altScreen ? this.#altSplit : this.#screenSplit;
|
|
@@ -296,7 +296,7 @@ export class ImageBudget {
|
|
|
296
296
|
// first. Note that leaving alt mode is not the same as unstacking a
|
|
297
297
|
// fullscreen overlay: the flush must exclude one that is still stacked
|
|
298
298
|
// from the pass itself, which is that caller's job, not this line's.
|
|
299
|
-
if (!altScreen) this.#liveIds.alt.clear();
|
|
299
|
+
if (!altScreen && this.#liveIds.alt.size > 0) this.#liveIds.alt.clear();
|
|
300
300
|
this.#applyingReset = !stable && this.#cap > 0 && this.#split.planned > this.#split.onTerminal;
|
|
301
301
|
}
|
|
302
302
|
|
|
@@ -349,7 +349,10 @@ export class ImageBudget {
|
|
|
349
349
|
// [0, onTerminal) is what this surface currently shows as text. Partial
|
|
350
350
|
// passes replay this per id (see #stablePass) instead of re-deriving it
|
|
351
351
|
// from a reversed, tail-only walk.
|
|
352
|
-
|
|
352
|
+
const suppressedIds = split.suppressedIds;
|
|
353
|
+
if (suppressedIds.size > 0) suppressedIds.clear();
|
|
354
|
+
const suppressedCount = Math.min(total, split.onTerminal);
|
|
355
|
+
for (let i = 0; i < suppressedCount; i++) suppressedIds.add(this.#passIds[i]);
|
|
353
356
|
return retry;
|
|
354
357
|
}
|
|
355
358
|
|
|
@@ -362,7 +365,12 @@ export class ImageBudget {
|
|
|
362
365
|
* the next pass on the *other* surface knows what it may not destroy.
|
|
363
366
|
*/
|
|
364
367
|
limitResidentImages(): void {
|
|
365
|
-
this.#liveIds[this.#surface]
|
|
368
|
+
const liveIds = this.#liveIds[this.#surface];
|
|
369
|
+
if (liveIds.size > 0) liveIds.clear();
|
|
370
|
+
for (let i = 0; i < this.#passIds.length; i++) {
|
|
371
|
+
const id = this.#passIds[i];
|
|
372
|
+
if (this.#passShowsLive(id)) liveIds.add(id);
|
|
373
|
+
}
|
|
366
374
|
const transmitted = this.#transmitted[this.#surface];
|
|
367
375
|
if (this.#cap <= 0 || transmitted.size <= this.#cap) return;
|
|
368
376
|
for (const id of transmitted) {
|