@oh-my-pi/pi-tui 16.3.10 → 16.3.12
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 +10 -0
- package/dist/types/autocomplete.d.ts +1 -0
- package/dist/types/tui.d.ts +2 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +1 -1
- package/src/components/editor.ts +45 -10
- package/src/components/markdown.ts +150 -1
- package/src/components/select-list.ts +4 -3
- package/src/tui.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.12] - 2026-07-08
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed selector rendering when a legacy theme omits symbol settings by falling back to an ASCII cursor instead of crashing ([#4745](https://github.com/can1357/oh-my-pi/issues/4745)).
|
|
10
|
+
- Kept slash command autocomplete rows compact by truncating descriptions instead of wrapping them into multi-line blocks.
|
|
11
|
+
- Fixed mid-prompt skill autocomplete so Tab and Enter accept the highlighted `/skill:<name>` suggestion and Backspace dismisses the popup immediately after removing the triggering slash ([#4619](https://github.com/can1357/oh-my-pi/issues/4619)).
|
|
12
|
+
- Fixed submitted slash-command arguments treating `@` file-reference tokens as prompt-composer autocomplete triggers when the command does not define argument completions. ([#4600](https://github.com/can1357/oh-my-pi/issues/4600))
|
|
13
|
+
- Fixed box-drawing tree lines (`├── item` — directory layouts, decision trees) in prose shearing apart when they wrap: continuation rows now hang under the node text with ancestor rails carried through (`├` → `│`, `└` → blank) instead of restarting at column 0. Applies to prose paragraphs (including inside blockquotes) only when a line with a branch-connector prefix (`├──`, `└─`, …) actually overflows; fitting lines, non-tree prose, and code blocks render byte-for-byte as before.
|
|
14
|
+
|
|
5
15
|
## [16.3.10] - 2026-07-06
|
|
6
16
|
|
|
7
17
|
### Fixed
|
|
@@ -73,6 +73,7 @@ export interface AutocompleteProvider {
|
|
|
73
73
|
shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
|
|
74
74
|
}
|
|
75
75
|
type CommandEntry = SlashCommand | AutocompleteItem;
|
|
76
|
+
export declare function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number;
|
|
76
77
|
export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
77
78
|
#private;
|
|
78
79
|
constructor(commands?: CommandEntry[], basePath?: string);
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -251,6 +251,8 @@ export declare class Container implements Component {
|
|
|
251
251
|
addChild(component: Component): void;
|
|
252
252
|
removeChild(component: Component): void;
|
|
253
253
|
clear(): void;
|
|
254
|
+
/** Dispose every child, then detach it from this container. */
|
|
255
|
+
disposeChildren(): void;
|
|
254
256
|
invalidate(): void;
|
|
255
257
|
/**
|
|
256
258
|
* Propagate teardown to children. Call when the container's children are
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.12",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.3.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.3.12",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.3.12",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -276,7 +276,7 @@ function commandMatchesNameOrAlias(cmd: CommandEntry, commandName: string): bool
|
|
|
276
276
|
return getCommandAliases(cmd).includes(commandName);
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number {
|
|
279
|
+
export function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number {
|
|
280
280
|
if (lowerPrefix.length === 0) return 1;
|
|
281
281
|
if (lowerPrefix === lowerTarget) return 1000;
|
|
282
282
|
// Flat score for every prefix match so same-prefix commands keep registry
|
package/src/components/editor.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type AutocompleteProvider,
|
|
4
4
|
findLeadingSlashCommandStart,
|
|
5
5
|
findTrailingSlashCommandStart,
|
|
6
|
+
scoreCommandTextMatch,
|
|
6
7
|
} from "../autocomplete";
|
|
7
8
|
import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
|
|
8
9
|
import { getKeybindings, type KeybindingsManager } from "../keybindings";
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
truncateToWidth,
|
|
22
23
|
visibleWidth,
|
|
23
24
|
} from "../utils";
|
|
24
|
-
import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
|
|
25
|
+
import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
|
|
25
26
|
|
|
26
27
|
const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
27
28
|
overflowSearch: false,
|
|
@@ -31,7 +32,6 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
|
31
32
|
minPrimaryColumnWidth: 12,
|
|
32
33
|
maxPrimaryColumnWidth: 32,
|
|
33
34
|
overflowSearch: false,
|
|
34
|
-
wrapDescription: true,
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
function sanitizeLoadedText(text: string): string {
|
|
@@ -1119,16 +1119,16 @@ export class Editor implements Component, Focusable {
|
|
|
1119
1119
|
|
|
1120
1120
|
// If Tab was pressed, always apply the selection
|
|
1121
1121
|
if (kb.matches(data, "tui.input.tab")) {
|
|
1122
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
1122
1123
|
// Check for stale autocomplete state due to buffer edits since last refresh
|
|
1123
1124
|
// (destructive keys or paste can outrun the debounced update).
|
|
1124
1125
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1125
1126
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1126
|
-
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
|
|
1127
|
+
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
|
|
1127
1128
|
// Autocomplete is stale - silently cancel; Tab has no fallback action here.
|
|
1128
1129
|
this.#cancelAutocomplete();
|
|
1129
1130
|
return;
|
|
1130
1131
|
}
|
|
1131
|
-
const selected = this.#autocompleteList.getSelectedItem();
|
|
1132
1132
|
if (selected && this.#autocompleteProvider) {
|
|
1133
1133
|
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
|
|
1134
1134
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
@@ -1166,14 +1166,14 @@ export class Editor implements Component, Focusable {
|
|
|
1166
1166
|
findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
|
|
1167
1167
|
!this.#selectedCompletionIsPath()
|
|
1168
1168
|
) {
|
|
1169
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
1169
1170
|
// Check for stale autocomplete state due to debounce
|
|
1170
1171
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1171
1172
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1172
|
-
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
|
|
1173
|
+
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
|
|
1173
1174
|
// Autocomplete is stale - cancel and fall through to normal submission
|
|
1174
1175
|
this.#cancelAutocomplete();
|
|
1175
1176
|
} else {
|
|
1176
|
-
const selected = this.#autocompleteList.getSelectedItem();
|
|
1177
1177
|
if (selected && this.#autocompleteProvider) {
|
|
1178
1178
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
1179
1179
|
this.#state.lines,
|
|
@@ -1194,14 +1194,14 @@ export class Editor implements Component, Focusable {
|
|
|
1194
1194
|
}
|
|
1195
1195
|
// If Enter was pressed on a file path, apply completion
|
|
1196
1196
|
else if (kb.matches(data, "tui.input.submit") || data === "\n") {
|
|
1197
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
1197
1198
|
// Check for stale autocomplete state due to buffer edits since last refresh.
|
|
1198
1199
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1199
1200
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1200
|
-
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
|
|
1201
|
+
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
|
|
1201
1202
|
// Autocomplete is stale - cancel and fall through to normal submission
|
|
1202
1203
|
this.#cancelAutocomplete();
|
|
1203
1204
|
} else {
|
|
1204
|
-
const selected = this.#autocompleteList.getSelectedItem();
|
|
1205
1205
|
if (selected && this.#autocompleteProvider) {
|
|
1206
1206
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
1207
1207
|
this.#state.lines,
|
|
@@ -2070,8 +2070,15 @@ export class Editor implements Component, Focusable {
|
|
|
2070
2070
|
this.#resetKillSequence();
|
|
2071
2071
|
this.#recordUndoState();
|
|
2072
2072
|
|
|
2073
|
+
let removedMidPromptSlashTrigger = false;
|
|
2074
|
+
|
|
2073
2075
|
if (this.#state.cursorCol > 0) {
|
|
2074
2076
|
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
2077
|
+
const textBeforeCursor = line.slice(0, this.#state.cursorCol);
|
|
2078
|
+
const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor);
|
|
2079
|
+
removedMidPromptSlashTrigger =
|
|
2080
|
+
trailingSlashStart === this.#state.cursorCol - 1 &&
|
|
2081
|
+
(!this.#hasOnlyWhitespaceBeforeCursorLine() || textBeforeCursor.slice(0, trailingSlashStart).trim() !== "");
|
|
2075
2082
|
// An atomic placeholder token (image/paste marker) deletes as a unit, so a single
|
|
2076
2083
|
// backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
|
|
2077
2084
|
const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
|
|
@@ -2111,7 +2118,12 @@ export class Editor implements Component, Focusable {
|
|
|
2111
2118
|
|
|
2112
2119
|
// Update or re-trigger autocomplete after backspace
|
|
2113
2120
|
if (this.#autocompleteState) {
|
|
2114
|
-
|
|
2121
|
+
if (removedMidPromptSlashTrigger) {
|
|
2122
|
+
this.#cancelAutocomplete();
|
|
2123
|
+
this.onAutocompleteUpdate?.();
|
|
2124
|
+
} else {
|
|
2125
|
+
this.#debouncedUpdateAutocomplete();
|
|
2126
|
+
}
|
|
2115
2127
|
} else {
|
|
2116
2128
|
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
|
|
2117
2129
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -2881,13 +2893,36 @@ export class Editor implements Component, Focusable {
|
|
|
2881
2893
|
* engages for command-shaped selections: absolute-path completions (`/tmp/fo`
|
|
2882
2894
|
* via the no-command-match fall-through) share the leading-slash prefix shape
|
|
2883
2895
|
* but must use the live-suffix path rule so the apply slice stays anchored.
|
|
2896
|
+
* - Mid-prompt skill branch re-anchors when the popup item is a skill and the
|
|
2897
|
+
* current text still ends in a trailing slash token, matching the provider's
|
|
2898
|
+
* mid-prompt replacement branch.
|
|
2884
2899
|
* - `@`-file branch re-anchors via `#extractAtPrefix`; safe when the current text
|
|
2885
2900
|
* still ends in a whitespace-anchored `@<token>`.
|
|
2886
2901
|
* - Everything else is stale — accepting it would corrupt the buffer (issue #4295).
|
|
2887
2902
|
*/
|
|
2888
|
-
#autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string): boolean {
|
|
2903
|
+
#autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string, item?: SelectItem | null): boolean {
|
|
2889
2904
|
if (currentTextBeforeCursor === this.#autocompletePrefix) return true;
|
|
2890
2905
|
|
|
2906
|
+
if (item?.value.startsWith("skill:") && findTrailingSlashCommandStart(this.#autocompletePrefix) !== null) {
|
|
2907
|
+
const currentTrailingStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
|
|
2908
|
+
if (currentTrailingStart !== null) {
|
|
2909
|
+
const token = currentTextBeforeCursor.slice(currentTrailingStart);
|
|
2910
|
+
if (!token.includes(" ") && !token.slice(1).includes("/")) {
|
|
2911
|
+
// Guard the timing window where the popup was built for an earlier
|
|
2912
|
+
// query (e.g. bare `/`) and the user typed further characters before
|
|
2913
|
+
// the 100 ms debounced refresh fired: accept the stale skill only
|
|
2914
|
+
// when the current query would still surface it. `tmp` after a bare
|
|
2915
|
+
// slash therefore falls through to file completion instead of
|
|
2916
|
+
// rewriting the user's `/tmp` to `/skill:…`.
|
|
2917
|
+
const lowerToken = token.slice(1).toLowerCase();
|
|
2918
|
+
if (scoreCommandTextMatch(lowerToken, item.value.toLowerCase()) > 0) return true;
|
|
2919
|
+
if (item.description && scoreCommandTextMatch(lowerToken, item.description.toLowerCase()) > 0)
|
|
2920
|
+
return true;
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
return false;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2891
2926
|
if (findLeadingSlashCommandStart(this.#autocompletePrefix) !== null && !this.#selectedCompletionIsPath()) {
|
|
2892
2927
|
const currentLeadingStart = findLeadingSlashCommandStart(currentTextBeforeCursor);
|
|
2893
2928
|
if (currentLeadingStart !== null) {
|
|
@@ -251,6 +251,155 @@ function splitTerminalLines(text: string): string[] {
|
|
|
251
251
|
return lines;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Tree-guide hanging wrap
|
|
256
|
+
//
|
|
257
|
+
// Models routinely emit box-drawing trees ("├── item") inside plain
|
|
258
|
+
// paragraphs — directory layouts, decision trees. The lexer sees those lines
|
|
259
|
+
// as ordinary prose, so the generic wrap pass restarts wrapped continuations
|
|
260
|
+
// at column 0 and visually shears the tree apart (doubly fast for CJK text,
|
|
261
|
+
// where every glyph is two cells wide). Mirror the guide semantics of
|
|
262
|
+
// `tree(1)` / rich.tree instead: wrap the node text within the cells that
|
|
263
|
+
// remain after the guide prefix, and indent every continuation row under the
|
|
264
|
+
// node text — branch glyphs swap to their pass-through form (`├` → `│`,
|
|
265
|
+
// `└` → blank) so the rails of still-open ancestors stay visually joined.
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/** Continuation glyph for each guide character a tree prefix may contain. */
|
|
269
|
+
const TREE_GUIDE_CONTINUATION: Record<string, string> = {
|
|
270
|
+
"│": "│",
|
|
271
|
+
"┃": "┃",
|
|
272
|
+
"║": "║",
|
|
273
|
+
"├": "│",
|
|
274
|
+
"┣": "┃",
|
|
275
|
+
"╠": "║",
|
|
276
|
+
"└": " ",
|
|
277
|
+
"┗": " ",
|
|
278
|
+
"╚": " ",
|
|
279
|
+
"╰": " ",
|
|
280
|
+
"─": " ",
|
|
281
|
+
"━": " ",
|
|
282
|
+
"═": " ",
|
|
283
|
+
" ": " ",
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** Cheap pre-gate: any guide glyph at all. The structural test is TREE_BRANCH_CONNECTOR_RE. */
|
|
287
|
+
const TREE_GUIDE_ANCHOR_RE = /[│┃║├┣╠└┗╚╰]/;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A prefix qualifies as tree-shaped only when a branch/corner glyph is
|
|
291
|
+
* immediately followed by a horizontal connector (`├──`, `└─`, `╰──`, …).
|
|
292
|
+
* A lone rail or branch glyph used as prose ("│ is the Unicode vertical box
|
|
293
|
+
* drawing glyph…") never qualifies, so such paragraphs keep the plain wrap.
|
|
294
|
+
*/
|
|
295
|
+
const TREE_BRANCH_CONNECTOR_RE = /[├┣╠└┗╚╰][─━═]/;
|
|
296
|
+
|
|
297
|
+
/** Below this many content cells a hanging wrap degenerates; keep the plain wrap. */
|
|
298
|
+
const MIN_TREE_CONTENT_WIDTH = 8;
|
|
299
|
+
|
|
300
|
+
const SGR_SEQUENCE_STICKY = /\x1b\[[0-9;:]*m/y;
|
|
301
|
+
const SGR_SEQUENCE_GLOBAL = /\x1b\[[0-9;:]*m/g;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Everything before the last full SGR reset is dead state — drop it so the
|
|
305
|
+
* re-played `carry` stays bounded by the paragraph's live style run instead
|
|
306
|
+
* of its whole code history.
|
|
307
|
+
*/
|
|
308
|
+
function compactSgrCarry(carry: string): string {
|
|
309
|
+
const shortReset = carry.lastIndexOf("\x1b[m");
|
|
310
|
+
const longReset = carry.lastIndexOf("\x1b[0m");
|
|
311
|
+
const cut = Math.max(shortReset === -1 ? -1 : shortReset + 3, longReset === -1 ? -1 : longReset + 4);
|
|
312
|
+
return cut === -1 ? carry : carry.slice(cut);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface TreeGuidePrefix {
|
|
316
|
+
/** Index of the first char past the guide run (start of the node text). */
|
|
317
|
+
end: number;
|
|
318
|
+
/** SGR sequences interleaved with the guides, in order (zero visible width). */
|
|
319
|
+
codes: string;
|
|
320
|
+
/** Guide characters with SGR stripped, exactly as they appear on screen. */
|
|
321
|
+
guides: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Match the leading box-drawing guide run of a rendered line (e.g. `│ ├── `),
|
|
326
|
+
* tolerating interleaved SGR styling. Returns undefined unless the run
|
|
327
|
+
* contains a branch glyph joined to a horizontal connector and node text
|
|
328
|
+
* follows, so dash art, indented prose, and lone glyphs used as prose are
|
|
329
|
+
* never treated as a tree.
|
|
330
|
+
*/
|
|
331
|
+
function matchTreeGuidePrefix(line: string): TreeGuidePrefix | undefined {
|
|
332
|
+
let codes = "";
|
|
333
|
+
let guides = "";
|
|
334
|
+
let i = 0;
|
|
335
|
+
while (i < line.length) {
|
|
336
|
+
if (line.charCodeAt(i) === 0x1b) {
|
|
337
|
+
SGR_SEQUENCE_STICKY.lastIndex = i;
|
|
338
|
+
const match = SGR_SEQUENCE_STICKY.exec(line);
|
|
339
|
+
if (!match) break;
|
|
340
|
+
codes += match[0];
|
|
341
|
+
i = SGR_SEQUENCE_STICKY.lastIndex;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const char = line[i]!;
|
|
345
|
+
if (!(char in TREE_GUIDE_CONTINUATION)) break;
|
|
346
|
+
guides += char;
|
|
347
|
+
i++;
|
|
348
|
+
}
|
|
349
|
+
if (i >= line.length || !TREE_BRANCH_CONNECTOR_RE.test(guides)) return undefined;
|
|
350
|
+
return { end: i, codes, guides };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Hanging wrap for box-drawing tree lines inside prose block text.
|
|
355
|
+
*
|
|
356
|
+
* Returns undefined when no line needs the treatment, so paragraphs without
|
|
357
|
+
* overflowing tree lines keep their exact current render. When a paragraph
|
|
358
|
+
* does hang, its lines are returned pre-split and style-self-contained: the
|
|
359
|
+
* SGR state open at each line start is re-played onto that line (`carry`),
|
|
360
|
+
* because the caller's wrap pass — which normally carries SGR state across
|
|
361
|
+
* the newlines of a single entry — no longer sees them as one entry.
|
|
362
|
+
*/
|
|
363
|
+
function hangWrapTreeGuideLines(text: string, width: number): string[] | undefined {
|
|
364
|
+
if (width < MIN_TREE_CONTENT_WIDTH || !TREE_GUIDE_ANCHOR_RE.test(text)) return undefined;
|
|
365
|
+
|
|
366
|
+
const sourceLines = text.split("\n");
|
|
367
|
+
const hangs = (line: string): TreeGuidePrefix | undefined => {
|
|
368
|
+
if (visibleWidth(line) <= width) return undefined;
|
|
369
|
+
const prefix = matchTreeGuidePrefix(line);
|
|
370
|
+
if (!prefix) return undefined;
|
|
371
|
+
if (width - visibleWidth(prefix.guides) < MIN_TREE_CONTENT_WIDTH) return undefined;
|
|
372
|
+
return prefix;
|
|
373
|
+
};
|
|
374
|
+
if (!sourceLines.some(line => hangs(line) !== undefined)) return undefined;
|
|
375
|
+
|
|
376
|
+
const out: string[] = [];
|
|
377
|
+
let carry = "";
|
|
378
|
+
for (const line of sourceLines) {
|
|
379
|
+
const prefix = hangs(line);
|
|
380
|
+
if (!prefix) {
|
|
381
|
+
out.push(carry ? carry + line : line);
|
|
382
|
+
carry = compactSgrCarry(carry + (line.match(SGR_SEQUENCE_GLOBAL)?.join("") ?? ""));
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
// Re-play the SGR state ahead of the node text so the wrapper carries
|
|
386
|
+
// it onto every continuation row; the codes are zero-width, so measured
|
|
387
|
+
// row widths are unaffected.
|
|
388
|
+
const activeCodes = carry + prefix.codes;
|
|
389
|
+
const rows = wrapTextWithAnsi(activeCodes + line.slice(prefix.end), width - visibleWidth(prefix.guides));
|
|
390
|
+
let hang = "";
|
|
391
|
+
for (const guide of prefix.guides) hang += TREE_GUIDE_CONTINUATION[guide] ?? " ";
|
|
392
|
+
const hangShortfall = visibleWidth(prefix.guides) - visibleWidth(hang);
|
|
393
|
+
if (hangShortfall > 0) hang += padding(hangShortfall);
|
|
394
|
+
out.push(carry + line.slice(0, prefix.end) + rows[0]!.slice(activeCodes.length));
|
|
395
|
+
for (let i = 1; i < rows.length; i++) {
|
|
396
|
+
out.push(activeCodes + hang + rows[i]!);
|
|
397
|
+
}
|
|
398
|
+
carry = compactSgrCarry(carry + (line.match(SGR_SEQUENCE_GLOBAL)?.join("") ?? ""));
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
254
403
|
class StrictStrikethroughTokenizer extends Tokenizer {
|
|
255
404
|
override del(src: string): Tokens.Del | undefined {
|
|
256
405
|
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
|
|
@@ -1383,7 +1532,7 @@ export class Markdown implements Component {
|
|
|
1383
1532
|
break;
|
|
1384
1533
|
}
|
|
1385
1534
|
const paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext);
|
|
1386
|
-
lines.push(paragraphText);
|
|
1535
|
+
lines.push(...(hangWrapTreeGuideLines(paragraphText, width) ?? [paragraphText]));
|
|
1387
1536
|
// Don't add spacing if next token is space or list
|
|
1388
1537
|
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
|
|
1389
1538
|
lines.push("");
|
|
@@ -12,6 +12,8 @@ const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
|
|
|
12
12
|
const PRIMARY_COLUMN_GAP = 2;
|
|
13
13
|
const MIN_DESCRIPTION_WIDTH = 10;
|
|
14
14
|
|
|
15
|
+
const DEFAULT_CURSOR_SYMBOL = ">";
|
|
16
|
+
|
|
15
17
|
function sanitizeSingleLine(text: string): string {
|
|
16
18
|
return replaceTabs(text)
|
|
17
19
|
.replace(/[\r\n]+/g, " ")
|
|
@@ -372,9 +374,8 @@ export class SelectList implements Component, MouseRoutable {
|
|
|
372
374
|
width: number,
|
|
373
375
|
primaryColumnWidth: number,
|
|
374
376
|
): SelectItemLayout {
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
: padding(visibleWidth(this.theme.symbols.cursor) + 1);
|
|
377
|
+
const cursor = this.theme.symbols?.cursor ?? DEFAULT_CURSOR_SYMBOL;
|
|
378
|
+
const prefix = isSelected ? `${cursor} ` : padding(visibleWidth(cursor) + 1);
|
|
378
379
|
const prefixWidth = visibleWidth(prefix);
|
|
379
380
|
const descriptionSingleLine = item.description ? sanitizeSingleLine(item.description) : undefined;
|
|
380
381
|
|
package/src/tui.ts
CHANGED
|
@@ -503,6 +503,12 @@ export class Container implements Component {
|
|
|
503
503
|
this.#memoLines = undefined;
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
/** Dispose every child, then detach it from this container. */
|
|
507
|
+
disposeChildren(): void {
|
|
508
|
+
this.dispose();
|
|
509
|
+
this.clear();
|
|
510
|
+
}
|
|
511
|
+
|
|
506
512
|
invalidate(): void {
|
|
507
513
|
this.#memoLines = undefined;
|
|
508
514
|
for (const child of this.children) {
|