@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +26 -58
  2. package/dist/types/app-keybindings.d.ts +1 -1
  3. package/dist/types/autocomplete.d.ts +5 -2
  4. package/dist/types/charts/chart-plan.d.ts +92 -0
  5. package/dist/types/charts/chart-svg.d.ts +30 -0
  6. package/dist/types/charts/table-data.d.ts +107 -0
  7. package/dist/types/chat/assistant-message.d.ts +4 -0
  8. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  9. package/dist/types/chat/figure-markdown.d.ts +42 -0
  10. package/dist/types/chat/svg-figure.d.ts +33 -0
  11. package/dist/types/chat/svg-source.d.ts +49 -0
  12. package/dist/types/chat/table-chart.d.ts +88 -0
  13. package/dist/types/components/editor.d.ts +1 -1
  14. package/dist/types/components/image.d.ts +9 -0
  15. package/dist/types/components/markdown.d.ts +9 -0
  16. package/dist/types/fuzzy.d.ts +14 -0
  17. package/dist/types/keybindings.d.ts +1 -1
  18. package/dist/types/overlays/agents-hub.d.ts +9 -0
  19. package/dist/types/overlays/annotation-overlay.d.ts +5 -2
  20. package/dist/types/overlays/model-browser.d.ts +25 -3
  21. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  22. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
  23. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  24. package/dist/types/render/composite.d.ts +2 -0
  25. package/dist/types/tools/glob.d.ts +4 -0
  26. package/dist/types/utils.d.ts +6 -0
  27. package/package.json +9 -9
  28. package/src/autocomplete.ts +8 -10
  29. package/src/charts/chart-plan.ts +367 -0
  30. package/src/charts/chart-svg.ts +693 -0
  31. package/src/charts/table-data.ts +436 -0
  32. package/src/chat/assistant-message.ts +139 -21
  33. package/src/chat/chat-transcript-builder.ts +4 -0
  34. package/src/chat/figure-markdown.ts +174 -0
  35. package/src/chat/svg-figure.ts +364 -0
  36. package/src/chat/svg-source.ts +188 -0
  37. package/src/chat/table-chart.ts +301 -0
  38. package/src/chrome/transcript-container.ts +7 -1
  39. package/src/components/editor.ts +52 -30
  40. package/src/components/image.ts +27 -1
  41. package/src/components/markdown.ts +6 -3
  42. package/src/fuzzy.ts +59 -19
  43. package/src/keybindings.ts +1 -1
  44. package/src/overlays/agent-transcript-viewer.ts +2 -0
  45. package/src/overlays/agents-hub.ts +14 -1
  46. package/src/overlays/annotation-overlay.ts +179 -80
  47. package/src/overlays/ask-dialog.ts +1 -1
  48. package/src/overlays/model-browser.ts +169 -76
  49. package/src/overlays/plan-review-overlay.ts +41 -4
  50. package/src/prompt/composer.ts +2 -14
  51. package/src/prompt/interactive-context-helpers.ts +3 -0
  52. package/src/prompt/model-mention-autocomplete.ts +14 -14
  53. package/src/render/composite.ts +62 -0
  54. package/src/status-line/segments.ts +3 -1
  55. package/src/tools/glob.ts +23 -6
  56. package/src/tui.ts +118 -105
  57. 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 {
@@ -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 debounced update).
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 #debouncedUpdateAutocomplete). Nothing to accept: cancel the
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 due to debounce
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
- if (syncResult && syncResult.items.length > 0) {
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
- * debounced refresh skips assist mode, so a surviving list would hang around forever).
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.#debouncedUpdateAutocomplete();
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.#debouncedUpdateAutocomplete();
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.#debouncedUpdateAutocomplete();
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.#debouncedUpdateAutocomplete();
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.#debouncedUpdateAutocomplete();
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 100 ms debounced refresh fired: accept the stale skill only
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 debounced refresh replaces it. */
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
- #debouncedUpdateAutocomplete(): void {
4686
- if (this.#autocompleteState !== "assist") this.#narrowAtFileList();
4687
- if (this.#autocompleteTimeout) {
4688
- clearTimeout(this.#autocompleteTimeout);
4689
- }
4690
- this.#autocompleteTimeout = setTimeout(() => {
4691
- void this.#updateAutocomplete();
4692
- this.#autocompleteTimeout = undefined;
4693
- }, 100);
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
- #clearAutocompleteTimeout(): void {
4697
- if (this.#autocompleteTimeout) {
4698
- clearTimeout(this.#autocompleteTimeout);
4699
- this.#autocompleteTimeout = undefined;
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
  /**
@@ -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 - 2, cap) : width - 2;
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, windowing anything large enough for the quadratic scan
1347
- * to bite. `links` holds every reference definition, at any nesting depth.
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);