@oh-my-pi/pi-tui 18.6.3 → 18.7.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 +26 -58
- package/dist/types/app-keybindings.d.ts +1 -1
- package/dist/types/autocomplete.d.ts +5 -2
- package/dist/types/charts/chart-plan.d.ts +92 -0
- package/dist/types/charts/chart-svg.d.ts +30 -0
- package/dist/types/charts/table-data.d.ts +107 -0
- package/dist/types/chat/assistant-message.d.ts +4 -0
- package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
- package/dist/types/chat/figure-markdown.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +33 -0
- package/dist/types/chat/svg-source.d.ts +49 -0
- package/dist/types/chat/table-chart.d.ts +88 -0
- package/dist/types/components/editor.d.ts +1 -1
- package/dist/types/components/image.d.ts +9 -0
- package/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/fuzzy.d.ts +14 -0
- package/dist/types/keybindings.d.ts +1 -1
- package/dist/types/overlays/agents-hub.d.ts +9 -0
- package/dist/types/overlays/annotation-overlay.d.ts +5 -2
- package/dist/types/overlays/model-browser.d.ts +25 -3
- package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
- package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
- package/dist/types/render/composite.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -0
- package/dist/types/utils.d.ts +6 -0
- package/package.json +9 -9
- package/src/autocomplete.ts +8 -10
- package/src/charts/chart-plan.ts +367 -0
- package/src/charts/chart-svg.ts +693 -0
- package/src/charts/table-data.ts +436 -0
- package/src/chat/assistant-message.ts +139 -21
- package/src/chat/chat-transcript-builder.ts +4 -0
- package/src/chat/figure-markdown.ts +174 -0
- package/src/chat/svg-figure.ts +364 -0
- package/src/chat/svg-source.ts +188 -0
- package/src/chat/table-chart.ts +301 -0
- package/src/chrome/transcript-container.ts +7 -1
- package/src/components/editor.ts +52 -30
- package/src/components/image.ts +27 -1
- package/src/components/markdown.ts +6 -3
- package/src/fuzzy.ts +59 -19
- package/src/keybindings.ts +1 -1
- package/src/overlays/agent-transcript-viewer.ts +2 -0
- package/src/overlays/agents-hub.ts +14 -1
- package/src/overlays/annotation-overlay.ts +179 -80
- package/src/overlays/ask-dialog.ts +1 -1
- package/src/overlays/model-browser.ts +169 -76
- package/src/overlays/plan-review-overlay.ts +41 -4
- package/src/prompt/composer.ts +2 -14
- package/src/prompt/interactive-context-helpers.ts +3 -0
- package/src/prompt/model-mention-autocomplete.ts +14 -14
- package/src/render/composite.ts +62 -0
- package/src/status-line/segments.ts +3 -1
- package/src/tools/glob.ts +23 -6
- package/src/tui.ts +118 -105
- package/src/utils.ts +19 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Charts drawn under numeric tables in assistant answers (the host's
|
|
3
|
+
* `tui.autoGraph`). {@link splitTableCharts} cuts a Markdown block after each
|
|
4
|
+
* top-level table that gets a chart; {@link lookupTableChart} plans, builds
|
|
5
|
+
* and draws that chart — locally under `always`, through the host's
|
|
6
|
+
* model-backed {@link TableChartPlanner} for multi-series tables under
|
|
7
|
+
* `smart` — and caches it per table source. {@link TableChartFigure} shows it
|
|
8
|
+
* as a rasterized terminal image; {@link describeTableChart} hands a TSP
|
|
9
|
+
* terminal the SVG itself. Both resolve the figure tokens against the live
|
|
10
|
+
* theme, so a chart follows theme switches like the prose around it.
|
|
11
|
+
*/
|
|
12
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
13
|
+
import type { Token, Tokens } from "@oh-my-pi/pi-utils/marked";
|
|
14
|
+
import { type ChartPlan, buildChart, planChart, worthCharting } from "../charts/chart-plan";
|
|
15
|
+
import { chartAlt, renderChartSvg } from "../charts/chart-svg";
|
|
16
|
+
import { analyzeTable, type TableAnalysis } from "../charts/table-data";
|
|
17
|
+
import type { ImageBudget } from "../components/image";
|
|
18
|
+
import { lexDocument } from "../components/markdown";
|
|
19
|
+
import { node } from "../native/describe";
|
|
20
|
+
import { registerNativeBlob } from "../native/blobs";
|
|
21
|
+
import type { NativeNode } from "../native/node";
|
|
22
|
+
import { getThemeEpoch } from "../theme";
|
|
23
|
+
import type { Component } from "../tui";
|
|
24
|
+
import { SvgFigure, svgFigurePalette } from "./svg-figure";
|
|
25
|
+
import { prepareSvg } from "./svg-source";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `smart`: a model picks the kind and columns of multi-series tables;
|
|
29
|
+
* `always`: the local best guess charts every table it reads as numeric;
|
|
30
|
+
* `off`: tables stay tables.
|
|
31
|
+
*/
|
|
32
|
+
export type TableChartMode = "smart" | "always" | "off";
|
|
33
|
+
|
|
34
|
+
/** A multi-series table the host's planner picks a chart for. */
|
|
35
|
+
export interface TableChartRequest {
|
|
36
|
+
/** The table's Markdown source. */
|
|
37
|
+
readonly markdown: string;
|
|
38
|
+
readonly table: TableAnalysis;
|
|
39
|
+
/** The local best guess; the planner may keep it. */
|
|
40
|
+
readonly guess: ChartPlan;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Picks the chart for a multi-series table under `smart`: a plan, or `null`
|
|
45
|
+
* for no chart. A rejection falls back to the local guess.
|
|
46
|
+
*/
|
|
47
|
+
export type TableChartPlanner = (request: TableChartRequest) => Promise<ChartPlan | null>;
|
|
48
|
+
|
|
49
|
+
/** A drawn chart, ready to resolve against the theme. */
|
|
50
|
+
export interface TableChart {
|
|
51
|
+
/** SVG colored with figure tokens (`var(--accent)`, …). */
|
|
52
|
+
readonly svg: string;
|
|
53
|
+
readonly alt: string;
|
|
54
|
+
/** Size in SVG user units. */
|
|
55
|
+
readonly width: number;
|
|
56
|
+
readonly height: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The chart under a table: drawn, none, or a smart pick on its way (the promise settles once the cache holds it). */
|
|
60
|
+
export type ChartLookup = TableChart | null | Promise<void>;
|
|
61
|
+
|
|
62
|
+
/** One run of a Markdown block: prose (ending at a charted table), or the chart drawn after that table. */
|
|
63
|
+
export type ChartSegment =
|
|
64
|
+
| { readonly kind: "markdown"; readonly text: string }
|
|
65
|
+
| { readonly kind: "chart"; readonly table: Tokens.Table };
|
|
66
|
+
|
|
67
|
+
/** Tables cached per source; a long session keeps its recent charts without growing without bound. */
|
|
68
|
+
const CACHE_LIMIT = 256;
|
|
69
|
+
/** SVG user units per terminal column (16 per row on 1:2 cells), sizing native images. */
|
|
70
|
+
const UNITS_PER_COLUMN = 8;
|
|
71
|
+
/** A GFM delimiter row (`|---|:--:|`), the cheap test before lexing a block. */
|
|
72
|
+
const DELIMITER_ROW = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*\|/m;
|
|
73
|
+
|
|
74
|
+
/** A table read once per source: its local guess and, once known, its chart. */
|
|
75
|
+
interface ChartEntry {
|
|
76
|
+
readonly analysis: TableAnalysis;
|
|
77
|
+
readonly guess: ChartPlan | undefined;
|
|
78
|
+
/** The host's planner picks this table's chart. */
|
|
79
|
+
readonly smart: boolean;
|
|
80
|
+
/** `undefined` until drawn (or judged chartless). */
|
|
81
|
+
chart?: TableChart | null;
|
|
82
|
+
pending?: Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let chartMode: TableChartMode = "off";
|
|
86
|
+
let chartPlanner: TableChartPlanner | undefined;
|
|
87
|
+
const charts = new Map<string, ChartEntry>();
|
|
88
|
+
/** The theme-resolved native blob last registered for a chart. */
|
|
89
|
+
const kNativeBlob = Symbol("tableChart.nativeBlob");
|
|
90
|
+
|
|
91
|
+
interface NativeTagged extends TableChart {
|
|
92
|
+
[kNativeBlob]?: { epoch: number; blob: string };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Set how tables become charts (the host's `tui.autoGraph`) and its `smart` planner; the host rebuilds the transcript after. */
|
|
96
|
+
export function setTableCharts(mode: TableChartMode, planner?: TableChartPlanner): void {
|
|
97
|
+
chartMode = mode;
|
|
98
|
+
chartPlanner = planner;
|
|
99
|
+
charts.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** How assistant tables currently become charts. */
|
|
103
|
+
export function tableChartMode(): TableChartMode {
|
|
104
|
+
return chartMode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether `markdown` may hold a table {@link splitTableCharts} would chart. */
|
|
108
|
+
export function hasChartTable(markdown: string): boolean {
|
|
109
|
+
return chartMode !== "off" && markdown.includes("|") && DELIMITER_ROW.test(markdown);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Split `markdown` after each top-level table that has (or may get) a chart.
|
|
114
|
+
* A table still streaming at the block's tail is left whole: rows may follow.
|
|
115
|
+
*/
|
|
116
|
+
export function splitTableCharts(markdown: string, streaming: boolean): ChartSegment[] {
|
|
117
|
+
if (chartMode === "off") return [{ kind: "markdown", text: markdown }];
|
|
118
|
+
const tokens = lexDocument(markdown);
|
|
119
|
+
const segments: ChartSegment[] = [];
|
|
120
|
+
let prose = "";
|
|
121
|
+
tokens.forEach((token, index) => {
|
|
122
|
+
prose += token.raw;
|
|
123
|
+
if (!isTable(token)) return;
|
|
124
|
+
const closed = !streaming || tokens.slice(index + 1).some(next => next.type !== "space");
|
|
125
|
+
const entry = closed ? chartEntry(token) : undefined;
|
|
126
|
+
if (!entry?.guess || entry.chart === null) return;
|
|
127
|
+
segments.push({ kind: "markdown", text: prose }, { kind: "chart", table: token });
|
|
128
|
+
prose = "";
|
|
129
|
+
});
|
|
130
|
+
if (prose.trim() || segments.length === 0) segments.push({ kind: "markdown", text: prose });
|
|
131
|
+
return segments;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isTable(token: Token): token is Tokens.Table {
|
|
135
|
+
return token.type === "table";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The chart for `table` under the current mode, read once per table source.
|
|
140
|
+
* Under `smart` the first lookup of a multi-series table asks the planner.
|
|
141
|
+
*/
|
|
142
|
+
export function lookupTableChart(table: Tokens.Table): ChartLookup {
|
|
143
|
+
if (chartMode === "off") return null;
|
|
144
|
+
const entry = chartEntry(table);
|
|
145
|
+
if (entry.chart !== undefined) return entry.chart;
|
|
146
|
+
if (entry.pending) return entry.pending;
|
|
147
|
+
const { analysis, guess } = entry;
|
|
148
|
+
entry.pending = chartPlanner!({ markdown: table.raw, table: analysis, guess: guess! }).then(
|
|
149
|
+
plan => {
|
|
150
|
+
entry.chart = plan ? drawChart(analysis, plan) : null;
|
|
151
|
+
},
|
|
152
|
+
(error: unknown) => {
|
|
153
|
+
logger.debug("Smart table chart failed; drawing the local guess", { error: String(error) });
|
|
154
|
+
entry.chart = drawChart(analysis, guess!, true);
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
return entry.pending;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `table` read under the current mode, cached per source. A table the local
|
|
162
|
+
* guess decides is drawn at once; a smart pick waits for its first lookup.
|
|
163
|
+
*/
|
|
164
|
+
function chartEntry(table: Tokens.Table): ChartEntry {
|
|
165
|
+
const key = table.raw;
|
|
166
|
+
const cached = charts.get(key);
|
|
167
|
+
if (cached) {
|
|
168
|
+
// Re-insert so eviction drops the least recently read table.
|
|
169
|
+
charts.delete(key);
|
|
170
|
+
charts.set(key, cached);
|
|
171
|
+
return cached;
|
|
172
|
+
}
|
|
173
|
+
if (charts.size >= CACHE_LIMIT) charts.delete(charts.keys().next().value!);
|
|
174
|
+
const analysis = analyzeTable(
|
|
175
|
+
table.header.map(cell => cell.text),
|
|
176
|
+
table.rows.map(row => row.map(cell => cell.text)),
|
|
177
|
+
);
|
|
178
|
+
const guess = planChart(analysis);
|
|
179
|
+
const smart =
|
|
180
|
+
guess !== undefined &&
|
|
181
|
+
chartMode === "smart" &&
|
|
182
|
+
chartPlanner !== undefined &&
|
|
183
|
+
analysis.measures.length >= 2 &&
|
|
184
|
+
analysis.rows.length >= 3;
|
|
185
|
+
const entry: ChartEntry = { analysis, guess, smart };
|
|
186
|
+
if (!smart) entry.chart = guess ? drawChart(analysis, guess, true) : null;
|
|
187
|
+
charts.set(key, entry);
|
|
188
|
+
return entry;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Draw `plan`. A guess must also pass {@link worthCharting}; a model's pick
|
|
193
|
+
* already judged the chart worth drawing.
|
|
194
|
+
*/
|
|
195
|
+
function drawChart(analysis: TableAnalysis, plan: ChartPlan, guessed = false): TableChart | null {
|
|
196
|
+
const spec = buildChart(analysis, plan);
|
|
197
|
+
if (!spec || (guessed && !worthCharting(spec))) return null;
|
|
198
|
+
return { ...renderChartSvg(spec), alt: chartAlt(spec) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A native `image` node drawing `chart` from its SVG — no raster — resolved
|
|
203
|
+
* against the active theme; rebuilt when the theme changes.
|
|
204
|
+
*/
|
|
205
|
+
export function describeTableChart(chart: TableChart, key: string): NativeNode {
|
|
206
|
+
const epoch = getThemeEpoch();
|
|
207
|
+
const tagged: NativeTagged = chart;
|
|
208
|
+
let entry = tagged[kNativeBlob];
|
|
209
|
+
if (entry?.epoch !== epoch) {
|
|
210
|
+
const svg = prepareSvg(chart.svg, svgFigurePalette());
|
|
211
|
+
entry = { epoch, blob: registerNativeBlob(new TextEncoder().encode(svg), "image/svg+xml") };
|
|
212
|
+
tagged[kNativeBlob] = entry;
|
|
213
|
+
}
|
|
214
|
+
return node(
|
|
215
|
+
"image",
|
|
216
|
+
{
|
|
217
|
+
blob: entry.blob,
|
|
218
|
+
alt: chart.alt,
|
|
219
|
+
w: chart.width,
|
|
220
|
+
h: chart.height,
|
|
221
|
+
max: { w: `${Math.round(chart.width / UNITS_PER_COLUMN)}ch` },
|
|
222
|
+
},
|
|
223
|
+
undefined,
|
|
224
|
+
key,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface TableChartFigureOptions {
|
|
229
|
+
/** Shared inline-image budget. */
|
|
230
|
+
budget?: ImageBudget;
|
|
231
|
+
/** The chart's rows changed outside a text update: its pick or raster landed. */
|
|
232
|
+
onChange: () => void;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The chart under one table, as a terminal image a blank row below it. Looks
|
|
237
|
+
* the chart up on first render — a block rebuilt mid-stream never asks for a
|
|
238
|
+
* pick it drops — and renders nothing until it is drawn, or when the table
|
|
239
|
+
* gets no chart.
|
|
240
|
+
*/
|
|
241
|
+
export class TableChartFigure implements Component {
|
|
242
|
+
readonly #options: TableChartFigureOptions;
|
|
243
|
+
readonly #figure: SvgFigure;
|
|
244
|
+
#table: Tokens.Table | undefined;
|
|
245
|
+
/** `undefined` while not looked up or waiting for a pick. */
|
|
246
|
+
#chart: TableChart | null | undefined;
|
|
247
|
+
#waiting = false;
|
|
248
|
+
#disposed = false;
|
|
249
|
+
|
|
250
|
+
constructor(options: TableChartFigureOptions) {
|
|
251
|
+
this.#options = options;
|
|
252
|
+
this.#figure = new SvgFigure({ budget: options.budget, onChange: options.onChange });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Point the figure at `table`. */
|
|
256
|
+
update(table: Tokens.Table): void {
|
|
257
|
+
if (table.raw === this.#table?.raw) return;
|
|
258
|
+
this.#table = table;
|
|
259
|
+
this.#chart = undefined;
|
|
260
|
+
this.#waiting = false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Whether the chart is still being looked up, picked or drawn. */
|
|
264
|
+
get pending(): boolean {
|
|
265
|
+
return this.#table !== undefined && (this.#chart === undefined || (this.#chart !== null && this.#figure.pending));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
render(width: number): readonly string[] {
|
|
269
|
+
if (this.#chart === undefined && !this.#waiting) this.#resolve();
|
|
270
|
+
if (!this.#chart) return [];
|
|
271
|
+
const rows = this.#figure.render(width);
|
|
272
|
+
return rows.length ? ["", ...rows] : rows;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
invalidate(): void {
|
|
276
|
+
this.#figure.invalidate();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
dispose(): void {
|
|
280
|
+
this.#disposed = true;
|
|
281
|
+
this.#figure.dispose();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#resolve(): void {
|
|
285
|
+
const table = this.#table;
|
|
286
|
+
if (!table) return;
|
|
287
|
+
const found = lookupTableChart(table);
|
|
288
|
+
if (found instanceof Promise) {
|
|
289
|
+
this.#waiting = true;
|
|
290
|
+
void found.then(() => {
|
|
291
|
+
if (this.#disposed || this.#table !== table) return;
|
|
292
|
+
this.#waiting = false;
|
|
293
|
+
this.#resolve();
|
|
294
|
+
this.#options.onChange();
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
this.#chart = found;
|
|
299
|
+
if (found) this.#figure.update(found.svg, true);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -57,6 +57,12 @@ export interface AppendOnlyTranscriptBlock {
|
|
|
57
57
|
|
|
58
58
|
interface FinalizableBlock {
|
|
59
59
|
isTranscriptBlockFinalized?(): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* A finalized block whose rows still wait on async work (an SVG figure's
|
|
62
|
+
* raster). Retirement holds until it lands: committed rows can never be
|
|
63
|
+
* repainted, so retiring early would freeze a placeholder into scrollback.
|
|
64
|
+
*/
|
|
65
|
+
isTranscriptBlockPending?(): boolean;
|
|
60
66
|
/** Render the row that must remain represented under emergency viewport pressure. */
|
|
61
67
|
renderTranscriptBlockEmergencyRow?(width: number): string | undefined;
|
|
62
68
|
}
|
|
@@ -121,7 +127,7 @@ const EMPTY_STABLE_ROWS: readonly TranscriptStableRow[] = [];
|
|
|
121
127
|
|
|
122
128
|
function isFinalized(component: Component): boolean {
|
|
123
129
|
const block = component as Component & FinalizableBlock;
|
|
124
|
-
return block.isTranscriptBlockFinalized?.() ?? true;
|
|
130
|
+
return (block.isTranscriptBlockFinalized?.() ?? true) && block.isTranscriptBlockPending?.() !== true;
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
function blockMode(component: Component): TranscriptBlockMode {
|
package/src/components/editor.ts
CHANGED
|
@@ -737,9 +737,6 @@ export class Editor implements Component, Focusable {
|
|
|
737
737
|
#undoStack: EditorState[] = [];
|
|
738
738
|
#suspendUndo = false;
|
|
739
739
|
|
|
740
|
-
// Debounce timer for autocomplete updates
|
|
741
|
-
#autocompleteTimeout?: NodeJS.Timeout;
|
|
742
|
-
|
|
743
740
|
onSubmit?: (text: string) => void | Promise<void>;
|
|
744
741
|
onAltEnter?: (text: string) => void;
|
|
745
742
|
onChange?: (text: string) => void;
|
|
@@ -1907,7 +1904,7 @@ export class Editor implements Component, Focusable {
|
|
|
1907
1904
|
if (kb.matchesCanonical(canonical, "tui.input.tab") || rightArrowAccepts) {
|
|
1908
1905
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1909
1906
|
// Check for stale autocomplete state due to buffer edits since last refresh
|
|
1910
|
-
// (destructive keys or paste can outrun the
|
|
1907
|
+
// (destructive keys or paste can outrun the async refresh).
|
|
1911
1908
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1912
1909
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1913
1910
|
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
|
|
@@ -1917,7 +1914,7 @@ export class Editor implements Component, Focusable {
|
|
|
1917
1914
|
}
|
|
1918
1915
|
if (!selected) {
|
|
1919
1916
|
// An `@` popup whose narrowing filter matched nothing stays open with no
|
|
1920
|
-
// candidate (see #
|
|
1917
|
+
// candidate (see #refreshAutocomplete). Nothing to accept: cancel the
|
|
1921
1918
|
// popup and fall through so Tab keeps its normal completion role and a right
|
|
1922
1919
|
// arrow at end of line moves the cursor.
|
|
1923
1920
|
this.#cancelAutocomplete();
|
|
@@ -1939,7 +1936,7 @@ export class Editor implements Component, Focusable {
|
|
|
1939
1936
|
!this.#selectedCompletionIsSkillNamespace()
|
|
1940
1937
|
) {
|
|
1941
1938
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1942
|
-
// Check for stale autocomplete state
|
|
1939
|
+
// Check for stale autocomplete state while an async refresh is pending
|
|
1943
1940
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1944
1941
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1945
1942
|
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
|
|
@@ -2116,15 +2113,18 @@ export class Editor implements Component, Focusable {
|
|
|
2116
2113
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2117
2114
|
if (
|
|
2118
2115
|
findLeadingSlashCommandStart(textBeforeCursor) !== null &&
|
|
2116
|
+
textBeforeCursor.trim() !== "/" &&
|
|
2119
2117
|
this.#isInSubmittedSlashCommandContext() &&
|
|
2120
2118
|
this.#autocompleteProvider?.trySyncSlashCompletion
|
|
2121
2119
|
) {
|
|
2122
2120
|
const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
|
|
2123
|
-
|
|
2121
|
+
// The collapsed `/skill:` namespace row is not a runnable command; Enter submits
|
|
2122
|
+
// immediately after applying, so only a real command may be completed here.
|
|
2123
|
+
const selected = syncResult?.items.find(item => item.value !== SKILL_NAMESPACE);
|
|
2124
|
+
if (syncResult && selected) {
|
|
2124
2125
|
// Invalidate any pending async autocomplete so its stale results are discarded
|
|
2125
2126
|
this.#autocompleteRequestId += 1;
|
|
2126
2127
|
// Apply the best match and submit the completed command
|
|
2127
|
-
const selected = syncResult.items[0]!;
|
|
2128
2128
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
2129
2129
|
this.#state.lines,
|
|
2130
2130
|
this.#state.cursorLine,
|
|
@@ -2774,7 +2774,7 @@ export class Editor implements Component, Focusable {
|
|
|
2774
2774
|
* Mirrors the transient state the key dispatch tears down before this action so the two
|
|
2775
2775
|
* cannot diverge: a pending character jump is cancelled by any other key, and an open
|
|
2776
2776
|
* spelling-assist popup is dismissed by anything that is not one of its accept keys (its
|
|
2777
|
-
*
|
|
2777
|
+
* refresh skips assist mode, so a surviving list would hang around forever).
|
|
2778
2778
|
* While Vim owns the buffer (Normal or Visual) the operation is Vim's `x` — deleting the
|
|
2779
2779
|
* selection and returning to Normal in Visual mode, the grapheme under the cursor
|
|
2780
2780
|
* otherwise. Only Insert mode and Vim-off editors delete straight through. */
|
|
@@ -3184,7 +3184,7 @@ export class Editor implements Component, Focusable {
|
|
|
3184
3184
|
}
|
|
3185
3185
|
}
|
|
3186
3186
|
} else {
|
|
3187
|
-
this.#
|
|
3187
|
+
this.#refreshAutocomplete();
|
|
3188
3188
|
}
|
|
3189
3189
|
}
|
|
3190
3190
|
|
|
@@ -3280,7 +3280,7 @@ export class Editor implements Component, Focusable {
|
|
|
3280
3280
|
/** Re-evaluate autocomplete triggers for the text ending at the cursor (used after bulk edits). */
|
|
3281
3281
|
#retriggerAutocompleteAtCursor(): void {
|
|
3282
3282
|
if (this.#autocompleteState) {
|
|
3283
|
-
this.#
|
|
3283
|
+
this.#refreshAutocomplete();
|
|
3284
3284
|
return;
|
|
3285
3285
|
}
|
|
3286
3286
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -3462,7 +3462,7 @@ export class Editor implements Component, Focusable {
|
|
|
3462
3462
|
this.#cancelAutocomplete();
|
|
3463
3463
|
this.onAutocompleteUpdate?.();
|
|
3464
3464
|
} else {
|
|
3465
|
-
this.#
|
|
3465
|
+
this.#refreshAutocomplete();
|
|
3466
3466
|
}
|
|
3467
3467
|
} else {
|
|
3468
3468
|
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
|
|
@@ -3642,7 +3642,7 @@ export class Editor implements Component, Focusable {
|
|
|
3642
3642
|
this.#notifyChange();
|
|
3643
3643
|
|
|
3644
3644
|
if (this.#autocompleteState) {
|
|
3645
|
-
this.#
|
|
3645
|
+
this.#refreshAutocomplete();
|
|
3646
3646
|
} else {
|
|
3647
3647
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
3648
3648
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
@@ -3976,7 +3976,7 @@ export class Editor implements Component, Focusable {
|
|
|
3976
3976
|
|
|
3977
3977
|
// Update or re-trigger autocomplete after forward delete
|
|
3978
3978
|
if (this.#autocompleteState) {
|
|
3979
|
-
this.#
|
|
3979
|
+
this.#refreshAutocomplete();
|
|
3980
3980
|
} else {
|
|
3981
3981
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
3982
3982
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
@@ -4260,7 +4260,7 @@ export class Editor implements Component, Focusable {
|
|
|
4260
4260
|
if (!token.includes(" ") && !token.slice(1).includes("/")) {
|
|
4261
4261
|
// Guard the timing window where the popup was built for an earlier
|
|
4262
4262
|
// query (e.g. bare `/`) and the user typed further characters before
|
|
4263
|
-
// the
|
|
4263
|
+
// the async refresh landed: accept the stale skill only
|
|
4264
4264
|
// when the refreshed popup would still surface it (same gate as
|
|
4265
4265
|
// buildMidPromptSkillCompletions). `tmp` after a bare slash
|
|
4266
4266
|
// therefore falls through to file completion instead of rewriting
|
|
@@ -4547,7 +4547,6 @@ export class Editor implements Component, Focusable {
|
|
|
4547
4547
|
|
|
4548
4548
|
#cancelAutocomplete(notifyCancel: boolean = false): void {
|
|
4549
4549
|
const wasAutocompleting = this.#autocompleteState !== null;
|
|
4550
|
-
this.#clearAutocompleteTimeout();
|
|
4551
4550
|
this.#invalidateAutocompleteRequests();
|
|
4552
4551
|
this.#autocompleteState = null;
|
|
4553
4552
|
this.#autocompleteList = undefined;
|
|
@@ -4666,7 +4665,7 @@ export class Editor implements Component, Focusable {
|
|
|
4666
4665
|
this.onAutocompleteUpdate?.();
|
|
4667
4666
|
}
|
|
4668
4667
|
|
|
4669
|
-
/** Filter a shown `@` file list to the live `@` token until the
|
|
4668
|
+
/** Filter a shown `@` file list to the live `@` token until the async refresh replaces it. */
|
|
4670
4669
|
#narrowAtFileList(): void {
|
|
4671
4670
|
if (!this.#autocompleteList || !this.#autocompletePrefix.startsWith("@")) return;
|
|
4672
4671
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
@@ -4682,22 +4681,45 @@ export class Editor implements Component, Focusable {
|
|
|
4682
4681
|
for (const resolve of waiters) resolve();
|
|
4683
4682
|
}
|
|
4684
4683
|
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4684
|
+
/**
|
|
4685
|
+
* Re-query suggestions for the live text after an edit while the popup is open. Runs on
|
|
4686
|
+
* every edit; the request queue coalesces bursts (a newer request replaces the pending one
|
|
4687
|
+
* and aborts the in-flight one), so no timer delays the popup behind the typing.
|
|
4688
|
+
*/
|
|
4689
|
+
#refreshAutocomplete(): void {
|
|
4690
|
+
if (this.#autocompleteState === "assist") return;
|
|
4691
|
+
if (this.#refreshSlashCommandListSync()) return;
|
|
4692
|
+
this.#narrowAtFileList();
|
|
4693
|
+
void this.#updateAutocomplete();
|
|
4694
4694
|
}
|
|
4695
4695
|
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4696
|
+
/**
|
|
4697
|
+
* Rebuild a shown slash command-name popup from the live token synchronously. An async
|
|
4698
|
+
* refresh resolves a microtask later at best, so Enter/Tab arriving in the same input
|
|
4699
|
+
* batch would accept a row computed for an earlier query (`/` → `mod` → Enter ran `/login`).
|
|
4700
|
+
* Returns false when the async refresh must handle the edit (arguments, mid-prompt
|
|
4701
|
+
* skills, absolute-path fall-through).
|
|
4702
|
+
*/
|
|
4703
|
+
#refreshSlashCommandListSync(): boolean {
|
|
4704
|
+
const provider = this.#autocompleteProvider;
|
|
4705
|
+
if (this.#autocompleteState !== "regular" || !provider?.trySyncSlashCompletion) return false;
|
|
4706
|
+
if (!this.#isInSubmittedSlashCommandContext()) return false;
|
|
4707
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
4708
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
4709
|
+
const token = textBeforeCursor.trimStart();
|
|
4710
|
+
if (token.includes(" ")) return false;
|
|
4711
|
+
const suggestions = provider.trySyncSlashCompletion(textBeforeCursor);
|
|
4712
|
+
if (suggestions) {
|
|
4713
|
+
this.#invalidateAutocompleteRequests();
|
|
4714
|
+
this.#showAutocompleteSuggestions(suggestions, "regular");
|
|
4715
|
+
return true;
|
|
4700
4716
|
}
|
|
4717
|
+
// No command matches: an inner slash may still be an absolute path, which needs the
|
|
4718
|
+
// async file lookup. Otherwise the async refresh would only close the popup.
|
|
4719
|
+
if (token.slice(1).includes("/")) return false;
|
|
4720
|
+
this.#cancelAutocomplete();
|
|
4721
|
+
this.onAutocompleteUpdate?.();
|
|
4722
|
+
return true;
|
|
4701
4723
|
}
|
|
4702
4724
|
|
|
4703
4725
|
/**
|
package/src/components/image.ts
CHANGED
|
@@ -65,6 +65,11 @@ const RESTORE_CURSOR = "\x1b8";
|
|
|
65
65
|
// non-plain so transcript blank-edge trimming does not collapse image-only blocks.
|
|
66
66
|
const RESERVED_IMAGE_ROW = "\x1b[0m";
|
|
67
67
|
|
|
68
|
+
/** Widest an {@link Image} rendered at `width` columns draws; callers sizing a raster for it (`SvgFigure`) match it. */
|
|
69
|
+
export function imageMaxColumns(width: number): number {
|
|
70
|
+
return Math.max(1, width - 2);
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
/** Default count of inline images kept as live graphics before older ones fall back to text. */
|
|
69
74
|
export const DEFAULT_MAX_INLINE_IMAGES = 8;
|
|
70
75
|
|
|
@@ -234,6 +239,8 @@ export class ImageBudget {
|
|
|
234
239
|
* placements) instead of every image ever registered.
|
|
235
240
|
*/
|
|
236
241
|
#watchedPlacements = new Set<PlacementEmitState>();
|
|
242
|
+
/** Ids whose owner replaced them for good ({@link release}); retired once no frame shows them. */
|
|
243
|
+
#released = new Set<number>();
|
|
237
244
|
|
|
238
245
|
constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) {
|
|
239
246
|
this.#cap = normalizeCap(cap);
|
|
@@ -284,6 +291,17 @@ export class ImageBudget {
|
|
|
284
291
|
return id;
|
|
285
292
|
}
|
|
286
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Retire the graphic held under `key` as soon as no frame shows it, instead
|
|
296
|
+
* of when the residency sweep reaches it. For an owner that replaced the
|
|
297
|
+
* image for good — a streaming SVG figure's earlier raster — so superseded
|
|
298
|
+
* revisions never push older images (scrollback included) out of the store.
|
|
299
|
+
*/
|
|
300
|
+
release(key: string): void {
|
|
301
|
+
const id = this.#keyToId.get(key);
|
|
302
|
+
if (id !== undefined) this.#released.add(id);
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
/**
|
|
288
306
|
* Start an alternate-buffer lifecycle. Call once per `?1049h`, before the
|
|
289
307
|
* first pass of the fullscreen overlay or resize borrow that owns the buffer.
|
|
@@ -405,6 +423,13 @@ export class ImageBudget {
|
|
|
405
423
|
if (this.#passShowsLive(id)) liveIds.add(id);
|
|
406
424
|
}
|
|
407
425
|
const transmitted = this.#transmitted[this.#surface];
|
|
426
|
+
for (const id of this.#released) {
|
|
427
|
+
// #retire refuses while this pass or the other surface still shows it.
|
|
428
|
+
if (transmitted.has(id)) this.#retire(id);
|
|
429
|
+
if (this.#isTransmitted(id)) continue;
|
|
430
|
+
this.#forgetKeyForId(id);
|
|
431
|
+
this.#released.delete(id);
|
|
432
|
+
}
|
|
408
433
|
if (this.#cap <= 0 || transmitted.size <= this.#cap) return;
|
|
409
434
|
for (const id of transmitted) {
|
|
410
435
|
if (transmitted.size <= this.#cap) break;
|
|
@@ -501,6 +526,7 @@ export class ImageBudget {
|
|
|
501
526
|
this.#idToKey.clear();
|
|
502
527
|
this.#placementState.clear();
|
|
503
528
|
this.#watchedPlacements.clear();
|
|
529
|
+
this.#released.clear();
|
|
504
530
|
for (const surface of SURFACES) this.#liveIds[surface].clear();
|
|
505
531
|
return [...ids];
|
|
506
532
|
}
|
|
@@ -892,7 +918,7 @@ export class Image implements Component {
|
|
|
892
918
|
}
|
|
893
919
|
|
|
894
920
|
const cap = this.#options.maxWidthCells;
|
|
895
|
-
const maxWidth = cap != null && cap > 0 ? Math.min(width
|
|
921
|
+
const maxWidth = cap != null && cap > 0 ? Math.min(imageMaxColumns(width), cap) : imageMaxColumns(width);
|
|
896
922
|
|
|
897
923
|
let lines: string[];
|
|
898
924
|
|
|
@@ -1343,10 +1343,13 @@ function probeBoundary(text: string, offset: number, size: number, mathBlocks: M
|
|
|
1343
1343
|
}
|
|
1344
1344
|
|
|
1345
1345
|
/**
|
|
1346
|
-
* Lex a whole document
|
|
1347
|
-
*
|
|
1346
|
+
* Lex a whole document with the renderer's rules (math, custom rules,
|
|
1347
|
+
* strikethrough), windowing anything large enough for the quadratic scan to
|
|
1348
|
+
* bite. `links` holds every reference definition, at any nesting depth. Callers
|
|
1349
|
+
* that split a document along its blocks (table charts) lex through this so
|
|
1350
|
+
* they see the blocks Markdown renders.
|
|
1348
1351
|
*/
|
|
1349
|
-
function lexDocument(text: string): TokensList {
|
|
1352
|
+
export function lexDocument(text: string): TokensList {
|
|
1350
1353
|
// A CR shifts every `raw` span (marked normalizes CRLF before tokenizing), so
|
|
1351
1354
|
// window offsets would address the wrong characters — lex those in one pass.
|
|
1352
1355
|
if (text.length < WINDOWED_LEX_MIN_BYTES || text.includes("\r")) return markdownParser.lexer(text);
|