@oh-my-pi/pi-tui 17.4.2 → 18.0.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.
@@ -1165,6 +1165,15 @@ export interface DefaultTextStyle {
1165
1165
  underline?: boolean;
1166
1166
  }
1167
1167
 
1168
+ /**
1169
+ * Stateful incremental code highlighter carrying parser state across pushes.
1170
+ * Produced per streaming fence by {@link MarkdownTheme.createHighlightStream}.
1171
+ */
1172
+ export interface HighlightStreamSession {
1173
+ /** Highlight the next chunk and advance parser state. */
1174
+ push(chunk: string): string;
1175
+ }
1176
+
1168
1177
  /**
1169
1178
  * Theme functions for markdown elements.
1170
1179
  * Each function takes text and returns styled text with ANSI codes.
@@ -1185,6 +1194,14 @@ export interface MarkdownTheme {
1185
1194
  strikethrough: (text: string) => string;
1186
1195
  underline: (text: string) => string;
1187
1196
  highlightCode?: (code: string, lang?: string) => string[];
1197
+ /**
1198
+ * Create a stateful incremental highlighter for one streaming code fence.
1199
+ * `push` receives newline-terminated complete lines (only the final push
1200
+ * may omit the trailing newline) and must return highlighted ANSI text for
1201
+ * exactly the pushed chunk, byte-identical to highlighting the concatenated
1202
+ * text through `highlightCode`. Return null when `lang` is unsupported.
1203
+ */
1204
+ createHighlightStream?: (lang?: string) => HighlightStreamSession | null;
1188
1205
  /**
1189
1206
  * Resolve a mermaid ASCII rendering by fenced block source text.
1190
1207
  * Return null to fall back to fenced code rendering.
@@ -1455,10 +1472,21 @@ interface StreamPrefixLineCache extends RenderSignature {
1455
1472
  lines: readonly string[];
1456
1473
  tables: readonly TableRenderSpec[];
1457
1474
  }
1458
- interface StreamingDiffLineCache extends RenderSignature {
1475
+ interface StreamingHighlightCache extends RenderSignature {
1459
1476
  lang: string | undefined;
1460
1477
  text: string;
1461
1478
  lines: readonly string[];
1479
+ stream: HighlightStreamSession;
1480
+ }
1481
+
1482
+ /**
1483
+ * Split a highlight-stream push result (newline-terminated lines) into
1484
+ * per-line strings, dropping the empty tail produced by the final newline.
1485
+ */
1486
+ function splitPushedHighlightLines(pushed: string): string[] {
1487
+ const lines = pushed.split("\n");
1488
+ lines.pop();
1489
+ return lines;
1462
1490
  }
1463
1491
 
1464
1492
  interface TableLayoutLock {
@@ -1532,11 +1560,14 @@ export class Markdown
1532
1560
  // True while #renderStreamingContentLines renders the frozen token range:
1533
1561
  // frozen code blocks highlight even in transient mode so their bytes match
1534
1562
  // the finalized render (they render once into the prefix line cache, so
1535
- // the FFI cost is amortized). The volatile tail normally stays
1536
- // unhighlighted; streaming diff fences line-highlight completed rows so
1537
- // semantic colors reach native scrollback before rows leave the viewport.
1563
+ // the FFI cost is amortized). In the volatile tail, an open fence
1564
+ // incrementally highlights its completed lines through a stateful
1565
+ // highlight stream (falling back to per-line highlighting for diff-family
1566
+ // fences when the theme lacks one) so semantic colors reach native
1567
+ // scrollback before rows leave the viewport; only the trailing partial
1568
+ // line stays unhighlighted.
1538
1569
  #renderingFrozenPrefix = false;
1539
- #streamingDiffLineCache?: StreamingDiffLineCache;
1570
+ #streamingHighlightCache?: StreamingHighlightCache;
1540
1571
  #activeRenderSignature?: RenderSignature;
1541
1572
  // Streaming tables may grow naturally while wholly repaintable. Once any
1542
1573
  // physical row of a table enters native scrollback, its current column widths
@@ -1548,9 +1579,19 @@ export class Markdown
1548
1579
  #activeTableRenderSpecs?: TableRenderSpec[];
1549
1580
 
1550
1581
  #ignoreTight = false;
1582
+ // Width-independent mutation counter for the width-epoch leading-stability
1583
+ // checks (getNativeScrollbackWidthEpochRevision): a Markdown that precedes an
1584
+ // epoch source (startup changelog before the transcript, a thinking block
1585
+ // before the streaming answer) would otherwise be validated by comparing
1586
+ // width-dependent row counts, which conflates reflow with mutation and fails
1587
+ // resolution on every width change. Bumped by every content-shape mutation.
1588
+ #widthEpochRevision = 0;
1551
1589
 
1552
1590
  setIgnoreTight(ignore: boolean): this {
1553
- if (this.#ignoreTight !== ignore) this.#clearTableLayouts();
1591
+ if (this.#ignoreTight !== ignore) {
1592
+ this.#clearTableLayouts();
1593
+ this.#widthEpochRevision++;
1594
+ }
1554
1595
  this.#ignoreTight = ignore;
1555
1596
  this.invalidate();
1556
1597
  return this;
@@ -1591,10 +1632,15 @@ export class Markdown
1591
1632
  this.#streamPrefixLineCache = undefined;
1592
1633
  this.#settledExposedText = undefined;
1593
1634
  }
1635
+ this.#widthEpochRevision++;
1594
1636
  this.invalidate();
1595
1637
  return true;
1596
1638
  }
1597
1639
 
1640
+ getNativeScrollbackWidthEpochRevision(): number {
1641
+ return this.#widthEpochRevision;
1642
+ }
1643
+
1598
1644
  invalidate(): void {
1599
1645
  this.#cachedText = undefined;
1600
1646
  this.#cachedWidth = undefined;
@@ -1608,6 +1654,7 @@ export class Markdown
1608
1654
  const next = value === true;
1609
1655
  if (this.#transientRenderCache === next) return;
1610
1656
  this.#transientRenderCache = next;
1657
+ this.#widthEpochRevision++;
1611
1658
  this.invalidate();
1612
1659
  }
1613
1660
 
@@ -2137,17 +2184,15 @@ export class Markdown
2137
2184
  const bodyLines: RenderedLine[] = [];
2138
2185
  const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
2139
2186
  const lang = "lang" in token && typeof token.lang === "string" ? token.lang : undefined;
2140
- const normalizedLang = lang?.toLowerCase();
2141
- const canStreamDiff =
2142
- this.transientRenderCache &&
2143
- !this.#renderingFrozenPrefix &&
2144
- this.#theme.highlightCode &&
2145
- (normalizedLang === "diff" || normalizedLang === "patch" || normalizedLang === "udiff");
2146
2187
  const addBodyLine = (line: string): void => {
2147
2188
  bodyLines.push(renderedLine(literalCode ? line : codeIndent + line, literalCode));
2148
2189
  };
2149
2190
 
2150
- if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
2191
+ const streaming = this.transientRenderCache && !this.#renderingFrozenPrefix;
2192
+ if (this.#theme.highlightCode && (!streaming || this.#codeTokenHasClosingFence(token))) {
2193
+ // Finalized content — or a fence that closed mid-stream, which
2194
+ // highlights through the same whole-block call the finalized render
2195
+ // uses so rows entering native scrollback byte-match it.
2151
2196
  const highlightedLines = this.#theme.highlightCode(tokenText, lang);
2152
2197
  for (const hlLine of highlightedLines) {
2153
2198
  addBodyLine(hlLine);
@@ -2155,18 +2200,18 @@ export class Markdown
2155
2200
  return bodyLines;
2156
2201
  }
2157
2202
 
2158
- if (canStreamDiff) {
2159
- const closedFence = this.#codeTokenHasClosingFence(token);
2203
+ if (streaming && this.#theme.highlightCode) {
2204
+ // Open fence: highlight completed lines incrementally so semantic
2205
+ // colors reach native scrollback before rows leave the viewport;
2206
+ // only the trailing partial line stays unhighlighted.
2160
2207
  const lineEnd = tokenText.lastIndexOf("\n");
2161
- if (closedFence || lineEnd >= 0) {
2162
- const completedText = closedFence ? tokenText : tokenText.slice(0, lineEnd);
2163
- for (const hlLine of this.#highlightStreamingDiffLines(completedText, lang)) {
2208
+ const completedLines = lineEnd >= 0 ? this.#highlightStreamingLines(tokenText.slice(0, lineEnd), lang) : null;
2209
+ if (completedLines) {
2210
+ for (const hlLine of completedLines) {
2164
2211
  addBodyLine(hlLine);
2165
2212
  }
2166
- if (!closedFence) {
2167
- for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) {
2168
- addBodyLine(this.#theme.codeBlock(codeLine));
2169
- }
2213
+ for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) {
2214
+ addBodyLine(this.#theme.codeBlock(codeLine));
2170
2215
  }
2171
2216
  return bodyLines;
2172
2217
  }
@@ -2209,11 +2254,17 @@ export class Markdown
2209
2254
  return false;
2210
2255
  }
2211
2256
 
2212
- #highlightStreamingDiffLines(completedText: string, lang: string | undefined): readonly string[] {
2213
- const highlightCode = this.#theme.highlightCode;
2214
- if (!highlightCode) return [];
2257
+ /**
2258
+ * Highlight the completed (newline-terminated) prefix of a streaming code
2259
+ * fence. Uses a stateful per-fence highlight stream so each render pushes
2260
+ * only the newly completed lines, with output byte-identical to the
2261
+ * whole-block `highlightCode` the finalized render performs. Returns null
2262
+ * when no stream is available for `lang` (caller falls back to plain
2263
+ * code-block styling).
2264
+ */
2265
+ #highlightStreamingLines(completedText: string, lang: string | undefined): readonly string[] | null {
2215
2266
  const signature = this.#activeRenderSignature;
2216
- const cache = this.#streamingDiffLineCache;
2267
+ const cache = this.#streamingHighlightCache;
2217
2268
  if (
2218
2269
  signature &&
2219
2270
  cache &&
@@ -2233,25 +2284,49 @@ export class Markdown
2233
2284
  cache.headingProbe === signature.headingProbe
2234
2285
  ) {
2235
2286
  if (completedText.length === cache.text.length) return cache.lines;
2236
- const lines = cache.lines.slice();
2287
+ // Invariant: the stream has consumed `cache.text + "\n"`, so pushing
2288
+ // the added lines with a trailing newline advances it to
2289
+ // `completedText + "\n"` — every fed line stays newline-terminated.
2237
2290
  const addedText = completedText.slice(cache.text.length + 1);
2238
- for (const codeLine of addedText.split("\n")) {
2239
- lines.push(...highlightCode(codeLine, lang));
2240
- }
2241
- this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
2291
+ const lines = cache.lines.concat(splitPushedHighlightLines(cache.stream.push(`${addedText}\n`)));
2292
+ this.#streamingHighlightCache = { ...signature, lang, text: completedText, lines, stream: cache.stream };
2242
2293
  return lines;
2243
2294
  }
2244
2295
 
2245
- const lines: string[] = [];
2246
- for (const codeLine of completedText.split("\n")) {
2247
- lines.push(...highlightCode(codeLine, lang));
2248
- }
2296
+ const stream = this.#createHighlightStream(lang);
2297
+ if (!stream) return null;
2298
+ const lines = splitPushedHighlightLines(stream.push(`${completedText}\n`));
2249
2299
  if (signature) {
2250
- this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
2300
+ this.#streamingHighlightCache = { ...signature, lang, text: completedText, lines, stream };
2251
2301
  }
2252
2302
  return lines;
2253
2303
  }
2254
2304
 
2305
+ /**
2306
+ * Resolve a highlight stream for `lang`. Prefers the theme's stateful
2307
+ * factory; without one, emulates a stream via per-line `highlightCode` for
2308
+ * diff-family fences only — that grammar is line-local, so per-line output
2309
+ * matches the whole-block render (other grammars carry cross-line state
2310
+ * and would diverge).
2311
+ */
2312
+ #createHighlightStream(lang: string | undefined): HighlightStreamSession | null {
2313
+ const factory = this.#theme.createHighlightStream;
2314
+ if (factory) return factory(lang);
2315
+ const highlightCode = this.#theme.highlightCode;
2316
+ if (!highlightCode) return null;
2317
+ const normalizedLang = lang?.toLowerCase();
2318
+ if (normalizedLang !== "diff" && normalizedLang !== "patch" && normalizedLang !== "udiff") return null;
2319
+ return {
2320
+ push: chunk => {
2321
+ const lines = chunk.split("\n");
2322
+ const trailing = lines.pop() ?? "";
2323
+ let out = "";
2324
+ for (const line of lines) out += `${highlightCode(line, lang).join("\n")}\n`;
2325
+ return trailing ? out + highlightCode(trailing, lang).join("\n") : out;
2326
+ },
2327
+ };
2328
+ }
2329
+
2255
2330
  #renderEmptyPaddingLines(signature: RenderSignature): string[] {
2256
2331
  const emptyLine = padding(signature.width);
2257
2332
  const emptyLines: string[] = [];
@@ -27,6 +27,8 @@ export interface SelectItem {
27
27
  value: string;
28
28
  label: string;
29
29
  description?: string;
30
+ /** Optional type-indicator glyph rendered in an aligned column before the label */
31
+ icon?: string;
30
32
  /** Dim hint text shown inline after cursor when this item is selected */
31
33
  hint?: string;
32
34
  }
@@ -38,6 +40,8 @@ export interface SelectListTheme {
38
40
  scrollInfo: (text: string) => string;
39
41
  noMatch: (text: string) => string;
40
42
  symbols: SymbolTheme;
43
+ /** Style for the type-icon column on unselected rows. Defaults to plain text. */
44
+ icon?: (text: string) => string;
41
45
  /** Hover band applied to the full row under the mouse pointer. */
42
46
  hovered?: (text: string) => string;
43
47
  }
@@ -64,12 +68,18 @@ export interface SelectListLayoutOptions {
64
68
  * wrap unevenly.
65
69
  */
66
70
  wrapDescription?: boolean;
71
+ /**
72
+ * Cap wrapped descriptions at this many visual rows; the last kept row is
73
+ * ellipsized. Only meaningful with `wrapDescription`.
74
+ */
75
+ maxDescriptionRows?: number;
67
76
  }
68
77
 
69
78
  type SelectItemLayout =
70
79
  | {
71
80
  kind: "description";
72
81
  prefix: string;
82
+ iconCell: string;
73
83
  truncatedValue: string;
74
84
  spacing: string;
75
85
  descriptionSingleLine: string;
@@ -79,6 +89,7 @@ type SelectItemLayout =
79
89
  | {
80
90
  kind: "primary";
81
91
  prefix: string;
92
+ iconCell: string;
82
93
  truncatedValue: string;
83
94
  spacing: "";
84
95
  };
@@ -172,6 +183,7 @@ export class SelectList implements Component, MouseRoutable {
172
183
  }
173
184
 
174
185
  const primaryColumnWidth = this.#getPrimaryColumnWidth();
186
+ const iconColumnWidth = this.#getIconColumnWidth();
175
187
  const wrapEnabled = this.layout.wrapDescription === true;
176
188
  // `maxVisible` is the picker's visual row budget. For non-wrap layouts
177
189
  // every item is one row, so the budget matches the original item count.
@@ -190,7 +202,9 @@ export class SelectList implements Component, MouseRoutable {
190
202
  rowCounts[i] = 0;
191
203
  continue;
192
204
  }
193
- rowCounts[i] = wrapEnabled ? this.#computeItemRowCount(item, conservativeRowWidth, primaryColumnWidth) : 1;
205
+ rowCounts[i] = wrapEnabled
206
+ ? this.#computeItemRowCount(item, conservativeRowWidth, primaryColumnWidth, iconColumnWidth)
207
+ : 1;
194
208
  visualTotal += rowCounts[i];
195
209
  }
196
210
 
@@ -211,7 +225,13 @@ export class SelectList implements Component, MouseRoutable {
211
225
  const item = this.#filteredItems[i];
212
226
  if (!item) continue;
213
227
  const hovered = this.theme.hovered !== undefined && i === this.#hoveredIndex && i !== this.#selectedIndex;
214
- const itemRows = this.#renderItem(item, i === this.#selectedIndex, rowWidth, primaryColumnWidth);
228
+ const itemRows = this.#renderItem(
229
+ item,
230
+ i === this.#selectedIndex,
231
+ rowWidth,
232
+ primaryColumnWidth,
233
+ iconColumnWidth,
234
+ );
215
235
  for (const row of itemRows) {
216
236
  if (rows.length >= visualBudget) break;
217
237
  this.#hitRows[rows.length] = i;
@@ -280,25 +300,33 @@ export class SelectList implements Component, MouseRoutable {
280
300
  }
281
301
  }
282
302
 
283
- #renderItem(item: SelectItem, isSelected: boolean, width: number, primaryColumnWidth: number): string[] {
284
- const layout = this.#computeItemLayout(item, isSelected, width, primaryColumnWidth);
303
+ #renderItem(
304
+ item: SelectItem,
305
+ isSelected: boolean,
306
+ width: number,
307
+ primaryColumnWidth: number,
308
+ iconColumnWidth: number,
309
+ ): string[] {
310
+ const layout = this.#computeItemLayout(item, isSelected, width, primaryColumnWidth, iconColumnWidth);
285
311
  const { prefix, truncatedValue, spacing } = layout;
312
+ const iconCell =
313
+ layout.iconCell && !isSelected && this.theme.icon ? this.theme.icon(layout.iconCell) : layout.iconCell;
286
314
 
287
315
  if (layout.kind === "description") {
288
316
  const { descriptionSingleLine, descriptionStart, remainingWidth } = layout;
289
317
  if (this.layout.wrapDescription) {
290
- const wrapped = wrapTextWithAnsi(descriptionSingleLine, remainingWidth);
318
+ const wrapped = this.#wrapDescription(descriptionSingleLine, remainingWidth);
291
319
  if (wrapped.length === 0) wrapped.push("");
292
320
  const indent = padding(descriptionStart);
293
321
  const first = wrapped[0] ?? "";
294
322
  if (isSelected) {
295
- const rows = [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${first}`)];
323
+ const rows = [this.theme.selectedText(`${prefix}${iconCell}${truncatedValue}${spacing}${first}`)];
296
324
  for (let i = 1; i < wrapped.length; i++) {
297
325
  rows.push(this.theme.selectedText(`${indent}${wrapped[i]}`));
298
326
  }
299
327
  return rows;
300
328
  }
301
- const rows = [prefix + truncatedValue + this.theme.description(spacing + first)];
329
+ const rows = [prefix + iconCell + truncatedValue + this.theme.description(spacing + first)];
302
330
  for (let i = 1; i < wrapped.length; i++) {
303
331
  rows.push(this.theme.description(`${indent}${wrapped[i]}`));
304
332
  }
@@ -307,25 +335,34 @@ export class SelectList implements Component, MouseRoutable {
307
335
 
308
336
  const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
309
337
  if (isSelected) {
310
- return [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`)];
338
+ return [this.theme.selectedText(`${prefix}${iconCell}${truncatedValue}${spacing}${truncatedDesc}`)];
311
339
  }
312
- return [prefix + truncatedValue + this.theme.description(spacing + truncatedDesc)];
340
+ return [prefix + iconCell + truncatedValue + this.theme.description(spacing + truncatedDesc)];
313
341
  }
314
342
 
315
343
  if (isSelected) {
316
- return [this.theme.selectedText(`${prefix}${truncatedValue}`)];
344
+ return [this.theme.selectedText(`${prefix}${iconCell}${truncatedValue}`)];
317
345
  }
318
- return [prefix + truncatedValue];
346
+ return [prefix + iconCell + truncatedValue];
319
347
  }
320
348
 
321
- #computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number): number {
349
+ #computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number, iconColumnWidth: number): number {
322
350
  // Selection style does not change row count; pass isSelected=false to
323
351
  // keep the cheap path uniform for items outside the visible window.
324
- const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth);
352
+ const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth, iconColumnWidth);
325
353
  if (layout.kind !== "description") return 1;
326
- const wrapped = wrapTextWithAnsi(layout.descriptionSingleLine, layout.remainingWidth);
354
+ const wrapped = this.#wrapDescription(layout.descriptionSingleLine, layout.remainingWidth);
327
355
  return Math.max(1, wrapped.length);
328
356
  }
357
+ /** Wrap a description, capping it at `maxDescriptionRows` with a trailing ellipsis. */
358
+ #wrapDescription(description: string, width: number): string[] {
359
+ const wrapped = wrapTextWithAnsi(description, width);
360
+ const cap = this.layout.maxDescriptionRows;
361
+ if (cap === undefined || cap < 1 || wrapped.length <= cap) return wrapped;
362
+ const kept = wrapped.slice(0, cap);
363
+ kept[cap - 1] = truncateToWidth(`${kept[cap - 1]} …`, width, Ellipsis.Unicode);
364
+ return kept;
365
+ }
329
366
 
330
367
  /**
331
368
  * Pick a contiguous window of items containing `selectedIndex` such that
@@ -380,10 +417,15 @@ export class SelectList implements Component, MouseRoutable {
380
417
  isSelected: boolean,
381
418
  width: number,
382
419
  primaryColumnWidth: number,
420
+ iconColumnWidth: number,
383
421
  ): SelectItemLayout {
384
422
  const cursor = this.theme.symbols?.cursor ?? DEFAULT_CURSOR_SYMBOL;
385
423
  const prefix = isSelected ? `${cursor} ` : padding(visibleWidth(cursor) + 1);
386
- const prefixWidth = visibleWidth(prefix);
424
+ // Icon column: every row reserves the same width so labels stay aligned
425
+ // whether or not an individual item carries an icon.
426
+ const iconWidth = item.icon ? visibleWidth(item.icon) : 0;
427
+ const iconCell = iconColumnWidth > 0 ? (item.icon ?? "") + padding(iconColumnWidth - iconWidth + 1) : "";
428
+ const prefixWidth = visibleWidth(prefix) + (iconColumnWidth > 0 ? iconColumnWidth + 1 : 0);
387
429
  const descriptionSingleLine = item.description ? sanitizeSingleLine(item.description) : undefined;
388
430
 
389
431
  if (descriptionSingleLine && width > 40) {
@@ -399,6 +441,7 @@ export class SelectList implements Component, MouseRoutable {
399
441
  return {
400
442
  kind: "description",
401
443
  prefix,
444
+ iconCell,
402
445
  truncatedValue,
403
446
  spacing,
404
447
  descriptionSingleLine,
@@ -413,11 +456,20 @@ export class SelectList implements Component, MouseRoutable {
413
456
  return {
414
457
  kind: "primary",
415
458
  prefix,
459
+ iconCell,
416
460
  truncatedValue,
417
461
  spacing: "",
418
462
  };
419
463
  }
420
464
 
465
+ #getIconColumnWidth(): number {
466
+ let widest = 0;
467
+ for (const item of this.#filteredItems) {
468
+ if (item.icon) widest = Math.max(widest, visibleWidth(item.icon));
469
+ }
470
+ return widest;
471
+ }
472
+
421
473
  #getPrimaryColumnWidth(): number {
422
474
  const { min, max } = this.#getPrimaryColumnBounds();
423
475
  const widestPrimary = this.#filteredItems.reduce((widest, item) => {
@@ -27,6 +27,7 @@ export interface Keybindings {
27
27
  "tui.editor.yank": true;
28
28
  "tui.editor.yankPop": true;
29
29
  "tui.editor.undo": true;
30
+ "tui.editor.spellingSuggestions": true;
30
31
  // Generic input actions
31
32
  "tui.input.newLine": true;
32
33
  "tui.input.submit": true;
@@ -118,6 +119,10 @@ export const TUI_KEYBINDINGS = {
118
119
  "tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
119
120
  "tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
120
121
  "tui.editor.undo": { defaultKeys: ["ctrl+-", "ctrl+_"], description: "Undo" },
122
+ "tui.editor.spellingSuggestions": {
123
+ defaultKeys: "ctrl+.",
124
+ description: "Show spelling replacements",
125
+ },
121
126
  "tui.input.newLine": { defaultKeys: ["shift+enter", "ctrl+j"], description: "Insert newline" },
122
127
  "tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
123
128
  "tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
@@ -129,7 +129,13 @@ export class TerminalInfo {
129
129
  if (this.imageProtocol === ImageProtocol.Sixel) {
130
130
  return hasSixelDcsStart(line);
131
131
  }
132
- return hasNeedleBefore(line, this.imageProtocol, 64) || hasNeedleBefore(line, KITTY_PLACEHOLDER, 64);
132
+ // 512-unit window: placeholder cells can sit deep in a composed row —
133
+ // the composer attachment band prefixes each thumbnail row with border
134
+ // SGRs and stacks cards side by side, so the first placeholder of a
135
+ // later card starts hundreds of units in. Rows past the window would
136
+ // silently lose the verbatim image-line path (no truncation, no SGR
137
+ // coalescing) that placeholder grids and placement APCs rely on.
138
+ return hasNeedleBefore(line, this.imageProtocol, 512) || hasNeedleBefore(line, KITTY_PLACEHOLDER, 512);
133
139
  }
134
140
 
135
141
  formatNotification(message: string | TerminalNotification): string {