@linxiraos/pi-tui 1.1.7 → 1.1.9

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 (69) hide show
  1. package/CHANGELOG.md +2 -7
  2. package/package.json +64 -70
  3. package/src/autocomplete.ts +1 -1
  4. package/src/components/composer/band.ts +6 -2
  5. package/src/components/editor.ts +13 -6
  6. package/src/components/image.ts +31 -24
  7. package/src/components/markdown.ts +79 -10
  8. package/src/components/select-list.ts +1 -0
  9. package/src/components/spacer.ts +1 -1
  10. package/src/components/text.ts +1 -1
  11. package/src/deccara.ts +2 -0
  12. package/src/keys.ts +1 -1
  13. package/src/kitty-graphics.ts +9 -6
  14. package/src/latex-block.ts +1 -0
  15. package/src/stdin-buffer.ts +28 -5
  16. package/src/terminal-capabilities.ts +22 -21
  17. package/src/terminal-multiplexer.ts +24 -0
  18. package/src/terminal.ts +173 -85
  19. package/src/tui.ts +198 -29
  20. package/THIRD-PARTY-NOTICES.txt +0 -22909
  21. package/dist/types/autocomplete.d.ts +0 -127
  22. package/dist/types/bracketed-paste.d.ts +0 -51
  23. package/dist/types/components/box.d.ts +0 -33
  24. package/dist/types/components/cancellable-loader.d.ts +0 -23
  25. package/dist/types/components/composer/band.d.ts +0 -2
  26. package/dist/types/components/composer/borderless.d.ts +0 -8
  27. package/dist/types/components/composer/box.d.ts +0 -2
  28. package/dist/types/components/composer/claude.d.ts +0 -8
  29. package/dist/types/components/composer/field.d.ts +0 -3
  30. package/dist/types/components/composer/index.d.ts +0 -10
  31. package/dist/types/components/composer/pi.d.ts +0 -2
  32. package/dist/types/components/composer/rail.d.ts +0 -3
  33. package/dist/types/components/composer/registry.d.ts +0 -19
  34. package/dist/types/components/composer/rule.d.ts +0 -7
  35. package/dist/types/components/composer/types.d.ts +0 -96
  36. package/dist/types/components/editor.d.ts +0 -219
  37. package/dist/types/components/image.d.ts +0 -164
  38. package/dist/types/components/input.d.ts +0 -27
  39. package/dist/types/components/loader.d.ts +0 -33
  40. package/dist/types/components/markdown.d.ts +0 -100
  41. package/dist/types/components/scroll-view.d.ts +0 -64
  42. package/dist/types/components/select-list.d.ts +0 -80
  43. package/dist/types/components/settings-list.d.ts +0 -133
  44. package/dist/types/components/spacer.d.ts +0 -13
  45. package/dist/types/components/tab-bar.d.ts +0 -91
  46. package/dist/types/components/text.d.ts +0 -29
  47. package/dist/types/components/truncated-text.d.ts +0 -12
  48. package/dist/types/debug-server.d.ts +0 -27
  49. package/dist/types/deccara.d.ts +0 -49
  50. package/dist/types/desktop-notify.d.ts +0 -52
  51. package/dist/types/editor-component.d.ts +0 -38
  52. package/dist/types/fuzzy.d.ts +0 -48
  53. package/dist/types/index.d.ts +0 -34
  54. package/dist/types/keybindings.d.ts +0 -202
  55. package/dist/types/keys.d.ts +0 -210
  56. package/dist/types/kill-ring.d.ts +0 -20
  57. package/dist/types/kitty-graphics.d.ts +0 -76
  58. package/dist/types/latex-block.d.ts +0 -8
  59. package/dist/types/latex-to-unicode.d.ts +0 -41
  60. package/dist/types/loop-watchdog.d.ts +0 -49
  61. package/dist/types/mouse.d.ts +0 -67
  62. package/dist/types/stdin-buffer.d.ts +0 -60
  63. package/dist/types/symbols.d.ts +0 -25
  64. package/dist/types/terminal-capabilities.d.ts +0 -364
  65. package/dist/types/terminal.d.ts +0 -226
  66. package/dist/types/tmux.d.ts +0 -6
  67. package/dist/types/ttyid.d.ts +0 -9
  68. package/dist/types/tui.d.ts +0 -371
  69. package/dist/types/utils.d.ts +0 -112
package/CHANGELOG.md CHANGED
@@ -2,14 +2,9 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [1.1.7] - 2026-09-01
5
+ ## [1.1.9] - 2026-09-05
6
6
 
7
- - 同步上游 OMP v18.0.11(`b8ce33a58911c26bed1d84f0db9a5e2e727c49a2`)。
8
-
9
- ## [1.1.6] - 2026-08-30
10
-
11
- - 同步上游 OMP v18.0.10(`33cc6b9a043a`)。
12
- - 同步上游 OMP v18.0.9(`cc14e04f075d`)。
7
+ - Sidebar gutter: provider-render frames now honor the reserved main width and paint the right gutter column (sidebar was dead code in production since the frame-provider refactor); overlay close clears the painted column.
13
8
 
14
9
  ## [1.1.3] - 2026-08-25
15
10
 
package/package.json CHANGED
@@ -1,72 +1,66 @@
1
1
  {
2
- "type": "module",
3
- "name": "@linxiraos/pi-tui",
4
- "version": "1.1.7",
5
- "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
- "homepage": "https://linxira-os.github.io/zeta/",
7
- "author": "Stencil Labs, Inc.",
8
- "contributors": [
9
- "Mario Zechner"
10
- ],
11
- "license": "MIT",
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
15
- "directory": "packages/tui"
16
- },
17
- "bugs": {
18
- "url": "https://github.com/Linxira-OS/linxira-zeta/issues"
19
- },
20
- "keywords": [
21
- "tui",
22
- "terminal",
23
- "ui",
24
- "text-editor",
25
- "differential-rendering",
26
- "typescript",
27
- "cli"
28
- ],
29
- "main": "./src/index.ts",
30
- "types": "./dist/types/index.d.ts",
31
- "scripts": {
32
- "check": "biome check . && bun run check:types",
33
- "check:types": "tsgo -p tsconfig.json --noEmit",
34
- "lint": "biome lint .",
35
- "test": "bun test --parallel test/*.test.ts",
36
- "fix": "biome check --write --unsafe .",
37
- "fmt": "biome format --write ."
38
- },
39
- "dependencies": {
40
- "@linxiraos/pi-natives": "1.1.7",
41
- "@linxiraos/pi-utils": "1.1.7"
42
- },
43
- "devDependencies": {
44
- "kitty-vt-wasm": "^0.2.0"
45
- },
46
- "engines": {
47
- "bun": ">=1.3.14"
48
- },
49
- "files": [
50
- "src",
51
- "README.md",
52
- "CHANGELOG.md",
53
- "LICENSE",
54
- "THIRD-PARTY-NOTICES.txt",
55
- "dist/types"
56
- ],
57
- "exports": {
58
- ".": {
59
- "types": "./dist/types/index.d.ts",
60
- "import": "./src/index.ts"
61
- },
62
- "./*": {
63
- "types": "./dist/types/*.d.ts",
64
- "import": "./src/*.ts"
65
- },
66
- "./components/*": {
67
- "types": "./dist/types/components/*.d.ts",
68
- "import": "./src/components/*.ts"
69
- },
70
- "./*.js": "./src/*.ts"
71
- }
2
+ "exports": {
3
+ "./*.js": "./src/*.ts",
4
+ "./components/*": {
5
+ "import": "./src/components/*.ts",
6
+ "types": "./src/components/*.ts"
7
+ },
8
+ "./*": {
9
+ "import": "./src/*.ts",
10
+ "types": "./src/*.ts"
11
+ },
12
+ ".": {
13
+ "import": "./src/index.ts",
14
+ "types": "./src/index.ts"
15
+ }
16
+ },
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "engines": {
23
+ "bun": ">=1.3.14"
24
+ },
25
+ "devDependencies": {
26
+ "kitty-vt-wasm": "^0.2.0"
27
+ },
28
+ "dependencies": {
29
+ "@linxiraos/pi-utils": "1.1.9",
30
+ "@linxiraos/pi-natives": "1.1.9"
31
+ },
32
+ "scripts": {
33
+ "test": "bun test --parallel test/*.test.ts",
34
+ "check:types": "tsgo -p tsconfig.json --noEmit",
35
+ "check": "tsgo -p tsconfig.json --noEmit"
36
+ },
37
+ "types": "./src/index.ts",
38
+ "main": "./src/index.ts",
39
+ "keywords": [
40
+ "tui",
41
+ "terminal",
42
+ "ui",
43
+ "text-editor",
44
+ "differential-rendering",
45
+ "typescript",
46
+ "cli"
47
+ ],
48
+ "bugs": {
49
+ "url": "https://github.com/Linxira-OS/linxira-zeta/issues"
50
+ },
51
+ "repository": {
52
+ "directory": "packages/tui",
53
+ "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
54
+ "type": "git"
55
+ },
56
+ "license": "MIT",
57
+ "contributors": [
58
+ "Mario Zechner"
59
+ ],
60
+ "author": "Stencil Labs, Inc.",
61
+ "homepage": "https://linxira-os.github.io/zeta/",
62
+ "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
63
+ "version": "1.1.9",
64
+ "name": "@linxiraos/pi-tui",
65
+ "type": "module"
72
66
  }
@@ -382,7 +382,7 @@ function buildSlashCommandCompletions(
382
382
  // Equal text-match scores fall back to usage frequency, then to the
383
383
  // stable registry order.
384
384
  .sort((a, b) => b.score - a.score || b.usage - a.usage)
385
- .map(({ score: _, usage: _usage, ...rest }) => rest)
385
+ .map(({ score: _score, usage: _usage, ...rest }) => rest)
386
386
  );
387
387
  }
388
388
 
@@ -24,9 +24,13 @@ export const bandComposerStyle: ComposerStyle = {
24
24
  return 0;
25
25
  },
26
26
 
27
- renderTop(ctx: ComposerChromeContext): string | undefined {
27
+ renderTop(ctx: ComposerChromeContext): string {
28
28
  const { topBorder, width } = ctx;
29
- if (!topBorder?.content) return undefined;
29
+ // The band always owns one chrome row (`verticalChrome: 1`): keep it
30
+ // reserved while the status line has nothing to show yet — the startup
31
+ // prepaint mounts the editor before the session-aware status line
32
+ // attaches — so the band fills in later without shifting the layout.
33
+ if (!topBorder?.content) return "";
30
34
  // The band builder already sizes its groups + gauge to the full width;
31
35
  // truncation only guards against a stale provider during resize.
32
36
  return topBorder.width > width ? truncateToWidth(topBorder.content, width) : topBorder.content;
@@ -467,6 +467,12 @@ export interface EditorTextAssistProvider {
467
467
  type HistoryCursorAnchor = "start" | "end";
468
468
  type AutocompleteRequest = { kind: "regular"; explicitTab: boolean } | { kind: "force" };
469
469
 
470
+ /** What the paste transport knew about the input burst before the editor inserts the payload. */
471
+ export interface PasteOptions {
472
+ /** A submit keypress arrived in the same input burst and will be dispatched right after the paste. */
473
+ submitAfterPaste?: boolean;
474
+ }
475
+
470
476
  export class Editor implements Component, Focusable {
471
477
  #state: EditorState = {
472
478
  lines: [""],
@@ -581,8 +587,9 @@ export class Editor implements Component, Focusable {
581
587
  * the editor inserts nothing and records no undo state, leaving insertion to the host (e.g. a
582
588
  * "wrap in a code block / XML / attach as file" menu for very large pastes), which re-inserts
583
589
  * via {@link insertPaste} or {@link insertText}. Return `false` (or leave unset) for the
584
- * default collapse-to-marker behavior. `lineCount` is the sanitized paste's line count. */
585
- onLargePaste?: (text: string, lineCount: number) => boolean;
590
+ * default collapse-to-marker behavior. `lineCount` is the sanitized paste's line count;
591
+ * `options` carries what the paste transport knew about the burst. */
592
+ onLargePaste?: (text: string, lineCount: number, options: PasteOptions) => boolean;
586
593
  onAutocompleteCancel?: () => void;
587
594
  disableSubmit: boolean = false;
588
595
 
@@ -2148,8 +2155,8 @@ export class Editor implements Component, Focusable {
2148
2155
  }
2149
2156
 
2150
2157
  /** Apply terminal paste semantics to text from non-bracketed paste transports. */
2151
- pasteText(text: string): void {
2152
- this.#handlePaste(text);
2158
+ pasteText(text: string, options: PasteOptions = {}): void {
2159
+ this.#handlePaste(text, options);
2153
2160
  }
2154
2161
 
2155
2162
  /** Insert `content` as a collapsed `[Paste #N]` marker (stored for expansion on submit via
@@ -2289,7 +2296,7 @@ export class Editor implements Component, Focusable {
2289
2296
  }
2290
2297
  }
2291
2298
 
2292
- #handlePaste(pastedText: string): void {
2299
+ #handlePaste(pastedText: string, options: PasteOptions = {}): void {
2293
2300
  let filteredText = this.#sanitizePastedText(pastedText);
2294
2301
 
2295
2302
  // If pasting a file path (starts with /, ~, or .) and the character before
@@ -2311,7 +2318,7 @@ export class Editor implements Component, Focusable {
2311
2318
  // Let the host intercept marker-sized pastes (e.g. the large-paste menu). When it takes
2312
2319
  // over, the editor inserts nothing and records no undo state — the host re-inserts via
2313
2320
  // `insertPaste`/`insertText` once the user chooses.
2314
- if (isMarkerSized && this.onLargePaste?.(filteredText, pastedLines.length)) {
2321
+ if (isMarkerSized && this.onLargePaste?.(filteredText, pastedLines.length, options)) {
2315
2322
  return;
2316
2323
  }
2317
2324
 
@@ -73,9 +73,9 @@ function nextImageIdSeed(): number {
73
73
  *
74
74
  * The budget keeps the most recent `cap` images live and demotes older ones to
75
75
  * their text fallback. Demotion needs a full redraw (so off-screen rows are
76
- * rewritten) plus an explicit graphics purge of the demoted ids {@link Image}
77
- * reports display order via {@link observe}, and the TUI drives the purge +
78
- * redraw on the frame after a new image pushes the count past the cap.
76
+ * rewritten) plus an explicit graphics purge of the demoted ids. {@link Image}
77
+ * reports display order via {@link observe}; when that reveals a stricter split,
78
+ * the TUI repeats the pass before emitting its terminal frame.
79
79
  *
80
80
  * `cap <= 0` disables budgeting: every image stays a live graphic.
81
81
  */
@@ -87,6 +87,8 @@ export class ImageBudget {
87
87
  #idToKey = new Map<number, string>();
88
88
  /** Display-order image ids observed during the in-flight pass. */
89
89
  #passIds: number[] = [];
90
+ /** Per-id suppression decision from the first observation in this pass. */
91
+ #passSuppression = new Map<number, boolean>();
90
92
  /**
91
93
  * Suppress threshold reflected in the frame currently on the terminal: images
92
94
  * at display indices `[0, #onTerminal)` are shown as text there.
@@ -104,7 +106,7 @@ export class ImageBudget {
104
106
  /** Image ids whose data is believed to be loaded in the terminal's store. */
105
107
  #transmitted = new Set<number>();
106
108
  /** Transmit sequences (full base64) to write once, before this frame's placements. */
107
- #pendingTransmits: string[] = [];
109
+ #pendingTransmits = new Map<number, string>();
108
110
  // True while the in-flight pass is a partial/throwaway pass (the
109
111
  // non-multiplexer resize viewport fast path) that walks only the visible
110
112
  // tail, bottom-up. Such a pass cannot derive display order from observe()
@@ -185,6 +187,7 @@ export class ImageBudget {
185
187
  */
186
188
  beginPass(stable = false): void {
187
189
  this.#passIds.length = 0;
190
+ this.#passSuppression.clear();
188
191
  this.#stablePass = stable;
189
192
  this.#applyingReset = !stable && this.#cap > 0 && this.#planned > this.#onTerminal;
190
193
  }
@@ -199,47 +202,49 @@ export class ImageBudget {
199
202
  * (`#suppressedIds`) keyed by id — order- and partiality-independent.
200
203
  */
201
204
  observe(imageId: number): boolean {
205
+ const existing = this.#passSuppression.get(imageId);
206
+ if (existing !== undefined) return existing;
202
207
  if (this.#stablePass) {
203
208
  const suppressed = this.#cap > 0 && this.#suppressedIds.has(imageId);
209
+ this.#passSuppression.set(imageId, suppressed);
204
210
  if (suppressed) this.#forgetKeyForId(imageId);
205
211
  return suppressed;
206
212
  }
207
213
  const index = this.#passIds.length;
208
214
  this.#passIds.push(imageId);
209
215
  const suppressed = this.#cap > 0 && index < this.#planned;
216
+ this.#passSuppression.set(imageId, suppressed);
210
217
  if (suppressed) this.#forgetKeyForId(imageId);
211
218
  return suppressed;
212
219
  }
213
220
 
214
221
  /**
215
- * End a render pass. Returns true when this frame must purge graphics and
216
- * fully repaint to apply a stricter budget; read the ids via
217
- * {@link takePurgeIds}.
222
+ * End a render pass. Returns true when the pass discovered a stricter budget
223
+ * and must be repeated before its terminal frame is emitted.
218
224
  */
219
225
  endPass(): boolean {
220
226
  const total = this.#passIds.length;
221
227
  this.#lastTotal = total;
222
- let reset = false;
223
228
  if (this.#applyingReset) {
224
229
  for (let i = this.#onTerminal; i < this.#planned && i < total; i++) {
225
230
  const id = this.#passIds[i];
226
- this.#purgeIds.push(id);
227
- // d=I frees the data too, so the image must re-transmit if it returns.
231
+ // A transmit queued by a discarded discovery pass never reached
232
+ // the terminal, so cancel it instead of transmitting then purging.
233
+ if (!this.#pendingTransmits.delete(id)) this.#purgeIds.push(id);
228
234
  this.#transmitted.delete(id);
229
235
  this.#deletePlacementState(id);
230
236
  this.#forgetKeyForId(id);
231
237
  }
232
238
  this.#onTerminal = this.#planned;
233
239
  this.#applyingReset = false;
234
- reset = true;
235
240
  }
236
- this.#reconcile(total);
241
+ const retry = this.#reconcile(total);
237
242
  // Snapshot the committed display-order suppression by id: the prefix
238
243
  // [0, #onTerminal) is what the terminal currently shows as text. Partial
239
244
  // passes replay this per id (see #stablePass) instead of re-deriving it
240
245
  // from a reversed, tail-only walk.
241
246
  this.#suppressedIds = new Set(this.#passIds.slice(0, this.#onTerminal));
242
- return reset;
247
+ return retry;
243
248
  }
244
249
 
245
250
  /** Image ids to delete from the terminal this frame; clears the pending set. */
@@ -256,7 +261,7 @@ export class ImageBudget {
256
261
  const ids = [...this.#transmitted];
257
262
  this.#transmitted.clear();
258
263
  this.#purgeIds = [];
259
- this.#pendingTransmits = [];
264
+ this.#pendingTransmits.clear();
260
265
  this.#keyToId.clear();
261
266
  this.#idToKey.clear();
262
267
  this.#placementState.clear();
@@ -393,12 +398,12 @@ export class ImageBudget {
393
398
  enqueueTransmit(imageId: number, sequence: string): void {
394
399
  if (this.#transmitted.has(imageId)) return;
395
400
  this.#transmitted.add(imageId);
396
- this.#pendingTransmits.push(sequence);
401
+ this.#pendingTransmits.set(imageId, sequence);
397
402
  }
398
403
 
399
404
  /** Whether a frame has image data queued but not yet written to the terminal. */
400
405
  hasPendingTransmits(): boolean {
401
- return this.#pendingTransmits.length > 0;
406
+ return this.#pendingTransmits.size > 0;
402
407
  }
403
408
 
404
409
  /**
@@ -410,7 +415,7 @@ export class ImageBudget {
410
415
  get quiescent(): boolean {
411
416
  return (
412
417
  this.#lastTotal === 0 &&
413
- this.#pendingTransmits.length === 0 &&
418
+ this.#pendingTransmits.size === 0 &&
414
419
  this.#purgeIds.length === 0 &&
415
420
  this.#planned === this.#onTerminal
416
421
  );
@@ -418,9 +423,9 @@ export class ImageBudget {
418
423
 
419
424
  /** Transmit sequences to write before this frame's placements; clears the queue. */
420
425
  takeTransmits(): readonly string[] {
421
- if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
422
- const sequences = this.#pendingTransmits;
423
- this.#pendingTransmits = [];
426
+ if (this.#pendingTransmits.size === 0) return EMPTY_TRANSMITS;
427
+ const sequences = [...this.#pendingTransmits.values()];
428
+ this.#pendingTransmits.clear();
424
429
  return sequences;
425
430
  }
426
431
 
@@ -433,9 +438,9 @@ export class ImageBudget {
433
438
  * re-emit together; keeps no base64 in budget state (the transmit-once design).
434
439
  */
435
440
  forgetTransmitted(): void {
436
- if (this.#transmitted.size === 0 && this.#pendingTransmits.length === 0) return;
441
+ if (this.#transmitted.size === 0 && this.#pendingTransmits.size === 0) return;
437
442
  this.#transmitted.clear();
438
- this.#pendingTransmits = [];
443
+ this.#pendingTransmits.clear();
439
444
  }
440
445
 
441
446
  #forgetKeyForId(id: number): void {
@@ -445,15 +450,16 @@ export class ImageBudget {
445
450
  if (this.#keyToId.get(key) === id) this.#keyToId.delete(key);
446
451
  }
447
452
 
448
- #reconcile(total: number): void {
453
+ #reconcile(total: number): boolean {
449
454
  const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
450
455
  if (desired === this.#planned) {
451
456
  // Budget relaxed without a stricter frame (cap raised or images
452
457
  // removed): surviving graphics are untouched and re-exposed rows
453
458
  // repaint normally, so just track the looser threshold.
454
459
  if (this.#planned < this.#onTerminal) this.#onTerminal = this.#planned;
455
- return;
460
+ return false;
456
461
  }
462
+ const retry = desired > this.#onTerminal;
457
463
  this.#planned = desired;
458
464
  // More images must be demoted than the terminal shows: schedule the purge +
459
465
  // full-redraw frame. Fewer: no ghosts to clear, so just catch the tracking
@@ -461,6 +467,7 @@ export class ImageBudget {
461
467
  // render is needed to apply the new threshold.
462
468
  if (desired <= this.#onTerminal) this.#onTerminal = desired;
463
469
  this.#requestRender();
470
+ return retry;
464
471
  }
465
472
  }
466
473
 
@@ -137,7 +137,7 @@ function normalizeHtmlEntitiesForTerminal(raw: string): string {
137
137
  if (Number.isFinite(value) && value >= 0 && value <= 0x10ffff) {
138
138
  try {
139
139
  return String.fromCodePoint(value);
140
- } catch (_) {
140
+ } catch {
141
141
  // Fallback to empty string or original if invalid codepoint
142
142
  }
143
143
  }
@@ -261,7 +261,7 @@ function normalizeHtmlForTerminal(
261
261
  }
262
262
  lastIndex = index + tag.length;
263
263
 
264
- const isClosing = /^<\//.test(tag);
264
+ const isClosing = tag.startsWith("</");
265
265
  const isSelfClosing = /\/\s*>$/.test(tag);
266
266
 
267
267
  switch (name) {
@@ -794,7 +794,7 @@ export function urlTokenPossible(src: string): boolean {
794
794
  }
795
795
  if (i === 0) return false;
796
796
  if (i >= URL_GATE_EMAIL_SCAN_LIMIT) return true; // over-long run: give up conservatively
797
- return src.charCodeAt(i) === 64 /* @ */;
797
+ return src.charCodeAt(i) === 64; /* @ */
798
798
  }
799
799
 
800
800
  // Setext-underline pre-gate for marked's `lheading` rule. The rule's lazy body
@@ -1131,7 +1131,7 @@ function listMayContinueAt(text: string, tailStart: number, listRaw: string): bo
1131
1131
  // bare newline, or end-of-input (which appends can still extend).
1132
1132
  if (i >= n) return true;
1133
1133
  const after = text.charCodeAt(i);
1134
- return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a /* \n */;
1134
+ return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
1135
1135
  }
1136
1136
 
1137
1137
  const NO_BLOCK_BOUNDARY = { end: 0, count: 0 } as const;
@@ -1254,6 +1254,52 @@ function lexDocument(text: string): Token[] {
1254
1254
  return lexWindowed(text);
1255
1255
  }
1256
1256
 
1257
+ /** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
1258
+ export interface MarkdownLink {
1259
+ /** Visible link text (equals `href` for autolinks and bare URLs). */
1260
+ text: string;
1261
+ /** Destination exactly as marked resolved it (references resolved, no normalization). */
1262
+ href: string;
1263
+ }
1264
+
1265
+ /**
1266
+ * Every link token in `text`, in document order, from the same configured
1267
+ * lexer the renderer uses — so fenced code, code spans, escapes, reference
1268
+ * definitions and the GFM autolink rules agree with what is drawn on screen.
1269
+ * Duplicate hrefs are kept; callers decide how to fold them.
1270
+ */
1271
+ export function extractMarkdownLinks(text: string): MarkdownLink[] {
1272
+ const links: MarkdownLink[] = [];
1273
+ const walk = (tokens: readonly Token[] | undefined): void => {
1274
+ if (!tokens) return;
1275
+ for (const token of tokens) {
1276
+ if (token.type === "link") {
1277
+ const link = token as Tokens.Link;
1278
+ if (typeof link.href === "string" && link.href.length > 0) {
1279
+ links.push({
1280
+ text: typeof link.text === "string" && link.text.length > 0 ? link.text : link.href,
1281
+ href: link.href,
1282
+ });
1283
+ }
1284
+ continue;
1285
+ }
1286
+ // Containers: paragraphs, emphasis, lists, blockquotes, table cells.
1287
+ const any = token as {
1288
+ tokens?: Token[];
1289
+ items?: Token[];
1290
+ header?: Array<{ tokens?: Token[] }>;
1291
+ rows?: Array<Array<{ tokens?: Token[] }>>;
1292
+ };
1293
+ walk(any.tokens);
1294
+ walk(any.items);
1295
+ if (any.header) for (const cell of any.header) walk(cell.tokens);
1296
+ if (any.rows) for (const row of any.rows) for (const cell of row) walk(cell.tokens);
1297
+ }
1298
+ };
1299
+ walk(lexDocument(text));
1300
+ return links;
1301
+ }
1302
+
1257
1303
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
1258
1304
  export function clearRenderCache(): void {
1259
1305
  renderCache.clear();
@@ -1300,6 +1346,15 @@ export interface HighlightStreamSession {
1300
1346
  push(chunk: string): string;
1301
1347
  }
1302
1348
 
1349
+ /** Collect distinct hyperlink destinations using the renderer's Markdown grammar, excluding images and code. */
1350
+ export function getMarkdownLinkUrls(text: string): string[] {
1351
+ const urls = new Set<string>();
1352
+ markdownParser.walkTokens(markdownParser.lexer(text), token => {
1353
+ if (token.type === "link" && typeof token.href === "string") urls.add(token.href);
1354
+ });
1355
+ return [...urls];
1356
+ }
1357
+
1303
1358
  /**
1304
1359
  * Theme functions for markdown elements.
1305
1360
  * Each function takes text and returns styled text with ANSI codes.
@@ -1308,6 +1363,8 @@ export interface MarkdownTheme {
1308
1363
  heading: (text: string) => string;
1309
1364
  link: (text: string) => string;
1310
1365
  linkUrl: (text: string) => string;
1366
+ /** Resolve the OSC 8 destination without changing visible text; undefined preserves the authored URL. */
1367
+ resolveLink?: (href: string) => string | undefined;
1311
1368
  code: (text: string) => string;
1312
1369
  codeBlock: (text: string) => string;
1313
1370
  codeBlockBorder: (text: string) => string;
@@ -2355,8 +2412,11 @@ export class Markdown implements Component {
2355
2412
  }
2356
2413
 
2357
2414
  const recorder: TailRenderRecorder = {
2415
+ // [suppressed] render-cache length preallocation
2358
2416
  rows: new Array(tokens.length - spliceEnd).fill(undefined),
2417
+ // [suppressed] render-cache length preallocation
2359
2418
  raws: new Array(tokens.length - spliceEnd).fill(undefined),
2419
+ // [suppressed] render-cache length preallocation
2360
2420
  nextTypes: new Array(tokens.length - spliceEnd).fill(undefined),
2361
2421
  };
2362
2422
  const fresh = this.#renderContentLines(tokens, spliceEnd, tokens.length, contentWidth, signature, recorder);
@@ -2369,8 +2429,11 @@ export class Markdown implements Component {
2369
2429
  // `start`), so a mostly-frozen document allocates only for the
2370
2430
  // unfrozen tail instead of the whole token list every frame.
2371
2431
  const tailCount = tokens.length - start;
2432
+ // [suppressed] render-cache length preallocation
2372
2433
  const rows: (readonly string[] | undefined)[] = new Array(tailCount).fill(undefined);
2434
+ // [suppressed] render-cache length preallocation
2373
2435
  const raws: (string | undefined)[] = new Array(tailCount).fill(undefined);
2436
+ // [suppressed] render-cache length preallocation
2374
2437
  const nextTypes: (string | undefined)[] = new Array(tailCount).fill(undefined);
2375
2438
  if (cache !== undefined && cache.tokenStart === start) {
2376
2439
  for (let i = start; i < Math.min(cache.cachedThrough, spliceEnd); i++) {
@@ -3155,17 +3218,22 @@ export class Markdown implements Component {
3155
3218
  markHtmlItemWhenContent(token.text);
3156
3219
  const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
3157
3220
  const styledLinkText = this.#theme.link(this.#theme.underline(linkText));
3158
- const clickableLinkText = formatHyperlink(styledLinkText, token.href);
3159
- // If link text matches href, only show the link once
3221
+ const href = typeof token.href === "string" ? token.href : "";
3222
+ const target = (href && this.#theme.resolveLink?.(href)) || href;
3223
+ const clickableLinkText = formatHyperlink(styledLinkText, target);
3224
+ // If link text matches href, only show the link once. A missing
3225
+ // href (malformed/partial link token) renders as plain link text
3226
+ // instead of crashing the renderer or emitting an empty "()"
3227
+ // (issue #10283).
3160
3228
  // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
3161
3229
  // For mailto: links, strip the prefix before comparing (autolinked emails have
3162
3230
  // text="foo@bar.com" but href="mailto:foo@bar.com")
3163
- const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
3164
- if (token.text === token.href || token.text === hrefForComparison)
3231
+ const hrefForComparison = href.startsWith("mailto:") ? href.slice(7) : href;
3232
+ if (!href || token.text === href || token.text === hrefForComparison)
3165
3233
  result += clickableLinkText + stylePrefix;
3166
3234
  else {
3167
- const styledLinkUrl = this.#theme.linkUrl(`(${token.href})`);
3168
- result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, token.href)}${stylePrefix}`;
3235
+ const styledLinkUrl = this.#theme.linkUrl(`(${href})`);
3236
+ result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, target)}${stylePrefix}`;
3169
3237
  }
3170
3238
  break;
3171
3239
  }
@@ -3464,6 +3532,7 @@ export class Markdown implements Component {
3464
3532
  let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
3465
3533
 
3466
3534
  if (minCellsWidth > availableForCells) {
3535
+ // [suppressed] column-width allocation
3467
3536
  minColumnWidths = new Array(numCols).fill(1);
3468
3537
  const remaining = availableForCells - numCols;
3469
3538
 
@@ -207,6 +207,7 @@ export class SelectList implements Component, MouseRoutable {
207
207
  // every count is 1, so visualTotal == #filteredItems and overflow falls
208
208
  // back to the original `N > maxVisible` predicate exactly.
209
209
  const conservativeRowWidth = Math.max(0, width - 1);
210
+ // [suppressed] length preallocation
210
211
  const rowCounts = new Array<number>(this.#filteredItems.length);
211
212
  let visualTotal = 0;
212
213
  for (let i = 0; i < this.#filteredItems.length; i++) {
@@ -20,7 +20,6 @@ export class Spacer implements Component {
20
20
  this.#lines = lines;
21
21
  this.#cached = undefined;
22
22
  }
23
-
24
23
  invalidate(): void {
25
24
  // No cached state to invalidate currently
26
25
  }
@@ -28,6 +27,7 @@ export class Spacer implements Component {
28
27
  render(_width: number): readonly string[] {
29
28
  let cached = this.#cached;
30
29
  if (cached === undefined) {
30
+ // [suppressed] cached line allocation
31
31
  cached = new Array(this.#lines).fill("");
32
32
  this.#cached = cached;
33
33
  }
@@ -168,7 +168,7 @@ export class Text implements Component {
168
168
 
169
169
  const result = [...emptyLines, ...contentLines, ...emptyLines];
170
170
  if (resultWidths !== undefined) {
171
- // Pad rows are exactly `width` cells wide.
171
+ // [suppressed] line-width allocation
172
172
  const emptyWidths = new Array<number>(emptyLines.length).fill(width);
173
173
  publishLineWidths(result, [...emptyWidths, ...resultWidths, ...emptyWidths]);
174
174
  }
package/src/deccara.ts CHANGED
@@ -239,7 +239,9 @@ export interface DeccaraPlan {
239
239
  */
240
240
  export function planDeccaraFills(lines: string[], width: number, firstScreenRow = 0): DeccaraPlan {
241
241
  const n = lines.length;
242
+ // [suppressed] length preallocation
242
243
  const texts: string[] = new Array(n);
244
+ // [suppressed] length preallocation
243
245
  const candidates: (FillCandidate | null)[] = new Array(n);
244
246
 
245
247
  for (let k = 0; k < n; k++) {
package/src/keys.ts CHANGED
@@ -198,7 +198,7 @@ export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
198
198
  * modifier methods return precisely-typed concatenations (e.g. `Key.ctrl("c")`
199
199
  * is `"ctrl+c"`, not just `string`). This mirrors the upstream
200
200
  * `@mariozechner/pi-tui` `Key` export verbatim so plugins built against any
201
- * scope alias (`@mariozechner`, `@earendil-works`, `@oh-my-pi`) keep working
201
+ * scope alias (`@mariozechner`, `@earendil-works`, `@linxiraos`) keep working
202
202
  * once the specifier shim remaps them to this package.
203
203
  */
204
204
  export const Key = {