@oh-my-pi/pi-tui 18.7.0 → 18.8.0
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 +21 -52
- package/dist/types/apps/debug/log-viewer.d.ts +4 -0
- package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
- package/dist/types/chat/fence-figure.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +1 -1
- package/dist/types/chat/tool-execution.d.ts +2 -0
- package/dist/types/components/editor.d.ts +8 -3
- package/dist/types/components/markdown.d.ts +11 -6
- package/dist/types/fuzzy.d.ts +17 -3
- package/dist/types/native/backend.d.ts +2 -1
- package/dist/types/native/blobs.d.ts +4 -2
- package/dist/types/native/encode.d.ts +3 -4
- package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
- package/dist/types/overlays/history-search.d.ts +10 -0
- package/dist/types/overlays/model-browser.d.ts +6 -0
- package/dist/types/prompt/custom-editor.d.ts +7 -4
- package/dist/types/prompt/interactive-context-helpers.d.ts +2 -1
- package/dist/types/tools/renderer.d.ts +22 -0
- package/dist/types/tools/streaming-output.d.ts +11 -1
- package/dist/types/tools/write.d.ts +8 -1
- package/package.json +9 -9
- package/src/apps/debug/log-viewer.ts +188 -72
- package/src/apps/debug/raw-sse-buffer.ts +8 -0
- package/src/apps/debug/raw-sse.ts +39 -5
- package/src/apps/git/diff-pane.ts +26 -5
- package/src/autocomplete.ts +59 -33
- package/src/chat/assistant-message.ts +30 -18
- package/src/chat/fence-figure.ts +80 -0
- package/src/chat/svg-figure.ts +2 -4
- package/src/chat/tool-execution.ts +53 -4
- package/src/components/editor.ts +16 -3
- package/src/components/markdown.ts +80 -307
- package/src/fuzzy.ts +59 -14
- package/src/native/backend.ts +170 -15
- package/src/native/blobs.ts +4 -2
- package/src/native/encode.ts +58 -31
- package/src/overlays/agent-hub-projection.ts +70 -2
- package/src/overlays/agent-hub.ts +3 -2
- package/src/overlays/agent-transcript-viewer.ts +64 -13
- package/src/overlays/agents-hub.ts +14 -7
- package/src/overlays/copy-selector.ts +30 -8
- package/src/overlays/extensions/state-manager.ts +52 -12
- package/src/overlays/history-search.ts +100 -7
- package/src/overlays/jobs-panel.ts +30 -2
- package/src/overlays/model-browser.ts +107 -21
- package/src/overlays/model-hub.ts +34 -7
- package/src/overlays/oauth-selector.ts +41 -26
- package/src/overlays/plan-review-overlay.ts +142 -64
- package/src/overlays/rewind-selector.ts +86 -17
- package/src/overlays/session-selector.ts +24 -4
- package/src/overlays/settings-selector.ts +21 -6
- package/src/overlays/tree-selector.ts +20 -4
- package/src/overlays/usage-dashboard.ts +12 -15
- package/src/prompt/attachment-chips.ts +2 -1
- package/src/prompt/composer-attachments.ts +6 -4
- package/src/prompt/custom-editor.ts +48 -19
- package/src/prompt/interactive-context-helpers.ts +27 -18
- package/src/prompt/macos-spelling.ts +7 -0
- package/src/prompt/magic-keywords.ts +11 -2
- package/src/prompt/word-completion.ts +38 -5
- package/src/setup/scenes/splash.ts +109 -45
- package/src/status-line/component.ts +220 -64
- package/src/status-line/segments.ts +5 -2
- package/src/terminal.ts +3 -2
- package/src/tools/ast-edit.ts +59 -35
- package/src/tools/ast-grep.ts +35 -16
- package/src/tools/default-renderer.ts +35 -19
- package/src/tools/eval-format/index.ts +28 -2
- package/src/tools/eval-format/javascript.ts +7 -5
- package/src/tools/eval.ts +164 -78
- package/src/tools/grep.ts +81 -48
- package/src/tools/lsp.ts +81 -59
- package/src/tools/mcp.ts +98 -68
- package/src/tools/renderer.ts +27 -0
- package/src/tools/streaming-output.ts +120 -48
- package/src/tools/task.ts +111 -54
- package/src/tools/todo.ts +115 -29
- package/src/tools/wait.ts +34 -24
- package/src/tools/write.ts +77 -39
package/src/autocomplete.ts
CHANGED
|
@@ -592,6 +592,22 @@ function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: s
|
|
|
592
592
|
);
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
+
const DIR_CACHE_MAX = 100;
|
|
596
|
+
const DIR_CACHE_EVICT = 50;
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Drops the `count` oldest entries once `map` exceeds `max`. Callers delete a
|
|
600
|
+
* key before re-setting it, so Map insertion order is refresh order.
|
|
601
|
+
*/
|
|
602
|
+
function evictOldest(map: Map<string, unknown>, max: number, count: number): void {
|
|
603
|
+
if (map.size <= max) return;
|
|
604
|
+
let remaining = count;
|
|
605
|
+
for (const key of map.keys()) {
|
|
606
|
+
if (remaining-- === 0) break;
|
|
607
|
+
map.delete(key);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
595
611
|
// Combined provider that handles both slash commands and file paths.
|
|
596
612
|
export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
597
613
|
#commands: CommandEntry[];
|
|
@@ -601,6 +617,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
601
617
|
// per-directory readdir fast-path for prefix completions. Global fuzzy
|
|
602
618
|
// discovery continues to use native fuzzyFind + shared scan cache.
|
|
603
619
|
#dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
|
|
620
|
+
// Whether a scoped fuzzy query's base (`src/` in `@src/foo`) is a directory;
|
|
621
|
+
// stat'ing it on every keystroke is redundant while the base is unchanged.
|
|
622
|
+
#scopedBaseCache: Map<string, { isDirectory: boolean; timestamp: number }> = new Map();
|
|
604
623
|
readonly #DIR_CACHE_TTL = 2000; // 2 seconds
|
|
605
624
|
|
|
606
625
|
constructor(
|
|
@@ -979,11 +998,21 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
979
998
|
baseDir = path.join(this.#basePath, displayBase);
|
|
980
999
|
}
|
|
981
1000
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1001
|
+
const now = Date.now();
|
|
1002
|
+
let cachedBase = this.#scopedBaseCache.get(baseDir);
|
|
1003
|
+
if (!cachedBase || now - cachedBase.timestamp >= this.#DIR_CACHE_TTL) {
|
|
1004
|
+
let isDirectory = false;
|
|
1005
|
+
try {
|
|
1006
|
+
isDirectory = (await fs.promises.stat(baseDir)).isDirectory();
|
|
1007
|
+
} catch {
|
|
1008
|
+
// Missing or inaccessible base: not a scope.
|
|
985
1009
|
}
|
|
986
|
-
|
|
1010
|
+
cachedBase = { isDirectory, timestamp: now };
|
|
1011
|
+
this.#scopedBaseCache.delete(baseDir);
|
|
1012
|
+
this.#scopedBaseCache.set(baseDir, cachedBase);
|
|
1013
|
+
evictOldest(this.#scopedBaseCache, DIR_CACHE_MAX, DIR_CACHE_EVICT);
|
|
1014
|
+
}
|
|
1015
|
+
if (!cachedBase.isDirectory) {
|
|
987
1016
|
return null;
|
|
988
1017
|
}
|
|
989
1018
|
|
|
@@ -1006,17 +1035,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
1006
1035
|
}
|
|
1007
1036
|
|
|
1008
1037
|
const entries = await fs.promises.readdir(searchDir, { withFileTypes: true });
|
|
1038
|
+
this.#dirCache.delete(searchDir);
|
|
1009
1039
|
this.#dirCache.set(searchDir, { entries, timestamp: now });
|
|
1010
|
-
|
|
1011
|
-
if (this.#dirCache.size > 100) {
|
|
1012
|
-
const sortedKeys = [...this.#dirCache.entries()]
|
|
1013
|
-
.sort((a, b) => a[1].timestamp - b[1].timestamp)
|
|
1014
|
-
.slice(0, 50)
|
|
1015
|
-
.map(([key]) => key);
|
|
1016
|
-
for (const key of sortedKeys) {
|
|
1017
|
-
this.#dirCache.delete(key);
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1040
|
+
evictOldest(this.#dirCache, DIR_CACHE_MAX, DIR_CACHE_EVICT);
|
|
1020
1041
|
|
|
1021
1042
|
return entries;
|
|
1022
1043
|
}
|
|
@@ -1024,8 +1045,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
1024
1045
|
invalidateDirCache(dir?: string): void {
|
|
1025
1046
|
if (dir) {
|
|
1026
1047
|
this.#dirCache.delete(dir);
|
|
1048
|
+
this.#scopedBaseCache.delete(dir);
|
|
1027
1049
|
} else {
|
|
1028
1050
|
this.#dirCache.clear();
|
|
1051
|
+
this.#scopedBaseCache.clear();
|
|
1029
1052
|
}
|
|
1030
1053
|
}
|
|
1031
1054
|
|
|
@@ -1090,26 +1113,29 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
1090
1113
|
const entries = await this.#getCachedDirEntries(searchDir);
|
|
1091
1114
|
const suggestions: AutocompleteItem[] = [];
|
|
1092
1115
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1116
|
+
const lowerSearchPrefix = searchPrefix.toLowerCase();
|
|
1117
|
+
// Skip .git directory
|
|
1118
|
+
const matched = entries.filter(
|
|
1119
|
+
entry => entry.name !== ".git" && entry.name.toLowerCase().startsWith(lowerSearchPrefix),
|
|
1120
|
+
);
|
|
1121
|
+
// Directory flag per match; symlinks are stat'ed concurrently to learn whether
|
|
1122
|
+
// they point at a directory. `null` drops a broken symlink, a file deleted
|
|
1123
|
+
// between readdir and stat, or a permission error.
|
|
1124
|
+
const directoryFlags = await Promise.all(
|
|
1125
|
+
matched.map(entry =>
|
|
1126
|
+
!entry.isDirectory() && entry.isSymbolicLink()
|
|
1127
|
+
? fs.promises.stat(path.join(searchDir, entry.name)).then(
|
|
1128
|
+
stats => stats.isDirectory(),
|
|
1129
|
+
() => null,
|
|
1130
|
+
)
|
|
1131
|
+
: entry.isDirectory(),
|
|
1132
|
+
),
|
|
1133
|
+
);
|
|
1101
1134
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
if (
|
|
1105
|
-
|
|
1106
|
-
const fullPath = path.join(searchDir, entry.name);
|
|
1107
|
-
isDirectory = (await fs.promises.stat(fullPath)).isDirectory();
|
|
1108
|
-
} catch {
|
|
1109
|
-
// Broken symlink, file deleted between readdir and stat, or permission error
|
|
1110
|
-
continue;
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1135
|
+
for (let index = 0; index < matched.length; index++) {
|
|
1136
|
+
const isDirectory = directoryFlags[index];
|
|
1137
|
+
if (typeof isDirectory !== "boolean") continue;
|
|
1138
|
+
const entry = matched[index]!;
|
|
1113
1139
|
|
|
1114
1140
|
let relativePath: string;
|
|
1115
1141
|
const name = entry.name;
|
|
@@ -247,16 +247,22 @@ export class AssistantMessageComponent extends Container {
|
|
|
247
247
|
#toolImagesByCallId = new Map<string, ImageContent[]>();
|
|
248
248
|
/**
|
|
249
249
|
* Payload keys ({@link imagePayloadKey}) whose Kitty PNG conversion this
|
|
250
|
-
* component
|
|
250
|
+
* component is awaiting, so a re-delivered image neither re-encodes nor
|
|
251
251
|
* schedules a second {@link updateContent} cascade. The conversions
|
|
252
|
-
* themselves
|
|
252
|
+
* themselves live in the bounded process-wide cache behind
|
|
253
|
+
* {@link convertImageToPngShared}; an evicted one is redone on the next
|
|
254
|
+
* render instead of being pinned here for the session.
|
|
253
255
|
*/
|
|
254
256
|
#kittyConversionsAwaited = new Set<string>();
|
|
255
257
|
/**
|
|
256
|
-
* Conversions
|
|
257
|
-
*
|
|
258
|
+
* Conversions the current image children display, by payload key. Rebuilt
|
|
259
|
+
* on every full render pass ({@link updateContent}), so a conversion is held
|
|
260
|
+
* only while it is on screen — a re-render after the shared cache evicted
|
|
261
|
+
* it still finds it — and released once hidden or replaced.
|
|
258
262
|
*/
|
|
259
|
-
#
|
|
263
|
+
#kittyDisplayed = new Map<string, ImageContent>();
|
|
264
|
+
/** The previous pass's {@link #kittyDisplayed}, readable only during a render pass. */
|
|
265
|
+
#kittyPreviouslyDisplayed: Map<string, ImageContent> | undefined;
|
|
260
266
|
#showImages = true;
|
|
261
267
|
#showToolResultImages = true;
|
|
262
268
|
/** Charts under numeric tables; off for subagent transcripts. */
|
|
@@ -1462,22 +1468,24 @@ export class AssistantMessageComponent extends Container {
|
|
|
1462
1468
|
}
|
|
1463
1469
|
}
|
|
1464
1470
|
|
|
1471
|
+
/** A displayable Kitty PNG for a non-PNG `image`: the shared cache, else this component's displayed copy. */
|
|
1472
|
+
#kittyConversion(image: ImageContent): ImageContent | undefined {
|
|
1473
|
+
const cached = cachedPngConversion(image);
|
|
1474
|
+
if (cached) return cached;
|
|
1475
|
+
const key = imagePayloadKey(image);
|
|
1476
|
+
return this.#kittyDisplayed.get(key) ?? this.#kittyPreviouslyDisplayed?.get(key);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1465
1479
|
#convertImagesForKitty(entries: Array<{ image: ImageContent; key: string }>): void {
|
|
1466
1480
|
if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
|
|
1467
1481
|
for (const { image } of entries) {
|
|
1468
|
-
if (image.mimeType === "image/png") continue;
|
|
1482
|
+
if (image.mimeType === "image/png" || this.#kittyConversion(image)) continue;
|
|
1469
1483
|
const key = imagePayloadKey(image);
|
|
1470
|
-
if (this.#kittyConverted.has(key)) continue;
|
|
1471
|
-
const cached = cachedPngConversion(image);
|
|
1472
|
-
if (cached) {
|
|
1473
|
-
this.#kittyConverted.set(key, cached);
|
|
1474
|
-
continue;
|
|
1475
|
-
}
|
|
1476
1484
|
if (this.#kittyConversionsAwaited.has(key)) continue;
|
|
1477
1485
|
this.#kittyConversionsAwaited.add(key);
|
|
1478
1486
|
convertImageToPngShared(image)
|
|
1479
|
-
.then(
|
|
1480
|
-
this.#
|
|
1487
|
+
.then(() => {
|
|
1488
|
+
this.#kittyConversionsAwaited.delete(key);
|
|
1481
1489
|
if (this.#lastMessage) {
|
|
1482
1490
|
this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
|
|
1483
1491
|
}
|
|
@@ -1495,10 +1503,11 @@ export class AssistantMessageComponent extends Container {
|
|
|
1495
1503
|
|
|
1496
1504
|
if (withLeadingSpacer) this.#contentContainer.addChild(new Spacer(1));
|
|
1497
1505
|
for (const { image, key } of entries) {
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1506
|
+
let displayImage: ImageContent | undefined = image;
|
|
1507
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Kitty && image.mimeType !== "image/png") {
|
|
1508
|
+
displayImage = this.#kittyConversion(image);
|
|
1509
|
+
if (displayImage) this.#kittyDisplayed.set(imagePayloadKey(image), displayImage);
|
|
1510
|
+
}
|
|
1502
1511
|
if (TERMINAL.imageProtocol && displayImage) {
|
|
1503
1512
|
this.#contentContainer.addChild(
|
|
1504
1513
|
new Image(
|
|
@@ -1713,6 +1722,8 @@ export class AssistantMessageComponent extends Container {
|
|
|
1713
1722
|
|
|
1714
1723
|
// Clear content container
|
|
1715
1724
|
this.#contentContainer.clear();
|
|
1725
|
+
this.#kittyPreviouslyDisplayed = this.#kittyDisplayed;
|
|
1726
|
+
this.#kittyDisplayed = new Map();
|
|
1716
1727
|
this.#thinkingExtensions.clear();
|
|
1717
1728
|
this.#emergencyText = undefined;
|
|
1718
1729
|
this.#thinkingDots = undefined;
|
|
@@ -1795,6 +1806,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
1795
1806
|
}
|
|
1796
1807
|
|
|
1797
1808
|
this.#renderToolImages();
|
|
1809
|
+
this.#kittyPreviouslyDisplayed = undefined;
|
|
1798
1810
|
const errorPresentation = resolveAssistantErrorPresentation(message);
|
|
1799
1811
|
const hasToolCalls = message.content.some(c => c.type === "toolCall");
|
|
1800
1812
|
if (errorPresentation.kind === "compact-recovered") {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tool call's {@link ToolFigure} drawn under its card, the way assistant
|
|
3
|
+
* Markdown draws that fence: ```svg as an inline image ({@link SvgFigure}),
|
|
4
|
+
* redrawn as the body streams; ```mermaid as ASCII art once the body is
|
|
5
|
+
* final. The card already shows the source, so unlike the assistant's fence
|
|
6
|
+
* it never falls back to code: {@link FenceFigure.draws} turns down fences
|
|
7
|
+
* this terminal shows as code, and an svg that does not rasterize stays blank.
|
|
8
|
+
*/
|
|
9
|
+
import type { ImageBudget } from "../components/image";
|
|
10
|
+
import { fencedCode, Markdown } from "../components/markdown";
|
|
11
|
+
import { TERMINAL } from "../terminal-capabilities";
|
|
12
|
+
import { getMarkdownTheme } from "../theme";
|
|
13
|
+
import type { ToolFigure } from "../tools/renderer";
|
|
14
|
+
import type { Component } from "../tui";
|
|
15
|
+
import { SvgFigure, svgFigureRendering } from "./svg-figure";
|
|
16
|
+
|
|
17
|
+
/** Fence languages a {@link FenceFigure} draws. */
|
|
18
|
+
export type FenceFigureLang = "svg" | "mermaid";
|
|
19
|
+
|
|
20
|
+
export interface FenceFigureOptions {
|
|
21
|
+
/** Shared inline-image budget for an svg's rasters. */
|
|
22
|
+
budget?: ImageBudget;
|
|
23
|
+
/** The figure's rows changed outside a render: a raster landed. */
|
|
24
|
+
onChange: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class FenceFigure implements Component {
|
|
28
|
+
/**
|
|
29
|
+
* Whether this terminal draws `figure` now: an svg where inline images show
|
|
30
|
+
* (`showImages` and a graphics protocol), a closed mermaid fence when
|
|
31
|
+
* diagram rendering is on and the source renders.
|
|
32
|
+
*/
|
|
33
|
+
static draws(
|
|
34
|
+
figure: ToolFigure,
|
|
35
|
+
options: { showImages: boolean },
|
|
36
|
+
): figure is ToolFigure & { readonly lang: FenceFigureLang } {
|
|
37
|
+
if (figure.lang === "svg") return options.showImages && TERMINAL.imageProtocol !== null && svgFigureRendering();
|
|
38
|
+
if (figure.lang !== "mermaid" || !figure.closed) return false;
|
|
39
|
+
return getMarkdownTheme().resolveMermaidAscii?.(figure.source.trimEnd()) != null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
readonly lang: FenceFigureLang;
|
|
43
|
+
readonly #svg: SvgFigure | undefined;
|
|
44
|
+
#source = "";
|
|
45
|
+
/** The mermaid fence as assistant Markdown renders it; rebuilt after a theme change. */
|
|
46
|
+
#diagram: Markdown | undefined;
|
|
47
|
+
|
|
48
|
+
constructor(lang: FenceFigureLang, options: FenceFigureOptions) {
|
|
49
|
+
this.lang = lang;
|
|
50
|
+
if (lang === "svg") this.#svg = new SvgFigure({ budget: options.budget, onChange: options.onChange });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Feed the fence as it stands; its `lang` is this figure's. */
|
|
54
|
+
update(figure: ToolFigure): void {
|
|
55
|
+
if (figure.source !== this.#source) this.#diagram = undefined;
|
|
56
|
+
this.#source = figure.source;
|
|
57
|
+
this.#svg?.update(figure.source, figure.closed);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a closed svg's raster is still on its way. */
|
|
61
|
+
get pending(): boolean {
|
|
62
|
+
return this.#svg?.pending === true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The drawing; no rows while an svg draws, or when it does not. */
|
|
66
|
+
render(width: number): readonly string[] {
|
|
67
|
+
if (this.#svg) return this.#svg.render(width);
|
|
68
|
+
this.#diagram ??= new Markdown(fencedCode("mermaid", this.#source), 1, 0, getMarkdownTheme());
|
|
69
|
+
return this.#diagram.render(width);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
invalidate(): void {
|
|
73
|
+
this.#svg?.invalidate();
|
|
74
|
+
this.#diagram = undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
dispose(): void {
|
|
78
|
+
this.#svg?.dispose();
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/chat/svg-figure.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { rasterizeSvg } from "@oh-my-pi/pi-natives";
|
|
16
16
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
17
17
|
import { Image, type ImageBudget, imageMaxColumns } from "../components/image";
|
|
18
|
-
import type
|
|
18
|
+
import { fencedCode, type Markdown } from "../components/markdown";
|
|
19
19
|
import { type CellDimensions, getCellDimensions, getImageDimensions } from "../terminal-capabilities";
|
|
20
20
|
import { getThemeEpoch, type ThemeColor, theme } from "../theme";
|
|
21
21
|
import type { Component } from "../tui";
|
|
@@ -355,9 +355,7 @@ export class SvgFigure implements Component {
|
|
|
355
355
|
const source = this.#source;
|
|
356
356
|
if (!this.#options.markdown) return [];
|
|
357
357
|
if (this.#fallback?.source !== source) {
|
|
358
|
-
|
|
359
|
-
const fence = "`".repeat(longest + 1);
|
|
360
|
-
this.#fallback = { source, md: this.#options.markdown(`${fence}svg\n${source.trimEnd()}\n${fence}`) };
|
|
358
|
+
this.#fallback = { source, md: this.#options.markdown(fencedCode("svg", source)) };
|
|
361
359
|
}
|
|
362
360
|
return this.#fallback.md.render(width);
|
|
363
361
|
}
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import type { XdevMountedState } from "../tools/xdev";
|
|
49
49
|
import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../render/index";
|
|
50
50
|
import { cachedPngConversion, convertImageToPngShared, imagePayloadKey } from "./image-loading";
|
|
51
|
+
import { FenceFigure } from "./fence-figure";
|
|
51
52
|
import { sanitizeWithOptionalSixelPassthrough } from "../render/sixel";
|
|
52
53
|
import { renderDiff } from "../chrome/diff";
|
|
53
54
|
import { type AnimationFrame, trimBlankEdges } from "../chrome/transcript-container";
|
|
@@ -301,6 +302,8 @@ export class ToolExecutionComponent extends Container {
|
|
|
301
302
|
#multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
|
|
302
303
|
#imageComponents: Image[] = [];
|
|
303
304
|
#imageSpacers: Spacer[] = [];
|
|
305
|
+
/** The renderer's {@link ToolRenderer.figure} drawing; kept across rebuilds while its language is unchanged, so it redraws in place and its raster survives them. */
|
|
306
|
+
#figure: FenceFigure | undefined;
|
|
304
307
|
readonly #instanceId = ++toolExecutionInstanceSeq;
|
|
305
308
|
#toolName: string;
|
|
306
309
|
#toolLabel: string;
|
|
@@ -759,6 +762,11 @@ export class ToolExecutionComponent extends Container {
|
|
|
759
762
|
}
|
|
760
763
|
}
|
|
761
764
|
|
|
765
|
+
/** Whether the result's figure still waits for its raster; the transcript holds retirement meanwhile. */
|
|
766
|
+
isTranscriptBlockPending(): boolean {
|
|
767
|
+
return this.#toolActivityVisible && this.#figure?.pending === true;
|
|
768
|
+
}
|
|
769
|
+
|
|
762
770
|
/**
|
|
763
771
|
* Whether this block is ready to retire as immutable history. Partial
|
|
764
772
|
* results, including detached background tasks, remain active and mutable
|
|
@@ -941,11 +949,13 @@ export class ToolExecutionComponent extends Container {
|
|
|
941
949
|
|
|
942
950
|
/**
|
|
943
951
|
* The native `collapsed` prop: the transcript's expand state, except that a
|
|
944
|
-
*
|
|
952
|
+
* view that asks to be {@link NativeToolView.open} never starts folded, and
|
|
953
|
+
* a call streaming its body (edit, write) stays open until it settles, then
|
|
945
954
|
* folds like a finished thought. The user's own toggle still wins meanwhile:
|
|
946
955
|
* the terminal keeps local collapse state until this prop changes.
|
|
947
956
|
*/
|
|
948
|
-
#nativeCollapsed(status: TspCardStatus): boolean {
|
|
957
|
+
#nativeCollapsed(status: TspCardStatus, view: NativeToolView): boolean {
|
|
958
|
+
if (view.open) return false;
|
|
949
959
|
const live = status === "pending" || status === "running";
|
|
950
960
|
return !this.#expanded && !(live && streamsBody(this.#toolName));
|
|
951
961
|
}
|
|
@@ -1008,7 +1018,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
1008
1018
|
intent: typeof intent === "string" && intent ? plainText(intent) : undefined,
|
|
1009
1019
|
frame: inline ? "inline" : "card",
|
|
1010
1020
|
collapsible: hasBody,
|
|
1011
|
-
collapsed: hasBody ? this.#nativeCollapsed(status) : undefined,
|
|
1021
|
+
collapsed: hasBody ? this.#nativeCollapsed(status, view) : undefined,
|
|
1012
1022
|
preview: hasBody ? (preview === "auto" ? { lines: DEFAULT_TERMINAL_PREVIEW_LINES } : preview) : undefined,
|
|
1013
1023
|
tools: view.tools,
|
|
1014
1024
|
tone: view.tone,
|
|
@@ -1076,7 +1086,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
1076
1086
|
tone: view.tone ?? NATIVE_STATUS_TONE[status],
|
|
1077
1087
|
status,
|
|
1078
1088
|
collapsible: hasBody,
|
|
1079
|
-
collapsed: hasBody ? this.#nativeCollapsed(status) : undefined,
|
|
1089
|
+
collapsed: hasBody ? this.#nativeCollapsed(status, view) : undefined,
|
|
1080
1090
|
preview: hasBody ? cardPreview(view.preview) : undefined,
|
|
1081
1091
|
},
|
|
1082
1092
|
children,
|
|
@@ -1132,6 +1142,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
1132
1142
|
body: [...(callView.body ?? []), ...(resultView.body ?? [])],
|
|
1133
1143
|
tone: resultView.tone ?? callView.tone,
|
|
1134
1144
|
preview: resultView.preview ?? callView.preview,
|
|
1145
|
+
open: resultView.open ?? callView.open,
|
|
1135
1146
|
inline: resultView.inline ?? callView.inline,
|
|
1136
1147
|
tools: resultView.tools ?? callView.tools,
|
|
1137
1148
|
};
|
|
@@ -1611,6 +1622,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
1611
1622
|
this.removeChild(spacer);
|
|
1612
1623
|
}
|
|
1613
1624
|
this.#imageSpacers = [];
|
|
1625
|
+
this.#syncFigure();
|
|
1614
1626
|
|
|
1615
1627
|
if (this.#result) {
|
|
1616
1628
|
const imageBlocks = this.#getAllImageBlocks();
|
|
@@ -1654,6 +1666,43 @@ export class ToolExecutionComponent extends Container {
|
|
|
1654
1666
|
this.#renderedImageCount = this.#imageComponents.length;
|
|
1655
1667
|
}
|
|
1656
1668
|
|
|
1669
|
+
/**
|
|
1670
|
+
* Mount the built-in renderer's figure under the card and feed it the fence
|
|
1671
|
+
* as it stands: the mounted one stays while its language is unchanged, so it
|
|
1672
|
+
* redraws in place as the args stream.
|
|
1673
|
+
*/
|
|
1674
|
+
#syncFigure(): void {
|
|
1675
|
+
const result = this.#result;
|
|
1676
|
+
const renderer = this.#tool?.renderCall || this.#tool?.renderResult ? undefined : this.#renderer;
|
|
1677
|
+
const fence =
|
|
1678
|
+
this.#isBenignSkip() || isNativeRendering()
|
|
1679
|
+
? undefined
|
|
1680
|
+
: renderer?.figure?.(
|
|
1681
|
+
this.#args,
|
|
1682
|
+
result && { content: result.content, details: result.details, isError: result.isError },
|
|
1683
|
+
this.#renderState,
|
|
1684
|
+
);
|
|
1685
|
+
const drawable = fence !== undefined && FenceFigure.draws(fence, { showImages: this.#showImages });
|
|
1686
|
+
let figure = this.#figure;
|
|
1687
|
+
if (figure && (!drawable || figure.lang !== fence.lang)) {
|
|
1688
|
+
this.removeChild(figure);
|
|
1689
|
+
figure.dispose();
|
|
1690
|
+
figure = this.#figure = undefined;
|
|
1691
|
+
}
|
|
1692
|
+
if (!drawable) return;
|
|
1693
|
+
if (!figure) {
|
|
1694
|
+
figure = this.#figure = new FenceFigure(fence.lang, {
|
|
1695
|
+
budget: this.#ui.imageBudget,
|
|
1696
|
+
onChange: () => {
|
|
1697
|
+
this.#blockVersion++;
|
|
1698
|
+
this.#ui.requestRender();
|
|
1699
|
+
},
|
|
1700
|
+
});
|
|
1701
|
+
this.addChild(figure);
|
|
1702
|
+
}
|
|
1703
|
+
figure.update(fence);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1657
1706
|
#getCallArgsForRender(): unknown {
|
|
1658
1707
|
const renderArgs = this.#args;
|
|
1659
1708
|
if (!isEditLikeToolName(this.#toolName)) {
|
package/src/components/editor.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { BracketedPasteHandler, decodeReencodedPasteControls } from "../brackete
|
|
|
14
14
|
import { canonicalKeyId, getKeybindings, type KeybindingsManager } from "../keybindings";
|
|
15
15
|
import { extractPrintableText, matchesKey, parseKey } from "../keys";
|
|
16
16
|
import { KillRing } from "../kill-ring";
|
|
17
|
-
import type { TspEditorDecoration, TspEditorProps, TspTone } from "@oh-my-pi/pi-wire";
|
|
17
|
+
import type { TspEditorDecoration, TspEditorProps, TspText, TspTone } from "@oh-my-pi/pi-wire";
|
|
18
18
|
import { col, node } from "../native/describe";
|
|
19
19
|
import { sameItems, sameProps } from "../native/memo";
|
|
20
20
|
import { plainText } from "../native/spans";
|
|
@@ -625,8 +625,11 @@ export class Editor implements Component, Focusable {
|
|
|
625
625
|
* dialog's text field; only the prompt composer claims `omp.editor`).
|
|
626
626
|
*/
|
|
627
627
|
describeLayout: ((input: NativeNode, cx: DescribeContext) => NativeEditorLayout) | undefined;
|
|
628
|
-
/**
|
|
629
|
-
|
|
628
|
+
/**
|
|
629
|
+
* TSP placeholder for the empty buffer; natively it replaces the rotating ANSI {@link placeholder} hints.
|
|
630
|
+
* Return a stable value (the same spans array) while it is unchanged: the editor node is reused by identity.
|
|
631
|
+
*/
|
|
632
|
+
describePlaceholder: (() => TspText) | undefined;
|
|
630
633
|
#promptGutter: string | undefined;
|
|
631
634
|
/** Bumped by {@link invalidate}: host-side decoration inputs (spelling results, settings) changed. */
|
|
632
635
|
#nativeGeneration = 0;
|
|
@@ -713,6 +716,7 @@ export class Editor implements Component, Focusable {
|
|
|
713
716
|
|
|
714
717
|
// Host-registered atomic chip tokens: exact buffer label → expansion emitted on submit.
|
|
715
718
|
#atoms: Map<string, string> = new Map();
|
|
719
|
+
#atomsRevision = 0;
|
|
716
720
|
|
|
717
721
|
/** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
|
|
718
722
|
* `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
|
|
@@ -1025,6 +1029,7 @@ export class Editor implements Component, Focusable {
|
|
|
1025
1029
|
this.#pastes.clear();
|
|
1026
1030
|
this.#pasteCounter = 0;
|
|
1027
1031
|
this.#atoms.clear();
|
|
1032
|
+
this.#atomsRevision++;
|
|
1028
1033
|
this.#historyDraftActive = false;
|
|
1029
1034
|
}
|
|
1030
1035
|
|
|
@@ -1064,6 +1069,7 @@ export class Editor implements Component, Focusable {
|
|
|
1064
1069
|
if (entry?.draft || this.#historyDraftActive) {
|
|
1065
1070
|
this.#pastes = new Map(entry?.draft?.pastes);
|
|
1066
1071
|
this.#atoms = new Map(entry?.draft?.atoms);
|
|
1072
|
+
this.#atomsRevision++;
|
|
1067
1073
|
this.#pasteCounter = entry?.draft?.pasteCounter ?? 0;
|
|
1068
1074
|
this.restoreHistoryState(entry?.draft?.restore);
|
|
1069
1075
|
this.#historyDraftActive = entry?.draft !== undefined;
|
|
@@ -2661,6 +2667,11 @@ export class Editor implements Component, Focusable {
|
|
|
2661
2667
|
return this.#textRevision;
|
|
2662
2668
|
}
|
|
2663
2669
|
|
|
2670
|
+
/** Monotonic atom-table revision for render caches: advances whenever {@link atoms} is mutated or replaced. */
|
|
2671
|
+
get atomsRevision(): number {
|
|
2672
|
+
return this.#atomsRevision;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2664
2675
|
#notifyChange(text?: string): void {
|
|
2665
2676
|
this.#textRevision++;
|
|
2666
2677
|
this.onChange?.(text ?? this.getText());
|
|
@@ -2699,6 +2710,7 @@ export class Editor implements Component, Focusable {
|
|
|
2699
2710
|
* it — for hosts that re-collapse restored draft text via {@link setText}. */
|
|
2700
2711
|
registerAtom(label: string, expansion: string): void {
|
|
2701
2712
|
this.#atoms.set(label, expansion);
|
|
2713
|
+
this.#atomsRevision++;
|
|
2702
2714
|
}
|
|
2703
2715
|
|
|
2704
2716
|
/** Insert `label` (plus a trailing space) at the cursor and register it as an atom expanding
|
|
@@ -2735,6 +2747,7 @@ export class Editor implements Component, Focusable {
|
|
|
2735
2747
|
/** Drop every registered atom expansion (draft cleared or replaced by the host). */
|
|
2736
2748
|
clearAtoms(): void {
|
|
2737
2749
|
this.#atoms.clear();
|
|
2750
|
+
this.#atomsRevision++;
|
|
2738
2751
|
}
|
|
2739
2752
|
|
|
2740
2753
|
/**
|