@luan.sh/pi-libtui 0.3.8

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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,247 @@
1
+ import { type Component, type Focusable, isFocusable } from "@earendil-works/pi-tui";
2
+ import type { TuiMouseEvent } from "./mouse.ts";
3
+
4
+ /** Input-routing policy for a {@link ComponentStack}. */
5
+ export type ComponentStackInputMode = "active" | "all";
6
+
7
+ /** Construction options for a vertical component stack. */
8
+ export interface ComponentStackOptions {
9
+ /** Send input to one selected child, or to every child in display order. */
10
+ inputMode?: ComponentStackInputMode;
11
+ /** Zero-based initial active child; invalid values select the first child. */
12
+ activeChild?: number;
13
+ /** Fill or clip the rendered stack to this many rows. */
14
+ height?: number | (() => number);
15
+ /** Clip after this many rows without padding shorter content. */
16
+ maxHeight?: number | (() => number);
17
+ /** Place the final child at the bottom when the configured height has spare rows. */
18
+ anchorLastChild?: boolean;
19
+ }
20
+
21
+ /** Geometry recorded for one visible child after the latest render. */
22
+ export interface ComponentStackSpan {
23
+ /** Child component occupying this span. */
24
+ readonly component: Component;
25
+ /** Zero-based position of the child in the stack. */
26
+ readonly index: number;
27
+ /** Zero-based first row of the child within the stack. */
28
+ readonly row: number;
29
+ /** Number of visible rows retained after clipping. */
30
+ readonly height: number;
31
+ /** Width in columns supplied to the child during rendering. */
32
+ readonly width: number;
33
+ }
34
+
35
+ type MouseComponent = Component & { onMouse?(event: TuiMouseEvent): boolean };
36
+
37
+ /**
38
+ * A vertical, semantic component host. Consumers compose and focus children;
39
+ * pointer discovery and coordinate translation remain structural details.
40
+ */
41
+ export class ComponentStack implements Component, Focusable {
42
+ private stackChildren: Component[];
43
+ private activeChild: number | undefined;
44
+ private spans: ComponentStackSpan[] = [];
45
+ private renderedHeight = 0;
46
+ private renderedWidth = 0;
47
+ private hoveredSpan: ComponentStackSpan | undefined;
48
+ private capturedSpan: ComponentStackSpan | undefined;
49
+ private _focused = false;
50
+
51
+ constructor(
52
+ children: readonly Component[] = [],
53
+ private readonly options: ComponentStackOptions = {},
54
+ ) {
55
+ this.stackChildren = [...children];
56
+ this.activeChild = options.activeChild ?? (children.length > 0 ? 0 : undefined);
57
+ this.clampActiveChild();
58
+ }
59
+
60
+ get focused(): boolean {
61
+ return this._focused;
62
+ }
63
+
64
+ set focused(value: boolean) {
65
+ this._focused = value;
66
+ const active = this.getActiveChild();
67
+ if (active && isFocusable(active)) active.focused = value;
68
+ }
69
+
70
+ setChildren(children: readonly Component[]): void {
71
+ if (
72
+ children.length === this.stackChildren.length &&
73
+ children.every((child, index) => child === this.stackChildren[index])
74
+ )
75
+ return;
76
+ const active = this.activeChild === undefined ? undefined : this.stackChildren[this.activeChild];
77
+ this.stackChildren = [...children];
78
+ this.activeChild = active ? this.stackChildren.indexOf(active) : this.activeChild;
79
+ this.clampActiveChild();
80
+ if (this._focused) {
81
+ if (active && active !== this.getActiveChild() && isFocusable(active)) active.focused = false;
82
+ const next = this.getActiveChild();
83
+ if (next && isFocusable(next)) next.focused = true;
84
+ }
85
+ this.spans = [];
86
+ this.hoveredSpan = undefined;
87
+ this.capturedSpan = undefined;
88
+ }
89
+
90
+ setActiveChild(child: number | Component | undefined): void {
91
+ const previous = this.getActiveChild();
92
+ this.activeChild = typeof child === "number" || child === undefined ? child : this.stackChildren.indexOf(child);
93
+ this.clampActiveChild();
94
+ if (this._focused) {
95
+ if (previous && previous !== this.getActiveChild() && isFocusable(previous)) previous.focused = false;
96
+ const next = this.getActiveChild();
97
+ if (next && isFocusable(next)) next.focused = true;
98
+ }
99
+ }
100
+
101
+ getActiveChild(): Component | undefined {
102
+ return this.activeChild === undefined ? undefined : this.stackChildren[this.activeChild];
103
+ }
104
+
105
+ getChildren(): readonly Component[] {
106
+ return [...this.stackChildren];
107
+ }
108
+
109
+ getSpans(): readonly ComponentStackSpan[] {
110
+ return this.spans.map((span) => ({ ...span }));
111
+ }
112
+
113
+ handleInput(data: string): void {
114
+ if ((this.options.inputMode ?? "active") === "all") {
115
+ for (const child of this.stackChildren) child.handleInput?.(data);
116
+ return;
117
+ }
118
+ this.getActiveChild()?.handleInput?.(data);
119
+ }
120
+
121
+ /** Route viewport input to the active child, preserving boolean handling. */
122
+ handleViewportInput(data: string): boolean {
123
+ const active = this.getActiveChild() as (Component & { handleViewportInput?(data: string): boolean }) | undefined;
124
+ return active?.handleViewportInput?.(data) ?? Boolean(active?.handleInput?.(data));
125
+ }
126
+
127
+ invalidate(): void {
128
+ this.spans = [];
129
+ this.hoveredSpan = undefined;
130
+ this.capturedSpan = undefined;
131
+ for (const child of this.stackChildren) child.invalidate();
132
+ }
133
+
134
+ render(width: number): string[] {
135
+ this.renderedWidth = Math.max(0, Math.floor(width));
136
+ const configuredHeight = typeof this.options.height === "function" ? this.options.height() : this.options.height;
137
+ const configuredMaxHeight =
138
+ typeof this.options.maxHeight === "function" ? this.options.maxHeight() : this.options.maxHeight;
139
+ const fixedHeight = configuredHeight !== undefined;
140
+ const requestedHeight = configuredHeight ?? configuredMaxHeight;
141
+ const boundedHeight = requestedHeight === undefined ? undefined : Math.max(0, Math.floor(requestedHeight));
142
+ const rendered: Array<string[] | undefined> = new Array(this.stackChildren.length);
143
+ if (boundedHeight === undefined) {
144
+ for (const [index, child] of this.stackChildren.entries()) rendered[index] = child.render(this.renderedWidth);
145
+ }
146
+ const footerIndex = this.options.anchorLastChild && rendered.length > 1 ? rendered.length - 1 : undefined;
147
+ if (boundedHeight !== undefined && boundedHeight > 0 && footerIndex !== undefined)
148
+ rendered[footerIndex] = this.stackChildren[footerIndex]!.render(this.renderedWidth);
149
+ const naturalHeight = rendered.reduce((height, lines) => height + (lines?.length ?? 0), 0);
150
+ const height = boundedHeight ?? naturalHeight;
151
+ const anchoredFooter = footerIndex === undefined ? undefined : rendered[footerIndex];
152
+ const footerRow = anchoredFooter ? Math.max(0, height - Math.min(height, anchoredFooter.length)) : height;
153
+ const lines: string[] = [];
154
+ const spans: ComponentStackSpan[] = [];
155
+
156
+ for (const [index, child] of this.stackChildren.entries()) {
157
+ const isFooter = anchoredFooter !== undefined && index === footerIndex;
158
+ if (isFooter && lines.length < footerRow)
159
+ lines.push(...Array.from({ length: footerRow - lines.length }, () => ""));
160
+ const row = lines.length;
161
+ const available = Math.max(0, (isFooter ? height : footerRow) - row);
162
+ if (available === 0 && !isFooter) {
163
+ if (footerIndex !== undefined) continue;
164
+ break;
165
+ }
166
+ const childLines = rendered[index] ?? child.render(this.renderedWidth);
167
+ const visibleLines = childLines.slice(0, available);
168
+ lines.push(...visibleLines);
169
+ if (visibleLines.length > 0) {
170
+ spans.push({
171
+ component: this.stackChildren[index]!,
172
+ index,
173
+ row,
174
+ height: visibleLines.length,
175
+ width: this.renderedWidth,
176
+ });
177
+ }
178
+ if (lines.length >= height) break;
179
+ }
180
+ if (fixedHeight) while (lines.length < height) lines.push("");
181
+
182
+ const remap = (previous: ComponentStackSpan | undefined): ComponentStackSpan | undefined =>
183
+ previous
184
+ ? spans.find((span) => span.component === previous.component && span.index === previous.index)
185
+ : undefined;
186
+ this.hoveredSpan = remap(this.hoveredSpan);
187
+ this.capturedSpan = remap(this.capturedSpan);
188
+ this.spans = spans;
189
+ this.renderedHeight = lines.length;
190
+ return lines;
191
+ }
192
+
193
+ /** Dispatch a normalized pointer event to the child span under its coordinates. */
194
+ onMouse(event: TuiMouseEvent): boolean {
195
+ if (event.type === "leave") {
196
+ const handled = this.hoveredSpan ? this.dispatch(this.hoveredSpan, event) : false;
197
+ this.hoveredSpan = undefined;
198
+ return handled;
199
+ }
200
+
201
+ if ((event.type === "drag" || event.type === "release") && this.capturedSpan) {
202
+ const captured = this.capturedSpan;
203
+ const handled = this.dispatch(captured, event);
204
+ if (event.type === "release") this.capturedSpan = undefined;
205
+ return handled;
206
+ }
207
+
208
+ const inside =
209
+ event.col >= 0 && event.col < this.renderedWidth && event.row >= 0 && event.row < this.renderedHeight;
210
+ const span = inside
211
+ ? this.spans.find((candidate) => event.row >= candidate.row && event.row < candidate.row + candidate.height)
212
+ : undefined;
213
+ const hoverHandled = this.updateHover(span, event);
214
+ if (!span) return hoverHandled;
215
+ const handled = this.dispatch(span, event);
216
+ if (event.type === "press" && handled) this.capturedSpan = span;
217
+ return hoverHandled || handled;
218
+ }
219
+
220
+ private updateHover(span: ComponentStackSpan | undefined, event: TuiMouseEvent): boolean {
221
+ if (this.hoveredSpan?.component === span?.component && this.hoveredSpan?.index === span?.index) return false;
222
+ if (this.hoveredSpan) this.dispatch(this.hoveredSpan, { ...event, type: "leave" });
223
+ this.hoveredSpan = span;
224
+ // A press must reach the child before any enter-triggered redraw clears
225
+ // nested stack geometry. All-motion terminals send the regular enter/move
226
+ // transition independently, so clicks do not need to synthesize one.
227
+ return span && (event.type === "enter" || event.type === "move")
228
+ ? this.dispatch(span, { ...event, type: "enter" })
229
+ : false;
230
+ }
231
+
232
+ private dispatch(span: ComponentStackSpan, event: TuiMouseEvent): boolean {
233
+ const handler = (span.component as MouseComponent).onMouse;
234
+ if (!handler) return false;
235
+ return handler.call(span.component, { ...event, row: event.row - span.row });
236
+ }
237
+
238
+ private clampActiveChild(): void {
239
+ if (this.stackChildren.length === 0) {
240
+ this.activeChild = undefined;
241
+ return;
242
+ }
243
+ if (this.activeChild === undefined || this.activeChild < 0 || this.activeChild >= this.stackChildren.length) {
244
+ this.activeChild = 0;
245
+ }
246
+ }
247
+ }
@@ -0,0 +1,60 @@
1
+ export interface TextRange {
2
+ readonly start: number;
3
+ readonly end: number;
4
+ }
5
+
6
+ interface Fence {
7
+ readonly marker: "`" | "~";
8
+ readonly length: number;
9
+ }
10
+
11
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/u;
12
+
13
+ /** Find fenced and inline Markdown code ranges without parsing unrelated Markdown syntax. */
14
+ export function markdownCodeRanges(lines: readonly string[]): readonly (readonly TextRange[])[] {
15
+ let fence: Fence | undefined;
16
+ return lines.map((line) => {
17
+ const marker = FENCE.exec(line)?.[1];
18
+ if (fence) {
19
+ if (
20
+ marker?.[0] === fence.marker &&
21
+ marker.length >= fence.length &&
22
+ /^[ \t]*$/u.test(line.slice(line.indexOf(marker) + marker.length))
23
+ ) {
24
+ fence = undefined;
25
+ }
26
+ return [{ start: 0, end: line.length }];
27
+ }
28
+ if (marker) {
29
+ fence = { marker: marker[0] as Fence["marker"], length: marker.length };
30
+ return [{ start: 0, end: line.length }];
31
+ }
32
+ return inlineCodeRanges(line);
33
+ });
34
+ }
35
+
36
+ function inlineCodeRanges(line: string): TextRange[] {
37
+ const ranges: TextRange[] = [];
38
+ for (let start = 0; start < line.length; start += 1) {
39
+ if (line[start] !== "`" || escaped(line, start)) continue;
40
+ const length = markerLength(line, start);
41
+ const marker = "`".repeat(length);
42
+ const end = line.indexOf(marker, start + length);
43
+ if (end < 0) continue;
44
+ ranges.push({ start, end: end + length });
45
+ start = end + length - 1;
46
+ }
47
+ return ranges;
48
+ }
49
+
50
+ function markerLength(line: string, start: number): number {
51
+ let end = start + 1;
52
+ while (line[end] === "`") end += 1;
53
+ return end - start;
54
+ }
55
+
56
+ function escaped(line: string, index: number): boolean {
57
+ let slashes = 0;
58
+ for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor -= 1) slashes += 1;
59
+ return slashes % 2 === 1;
60
+ }
@@ -0,0 +1,135 @@
1
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
2
+
3
+ /** Remove terminal controls from text that is not explicitly a terminal projection. */
4
+ export function sanitizeTuiText(text: string): string {
5
+ return stripTerminalSequences(sanitizeTuiAnsi(text).replace(/\u009b[0-?]*[ -/]*[@-~]/gu, ""))
6
+ .replaceAll("\r", "")
7
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, "�");
8
+ }
9
+
10
+ /** Sanitize a bounded display prefix without splitting a UTF-16 surrogate pair. */
11
+ export function sanitizeTuiTextPreview(text: string, maximumCharacters: number): string {
12
+ if (maximumCharacters === Number.POSITIVE_INFINITY) return sanitizeTuiText(text);
13
+ const limit = Math.max(1, Math.floor(Number.isFinite(maximumCharacters) ? maximumCharacters : 1));
14
+ if (text.length <= limit) return sanitizeTuiText(text);
15
+ let end = limit;
16
+ const last = text.charCodeAt(end - 1);
17
+ const next = text.charCodeAt(end);
18
+ if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end -= 1;
19
+ return `${sanitizeTuiText(text.slice(0, end))}…`;
20
+ }
21
+
22
+ /** Preserve SGR styling while removing terminal controls with side effects. */
23
+ export function sanitizeTuiAnsi(text: string): string {
24
+ return sanitizeTuiAnsiChunk(text).text;
25
+ }
26
+
27
+ interface SanitizedAnsiChunk {
28
+ text: string;
29
+ pending: string;
30
+ }
31
+
32
+ /** Sanitize one stream chunk while retaining a split terminal sequence for the next chunk. */
33
+ export function sanitizeTuiAnsiChunk(text: string, maxPending = 4_096): SanitizedAnsiChunk {
34
+ let safe = "";
35
+ let plainStart = 0;
36
+ for (let index = terminalSequenceStart(text); index !== undefined; ) {
37
+ safe += sanitizePlainSegment(text.slice(plainStart, index));
38
+ const sequence = terminalSequenceEnd(text, index);
39
+ if (sequence === undefined) return { text: safe, pending: boundPending(text.slice(index), maxPending) };
40
+ if (sequence.kind === "sgr") safe += text.slice(index, sequence.end);
41
+ plainStart = sequence.end;
42
+ index = terminalSequenceStart(text, sequence.end);
43
+ }
44
+ return { text: safe + sanitizePlainSegment(text.slice(plainStart)), pending: "" };
45
+ }
46
+
47
+ function boundPending(pending: string, maximum: number): string {
48
+ const limit = Math.max(4, Math.floor(Number.isFinite(maximum) ? maximum : 4_096));
49
+ if (pending.length <= limit) return pending;
50
+ const csiPrefix = pending.charCodeAt(0) === 0x9b ? "\u009b" : pending.startsWith("\u001b[") ? "\u001b[" : undefined;
51
+ if (csiPrefix) {
52
+ const tail = pending.slice(-(limit - csiPrefix.length - 1));
53
+ return `${csiPrefix}!${tail}`;
54
+ }
55
+ const prefixLength = pending.charCodeAt(0) === 0x1b ? 2 : 1;
56
+ const prefix = pending.slice(0, prefixLength);
57
+ return prefix + pending.slice(-(limit - prefix.length));
58
+ }
59
+
60
+ /** Sanitize and flatten one action-row field. */
61
+ export function sanitizeTuiField(text: string): string {
62
+ return sanitizeTuiText(text).replace(/[\n\t]+/gu, " ");
63
+ }
64
+
65
+ /** Sanitize and flatten a bounded action-row prefix. */
66
+ export function sanitizeTuiFieldPreview(text: string, maximumCharacters: number): string {
67
+ return sanitizeTuiTextPreview(text, maximumCharacters).replace(/[\n\t]+/gu, " ");
68
+ }
69
+
70
+ function sanitizePlainSegment(text: string): string {
71
+ return text.replaceAll("\r", "").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "�");
72
+ }
73
+
74
+ export function terminalSequenceStart(text: string, start = 0): number | undefined {
75
+ for (let index = start; index < text.length; index += 1) {
76
+ const code = text.charCodeAt(index);
77
+ if (
78
+ code === 0x1b ||
79
+ code === 0x90 ||
80
+ code === 0x98 ||
81
+ code === 0x9b ||
82
+ code === 0x9d ||
83
+ code === 0x9e ||
84
+ code === 0x9f
85
+ )
86
+ return index;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ export function terminalSequenceEnd(text: string, start: number): { end: number; kind: "sgr" | "control" } | undefined {
92
+ const control = text.charCodeAt(start);
93
+ const csiStart = control === 0x9b ? start + 1 : text[start + 1] === "[" ? start + 2 : -1;
94
+ if (csiStart >= 0) {
95
+ for (let index = csiStart; index < text.length; index += 1) {
96
+ const code = text.charCodeAt(index);
97
+ if (code < 0x40 || code > 0x7e) continue;
98
+ const parameters = text.slice(csiStart, index);
99
+ return {
100
+ end: index + 1,
101
+ kind: text[index] === "m" && /^[0-9:;?]*$/u.test(parameters) ? "sgr" : "control",
102
+ };
103
+ }
104
+ return undefined;
105
+ }
106
+ if (control === 0x9d) {
107
+ const end = stringControlEnd(text, start + 1, true);
108
+ return end === undefined ? undefined : { end, kind: "control" };
109
+ }
110
+ if (control === 0x90 || control === 0x98 || control === 0x9e || control === 0x9f) {
111
+ const end = stringControlEnd(text, start + 1, false);
112
+ return end === undefined ? undefined : { end, kind: "control" };
113
+ }
114
+
115
+ const introducer = text[start + 1];
116
+ if (introducer === undefined) return undefined;
117
+ if (introducer === "]") {
118
+ const end = stringControlEnd(text, start + 2, true);
119
+ return end === undefined ? undefined : { end, kind: "control" };
120
+ }
121
+ if (introducer === "P" || introducer === "^" || introducer === "_") {
122
+ const end = stringControlEnd(text, start + 2, false);
123
+ return end === undefined ? undefined : { end, kind: "control" };
124
+ }
125
+ return { end: Math.min(text.length, start + 2), kind: "control" };
126
+ }
127
+
128
+ function stringControlEnd(text: string, start: number, bellTerminates: boolean): number | undefined {
129
+ for (let index = start; index < text.length; index += 1) {
130
+ if (bellTerminates && text.charCodeAt(index) === 0x07) return index + 1;
131
+ if (text.charCodeAt(index) === 0x9c) return index + 1;
132
+ if (text.charCodeAt(index) === 0x1b && text[index + 1] === "\\") return index + 2;
133
+ }
134
+ return undefined;
135
+ }
@@ -0,0 +1,105 @@
1
+ import { highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { MarkdownTheme } from "@earendil-works/pi-tui";
3
+ import { type Component, Markdown, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import { tuiTheme } from "../color/theme.ts";
5
+ import { RenderedLinesCache } from "../render-cache.ts";
6
+ import { getTuiRenderEpoch } from "../render-epoch.ts";
7
+ import { sanitizeTuiText } from "./terminal-text.ts";
8
+
9
+ /**
10
+ * Builds the complete theme contract required by Pi's native Markdown renderer.
11
+ * Markdown element colors resolve through pi-libtui semantic tokens. Syntax
12
+ * parsing and language coverage remain delegated to Pi's native highlighter;
13
+ * this function is the sole alias boundary that exposes those native syntax
14
+ * colors to consumers.
15
+ *
16
+ * @param theme The active Pi theme used to derive pi-libtui colors and preserve
17
+ * non-color text attributes such as bold, italic, and underline.
18
+ * @returns A Markdown renderer theme whose color ownership stays behind
19
+ * pi-libtui, including the native syntax-highlighting alias.
20
+ */
21
+ export function semanticMarkdownTheme(theme: Theme): MarkdownTheme {
22
+ const colors = tuiTheme(theme);
23
+ return {
24
+ heading: (text) => colors.fg("heading", text),
25
+ link: (text) => colors.fg("accent", text),
26
+ linkUrl: (text) => colors.fg("text.secondary", text),
27
+ code: (text) => colors.fg("warning", text),
28
+ codeBlock: (text) => colors.fg("text.primary", text),
29
+ codeBlockBorder: (text) => colors.fg("border", text),
30
+ quote: (text) => colors.fg("text.secondary", text),
31
+ quoteBorder: (text) => colors.fg("border", text),
32
+ hr: (text) => colors.fg("border", text),
33
+ listBullet: (text) => colors.fg("accent", text),
34
+ bold: (text) => theme.bold?.(text) ?? text,
35
+ italic: (text) => theme.italic?.(text) ?? text,
36
+ strikethrough: (text) => `\x1b[9m${text}\x1b[29m`,
37
+ underline: (text) => theme.underline?.(text) ?? text,
38
+ // Pi owns parsing and language coverage; pi-libtui owns this native-color alias.
39
+ highlightCode,
40
+ };
41
+ }
42
+
43
+ export interface MarkdownTextOptions {
44
+ readonly theme: Theme;
45
+ readonly text: string;
46
+ readonly maxRows?: number;
47
+ readonly maxCharacters?: number;
48
+ }
49
+
50
+ /** Bounded native Markdown rendering without a card, gutter, or padding. */
51
+ export class MarkdownText implements Component {
52
+ private readonly markdown: Markdown;
53
+ private readonly cache = new RenderedLinesCache();
54
+ private renderEpoch = getTuiRenderEpoch();
55
+ private text: string;
56
+
57
+ constructor(private readonly options: MarkdownTextOptions) {
58
+ this.text = typeof options.text === "string" ? sanitizeTuiText(options.text) : "";
59
+ this.markdown = new Markdown(this.clippedText(), 0, 0, semanticMarkdownTheme(options.theme));
60
+ }
61
+
62
+ setText(text: string): void {
63
+ const normalized = typeof text === "string" ? sanitizeTuiText(text) : "";
64
+ if (normalized === this.text) return;
65
+ this.text = normalized;
66
+ this.markdown.setText(this.clippedText());
67
+ this.cache.clear();
68
+ }
69
+
70
+ render(width: number): string[] {
71
+ const epoch = getTuiRenderEpoch();
72
+ if (epoch !== this.renderEpoch) {
73
+ this.renderEpoch = epoch;
74
+ this.markdown.invalidate();
75
+ }
76
+ const boundedWidth = Math.max(0, Math.floor(width));
77
+ if (boundedWidth === 0) return [];
78
+ return this.cache.get(boundedWidth, "current", () => {
79
+ const rows = this.markdown.render(boundedWidth);
80
+ const limit = finiteBound(this.options.maxRows, 500, 1);
81
+ if (rows.length <= limit) return rows;
82
+ return [...rows.slice(0, limit - 1), truncateToWidth("… content omitted …", boundedWidth, "…")];
83
+ });
84
+ }
85
+
86
+ invalidate(): void {
87
+ this.markdown.invalidate();
88
+ this.cache.clear();
89
+ }
90
+
91
+ private clippedText(): string {
92
+ const maximum = finiteBound(this.options.maxCharacters, 20_000, 0);
93
+ const clipped = this.text.length > maximum ? `${this.text.slice(0, Math.max(0, maximum - 1))}…` : this.text;
94
+ if (clipped.length <= 4_000) return clipped;
95
+ let delimiters = 0;
96
+ for (let index = 0; index < clipped.length; index += 1) {
97
+ if ("*_~`#[](){}|>".includes(clipped[index]!)) delimiters += 1;
98
+ }
99
+ return delimiters > clipped.length / 2 ? `${clipped.slice(0, 1_999)}\n\n… content omitted …` : clipped;
100
+ }
101
+ }
102
+
103
+ function finiteBound(value: number | undefined, fallback: number, minimum: number): number {
104
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(minimum, Math.floor(value)) : fallback;
105
+ }