@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.
- package/CHANGELOG.md +41 -0
- package/README.md +1 -1
- package/dist/types/autocomplete.d.ts +10 -1
- package/dist/types/components/editor.d.ts +40 -1
- package/dist/types/components/markdown.d.ts +17 -0
- package/dist/types/components/select-list.d.ts +9 -0
- package/dist/types/keybindings.d.ts +5 -0
- package/dist/types/terminal.d.ts +32 -2
- package/dist/types/tui.d.ts +43 -0
- package/dist/types/utils.d.ts +3 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +84 -54
- package/src/components/editor.ts +273 -26
- package/src/components/markdown.ts +111 -36
- package/src/components/select-list.ts +67 -15
- package/src/keybindings.ts +5 -0
- package/src/terminal-capabilities.ts +7 -1
- package/src/terminal.ts +133 -22
- package/src/tui.ts +177 -23
- package/src/utils.ts +12 -3
|
@@ -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
|
|
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).
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
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
|
-
#
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
2159
|
-
|
|
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
|
-
|
|
2162
|
-
|
|
2163
|
-
for (const hlLine of
|
|
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
|
-
|
|
2167
|
-
|
|
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
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
2239
|
-
|
|
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
|
|
2246
|
-
|
|
2247
|
-
|
|
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.#
|
|
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
|
|
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(
|
|
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(
|
|
284
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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) => {
|
package/src/keybindings.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|