pi-libtui 0.3.2
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/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +55 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
package/src/cursor.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CURSOR_MARKER, stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
3
|
+
import { getTuiAppearance, type TuiCursorStyle } from "./appearance.ts";
|
|
4
|
+
import { tuiTheme } from "./color/theme.ts";
|
|
5
|
+
|
|
6
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
7
|
+
|
|
8
|
+
/** Rendering options for a palette-owned virtual cursor cell. */
|
|
9
|
+
export interface VirtualCursorOptions {
|
|
10
|
+
/** Use the warning cursor used while copy-mode has an active selection. */
|
|
11
|
+
selected?: boolean;
|
|
12
|
+
/** Preserve copy-mode's bold block cursor while sharing its palette policy. */
|
|
13
|
+
mode?: "editor" | "copy";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Semantic purpose used to resolve a configured cursor policy. */
|
|
17
|
+
export type CursorRole = "insertion" | "navigation" | "selection";
|
|
18
|
+
export type NativeCursorStyle = Exclude<TuiCursorStyle, "virtual">;
|
|
19
|
+
export type CursorPresentation = { readonly role: CursorRole } | { readonly style: NativeCursorStyle };
|
|
20
|
+
|
|
21
|
+
/** Options shared by semantic cursor rendering and cursor markers. */
|
|
22
|
+
export interface SemanticCursorOptions {
|
|
23
|
+
/** Semantic cursor whose configured style should be applied. */
|
|
24
|
+
role: CursorRole;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Semantic editor cursor options with the theme required for virtual paint. */
|
|
28
|
+
export interface MarkEditorCursorOptions extends SemanticCursorOptions {
|
|
29
|
+
/** Pi theme used to derive virtual cursor colors. */
|
|
30
|
+
theme: Theme;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CURSOR_ROLE_MARKERS: Record<CursorRole, string> = {
|
|
34
|
+
insertion: "\x1b_pi-libtui:cursor:insertion\x07",
|
|
35
|
+
navigation: "\x1b_pi-libtui:cursor:navigation\x07",
|
|
36
|
+
selection: "\x1b_pi-libtui:cursor:selection\x07",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const CURSOR_ROLE_PATTERN = /\x1b_pi-libtui:cursor:(insertion|navigation|selection)\x07/gu;
|
|
40
|
+
const CURSOR_STYLE_PATTERN =
|
|
41
|
+
/\x1b_pi-libtui:cursor-style:(terminal-default|blinking-block|steady-block|blinking-underline|steady-underline|blinking-bar|steady-bar)\x07/gu;
|
|
42
|
+
|
|
43
|
+
function firstMarkerMatch(pattern: RegExp, text: string): RegExpExecArray | null {
|
|
44
|
+
pattern.lastIndex = 0;
|
|
45
|
+
const match = pattern.exec(text);
|
|
46
|
+
pattern.lastIndex = 0;
|
|
47
|
+
return match;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the configured cursor style for a semantic role.
|
|
52
|
+
*
|
|
53
|
+
* @param role Insertion, navigation, or selection purpose.
|
|
54
|
+
* @returns The active virtual or native terminal cursor style for that role.
|
|
55
|
+
*/
|
|
56
|
+
export function cursorStyle(role: CursorRole): TuiCursorStyle {
|
|
57
|
+
const appearance = getTuiAppearance();
|
|
58
|
+
if (role === "insertion") return appearance.insertionCursor;
|
|
59
|
+
if (role === "navigation") return appearance.navigationCursor;
|
|
60
|
+
return appearance.selectionCursor;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tests whether a cursor style is rendered by the terminal rather than painted into text.
|
|
65
|
+
*
|
|
66
|
+
* @param style Cursor style to classify.
|
|
67
|
+
* @returns `true` for terminal-default, block, underline, and bar styles.
|
|
68
|
+
*/
|
|
69
|
+
export function isNativeCursorStyle(style: TuiCursorStyle): boolean {
|
|
70
|
+
return style !== "virtual";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isProjectedNativeCursorStyle(style: string | undefined): style is NativeCursorStyle {
|
|
74
|
+
return (
|
|
75
|
+
style === "terminal-default" ||
|
|
76
|
+
style === "blinking-block" ||
|
|
77
|
+
style === "steady-block" ||
|
|
78
|
+
style === "blinking-underline" ||
|
|
79
|
+
style === "steady-underline" ||
|
|
80
|
+
style === "blinking-bar" ||
|
|
81
|
+
style === "steady-bar"
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Finds the semantic role paired with Pi's active cursor position in the visible viewport.
|
|
87
|
+
*
|
|
88
|
+
* @param lines Rendered terminal rows, including private cursor markers.
|
|
89
|
+
* @param height Number of bottom-most rows visible to the renderer.
|
|
90
|
+
* @returns The active semantic role, or `undefined` when no valid paired marker exists.
|
|
91
|
+
*/
|
|
92
|
+
export function findCursorRole(lines: readonly string[], height: number): CursorRole | undefined {
|
|
93
|
+
const presentation = findCursorPresentation(lines, height);
|
|
94
|
+
return presentation && "role" in presentation ? presentation.role : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Finds semantic or projected native cursor presentation in the visible viewport. */
|
|
98
|
+
export function findCursorPresentation(lines: readonly string[], height: number): CursorPresentation | undefined {
|
|
99
|
+
const top = Math.max(0, lines.length - height);
|
|
100
|
+
for (let row = lines.length - 1; row >= top; row -= 1) {
|
|
101
|
+
const line = lines[row] ?? "";
|
|
102
|
+
const cursor = line.indexOf(CURSOR_MARKER);
|
|
103
|
+
const afterCursor = cursor >= 0 ? line.slice(cursor + CURSOR_MARKER.length) : "";
|
|
104
|
+
const roleMatch = cursor >= 0 ? firstMarkerMatch(CURSOR_ROLE_PATTERN, afterCursor) : undefined;
|
|
105
|
+
const styleMatch = cursor >= 0 ? firstMarkerMatch(CURSOR_STYLE_PATTERN, afterCursor) : undefined;
|
|
106
|
+
const role = roleMatch?.index === 0 ? roleMatch[1] : undefined;
|
|
107
|
+
const style = styleMatch?.index === 0 ? styleMatch[1] : undefined;
|
|
108
|
+
if (role === "insertion" || role === "navigation" || role === "selection") return { role };
|
|
109
|
+
if (isProjectedNativeCursorStyle(style)) return { style };
|
|
110
|
+
// Virtual navigation and selection cursors deliberately omit Pi's marker.
|
|
111
|
+
// Virtual insertion never does, so an unpaired insertion role is stale.
|
|
112
|
+
if (cursor < 0) {
|
|
113
|
+
const virtualRole = [...line.matchAll(CURSOR_ROLE_PATTERN)].at(-1)?.[1];
|
|
114
|
+
if (virtualRole === "navigation" || virtualRole === "selection") return { role: virtualRole };
|
|
115
|
+
}
|
|
116
|
+
// Pi selects the first cursor marker on the bottom-most marked line. A
|
|
117
|
+
// role elsewhere must not style that cursor.
|
|
118
|
+
if (cursor >= 0) return undefined;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Attaches an embedded terminal's concrete native cursor style to its projected cursor position. */
|
|
124
|
+
export function markNativeCursorPosition(line: string, style: NativeCursorStyle): string {
|
|
125
|
+
const marker = line.indexOf(CURSOR_MARKER);
|
|
126
|
+
if (marker < 0) return line;
|
|
127
|
+
const after = marker + CURSOR_MARKER.length;
|
|
128
|
+
if (
|
|
129
|
+
firstMarkerMatch(CURSOR_ROLE_PATTERN, line.slice(after))?.index === 0 ||
|
|
130
|
+
firstMarkerMatch(CURSOR_STYLE_PATTERN, line.slice(after))?.index === 0
|
|
131
|
+
)
|
|
132
|
+
return line;
|
|
133
|
+
return `${line.slice(0, after)}\x1b_pi-libtui:cursor-style:${style}\x07${line.slice(after)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Attaches a semantic role marker to an existing Pi cursor marker.
|
|
138
|
+
*
|
|
139
|
+
* @param line Rendered line that may contain Pi's cursor marker.
|
|
140
|
+
* @param role Semantic role to attach.
|
|
141
|
+
* @returns The marked line, or the original line when no marker exists or a role is already attached.
|
|
142
|
+
*/
|
|
143
|
+
export function markSemanticCursorPosition(line: string, role: CursorRole): string {
|
|
144
|
+
const marker = line.indexOf(CURSOR_MARKER);
|
|
145
|
+
if (marker < 0) return line;
|
|
146
|
+
const after = marker + CURSOR_MARKER.length;
|
|
147
|
+
CURSOR_ROLE_PATTERN.lastIndex = 0;
|
|
148
|
+
const existing = CURSOR_ROLE_PATTERN.exec(line.slice(after));
|
|
149
|
+
CURSOR_ROLE_PATTERN.lastIndex = 0;
|
|
150
|
+
if (existing?.index === 0) return line;
|
|
151
|
+
return `${line.slice(0, after)}${CURSOR_ROLE_MARKERS[role]}${line.slice(after)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Removes all private pi-libtui cursor-role markers from a rendered line.
|
|
156
|
+
*
|
|
157
|
+
* @param line Rendered terminal line.
|
|
158
|
+
* @returns The line with semantic role metadata removed and other content preserved.
|
|
159
|
+
*/
|
|
160
|
+
export function stripCursorRoleMarkers(line: string): string {
|
|
161
|
+
return line.replaceAll(CURSOR_ROLE_PATTERN, "").replaceAll(CURSOR_STYLE_PATTERN, "");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Removes Pi's one-grapheme fake editor cursor when an unfocused editor omits its hardware marker.
|
|
166
|
+
*
|
|
167
|
+
* @param line Rendered editor line.
|
|
168
|
+
* @returns The cleaned line, or the original line when it contains a focused cursor or no exact fake-cursor form.
|
|
169
|
+
*/
|
|
170
|
+
export function removeUnmarkedEditorCursor(line: string): string {
|
|
171
|
+
// A focused overlay editor owns this marked cursor. Its fake inverse
|
|
172
|
+
// styling is intentionally preserved for Pi to position and render.
|
|
173
|
+
if (line.includes(CURSOR_MARKER)) return line;
|
|
174
|
+
// Native selection highlighting ends with INVERSE_OFF. Restrict this to
|
|
175
|
+
// the editor's one-grapheme + RESET form so selected transcript text stays
|
|
176
|
+
// untouched.
|
|
177
|
+
const match = /\x1b\[7m([^\x1b]+)\x1b\[0m/u.exec(line);
|
|
178
|
+
if (!match || [...graphemes.segment(stripTerminalSequences(match[1] ?? ""))].length !== 1) return line;
|
|
179
|
+
const start = match.index ?? 0;
|
|
180
|
+
return `${line.slice(0, start)}${match[1]}${line.slice(start + match[0].length)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Paints a visible one-cell cursor using pi-libtui semantic colors.
|
|
185
|
+
*
|
|
186
|
+
* @param theme Pi theme used to derive cursor foreground and background colors.
|
|
187
|
+
* @param text Text occupying the cursor cell.
|
|
188
|
+
* @param options Selection and editor/copy rendering policy.
|
|
189
|
+
* @returns ANSI-styled cursor-cell text without semantic role metadata.
|
|
190
|
+
*/
|
|
191
|
+
export function renderVirtualCursor(theme: Theme, text: string, options: VirtualCursorOptions = {}): string {
|
|
192
|
+
const colors = tuiTheme(theme);
|
|
193
|
+
if (options.selected) {
|
|
194
|
+
return `\x1b[1m${colors.fgAnsi("cursor.selectedText")}${colors.bgAnsi("cursor.selected")}${text}\x1b[0m`;
|
|
195
|
+
}
|
|
196
|
+
if (options.mode === "copy") {
|
|
197
|
+
if (getTuiAppearance().softCursor) {
|
|
198
|
+
return `\x1b[1m${colors.fgAnsi("text.primary")}${colors.bgAnsi("surface.cursor")}${text}\x1b[0m`;
|
|
199
|
+
}
|
|
200
|
+
return `\x1b[1m${colors.fgAnsi("cursor.idleText")}${colors.bgAnsi("cursor.idle")}${text}\x1b[0m`;
|
|
201
|
+
}
|
|
202
|
+
if (getTuiAppearance().softCursor) {
|
|
203
|
+
return colors.bg("surface.cursor", colors.fg("text.primary", text));
|
|
204
|
+
}
|
|
205
|
+
return colors.bg("surface.base", colors.fg("text.primary", `\x1b[7m${text}\x1b[27m`));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Renders one active semantic cursor and carries its role to the cursor bridge.
|
|
210
|
+
*
|
|
211
|
+
* @param theme Pi theme used when the resolved style is virtual.
|
|
212
|
+
* @param text Text occupying the cursor cell; virtual rendering strips existing terminal styling.
|
|
213
|
+
* @param options Semantic role whose configured policy should be applied.
|
|
214
|
+
* @returns Virtual ANSI paint or native cursor-position metadata followed by the cell text.
|
|
215
|
+
*/
|
|
216
|
+
export function renderSemanticCursor(theme: Theme, text: string, options: SemanticCursorOptions): string {
|
|
217
|
+
const style = cursorStyle(options.role);
|
|
218
|
+
const marker = CURSOR_ROLE_MARKERS[options.role];
|
|
219
|
+
if (isNativeCursorStyle(style)) return `${CURSOR_MARKER}${marker}${text}`;
|
|
220
|
+
|
|
221
|
+
const plain = stripTerminalSequences(text);
|
|
222
|
+
const cursor =
|
|
223
|
+
options.role === "insertion"
|
|
224
|
+
? renderVirtualCursor(theme, plain)
|
|
225
|
+
: renderVirtualCursor(theme, plain, {
|
|
226
|
+
mode: "copy",
|
|
227
|
+
selected: options.role === "selection",
|
|
228
|
+
});
|
|
229
|
+
// Insertion cursors retain Pi's hidden marker for IME positioning. Copy-mode
|
|
230
|
+
// virtual cursors have no text input and keep the hardware cursor absent.
|
|
231
|
+
return `${options.role === "insertion" ? CURSOR_MARKER : ""}${marker}${cursor}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Preserves and optionally restyles Pi's focused multiline editor cursor through compositing.
|
|
236
|
+
*
|
|
237
|
+
* @param line Rendered editor line containing Pi's inverse-video fake cursor.
|
|
238
|
+
* @param renderCursor Custom cursor-cell renderer, semantic options, or `undefined` to preserve Pi's styling.
|
|
239
|
+
* @returns The line with exactly one Pi cursor position and the requested cursor rendering.
|
|
240
|
+
*/
|
|
241
|
+
export function markEditorCursor(
|
|
242
|
+
line: string,
|
|
243
|
+
renderCursor?: ((text: string) => string) | MarkEditorCursorOptions,
|
|
244
|
+
): string {
|
|
245
|
+
const marker = line.indexOf(CURSOR_MARKER);
|
|
246
|
+
if (marker < 0 && renderCursor && typeof renderCursor !== "function") return removeUnmarkedEditorCursor(line);
|
|
247
|
+
const start = marker >= 0 ? marker + CURSOR_MARKER.length : 0;
|
|
248
|
+
const match = /\x1b\[7m([^\x1b]+)\x1b\[0m/u.exec(line.slice(start));
|
|
249
|
+
if (!match || [...graphemes.segment(stripTerminalSequences(match[1] ?? ""))].length !== 1) return line;
|
|
250
|
+
const cursorStart = start + (match.index ?? 0);
|
|
251
|
+
const render =
|
|
252
|
+
typeof renderCursor === "function"
|
|
253
|
+
? renderCursor
|
|
254
|
+
: renderCursor
|
|
255
|
+
? (text: string) => renderSemanticCursor(renderCursor.theme, text, renderCursor)
|
|
256
|
+
: undefined;
|
|
257
|
+
const cursor = render ? render(stripTerminalSequences(match[1] ?? "")) : match[0];
|
|
258
|
+
let before = line.slice(0, cursorStart);
|
|
259
|
+
if (marker >= 0 && cursor.includes(CURSOR_MARKER)) before = before.replace(CURSOR_MARKER, "");
|
|
260
|
+
return `${before}${marker >= 0 || cursor.includes(CURSOR_MARKER) ? "" : CURSOR_MARKER}${cursor}${line.slice(cursorStart + match[0].length)}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Restyles Pi's focused single-line Input cursor, whose inverse span closes with SGR 27.
|
|
265
|
+
*
|
|
266
|
+
* @param line Rendered Input line containing Pi's cursor marker and inverse cursor cell.
|
|
267
|
+
* @param options Theme and semantic role used to render the replacement cursor.
|
|
268
|
+
* @returns The line with Pi's fake cursor replaced by the configured semantic cursor; unmarked fake cursors are removed.
|
|
269
|
+
*/
|
|
270
|
+
export function markInputCursor(line: string, options: MarkEditorCursorOptions): string {
|
|
271
|
+
const marker = line.indexOf(CURSOR_MARKER);
|
|
272
|
+
if (marker < 0) return removeUnmarkedInputCursor(line);
|
|
273
|
+
const start = marker + CURSOR_MARKER.length;
|
|
274
|
+
const match = /\x1b\[7m([^\x1b]+)\x1b\[27m/u.exec(line.slice(start));
|
|
275
|
+
if (!match || [...graphemes.segment(stripTerminalSequences(match[1] ?? ""))].length !== 1) return line;
|
|
276
|
+
const cursorStart = start + (match.index ?? 0);
|
|
277
|
+
const cursor = renderSemanticCursor(options.theme, stripTerminalSequences(match[1] ?? ""), options);
|
|
278
|
+
const before = line.slice(0, cursorStart).replace(CURSOR_MARKER, "");
|
|
279
|
+
return `${before}${cursor}${line.slice(cursorStart + match[0].length)}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function removeUnmarkedInputCursor(line: string): string {
|
|
283
|
+
if (line.includes(CURSOR_MARKER)) return line;
|
|
284
|
+
const match = /\x1b\[7m([^\x1b]+)\x1b\[27m/u.exec(line);
|
|
285
|
+
if (!match || [...graphemes.segment(stripTerminalSequences(match[1] ?? ""))].length !== 1) return line;
|
|
286
|
+
const start = match.index ?? 0;
|
|
287
|
+
return `${line.slice(0, start)}${match[1]}${line.slice(start + match[0].length)}`;
|
|
288
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { type TuiForegroundColor, tuiTheme } from "../color/theme.ts";
|
|
4
|
+
import type { PillContent } from "./glyphs.ts";
|
|
5
|
+
import { backgroundAnsiAtColumn, contrastingPillBackground, renderPill } from "./powerline-pill.ts";
|
|
6
|
+
|
|
7
|
+
const PASTE_MARKER = String.raw`\[paste #\d+(?: [^\]\r\n]*)?\]`;
|
|
8
|
+
const RENDERED_PASTE_MARKER = new RegExp(`(?:\\x1b\\[7m(${PASTE_MARKER})\\x1b\\[0m)|(${PASTE_MARKER})`, "g");
|
|
9
|
+
|
|
10
|
+
export interface EditorTokenPresentation {
|
|
11
|
+
readonly token: string;
|
|
12
|
+
readonly label: string;
|
|
13
|
+
readonly icon: PillContent["icon"];
|
|
14
|
+
readonly iconTone?: TuiForegroundColor;
|
|
15
|
+
/** Optional feature-owned renderer for stateful token presentation. */
|
|
16
|
+
readonly render?: (context: EditorTokenPillRenderContext) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EditorTokenPillRenderContext {
|
|
20
|
+
readonly theme: Theme;
|
|
21
|
+
readonly content: PillContent;
|
|
22
|
+
readonly destinationBackgroundAnsi: string;
|
|
23
|
+
readonly inverse: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EditorTokenPillGeometry {
|
|
27
|
+
line: number;
|
|
28
|
+
x: number;
|
|
29
|
+
width: number;
|
|
30
|
+
token: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EditorTokenPillResult {
|
|
34
|
+
lines: string[];
|
|
35
|
+
pills: readonly EditorTokenPillGeometry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NO_PILLS: readonly EditorTokenPillGeometry[] = [];
|
|
39
|
+
|
|
40
|
+
function escapeRegExp(value: string): string {
|
|
41
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Render atomic editor tokens as destination-aware pills without changing editor text or cursor state. */
|
|
45
|
+
export function renderEditorTokenPills(
|
|
46
|
+
lines: readonly string[],
|
|
47
|
+
width: number,
|
|
48
|
+
theme: Theme,
|
|
49
|
+
tokens: readonly EditorTokenPresentation[],
|
|
50
|
+
): EditorTokenPillResult {
|
|
51
|
+
const present = tokens.filter(
|
|
52
|
+
(presentation) => presentation.token && lines.some((line) => line.includes(presentation.token)),
|
|
53
|
+
);
|
|
54
|
+
if (present.length === 0) return { lines: [...lines], pills: NO_PILLS };
|
|
55
|
+
const boundedWidth = Math.max(0, Math.floor(width));
|
|
56
|
+
const pills: EditorTokenPillGeometry[] = [];
|
|
57
|
+
let rendered = [...lines];
|
|
58
|
+
for (const presentation of present) {
|
|
59
|
+
const escaped = escapeRegExp(presentation.token);
|
|
60
|
+
const pattern = new RegExp(`(?:\\x1b\\[7m(${escaped})\\x1b\\[0m)|(${escaped})`, "gu");
|
|
61
|
+
rendered = rendered.map((line, lineIndex) =>
|
|
62
|
+
replaceMatches(
|
|
63
|
+
line,
|
|
64
|
+
pattern,
|
|
65
|
+
boundedWidth,
|
|
66
|
+
theme,
|
|
67
|
+
presentation.label,
|
|
68
|
+
presentation.icon,
|
|
69
|
+
presentation.iconTone,
|
|
70
|
+
presentation.render,
|
|
71
|
+
presentation.token,
|
|
72
|
+
lineIndex,
|
|
73
|
+
pills,
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { lines: rendered.map((line) => padLine(line, boundedWidth)), pills };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Render Pi's native multiline-paste markers with the same editor-token contract. */
|
|
81
|
+
export function renderEditorPasteMarkerPills(
|
|
82
|
+
lines: readonly string[],
|
|
83
|
+
width: number,
|
|
84
|
+
theme: Theme,
|
|
85
|
+
): EditorTokenPillResult {
|
|
86
|
+
if (!lines.some((line) => line.includes("[paste #"))) return { lines: [...lines], pills: NO_PILLS };
|
|
87
|
+
const boundedWidth = Math.max(0, Math.floor(width));
|
|
88
|
+
const pills: EditorTokenPillGeometry[] = [];
|
|
89
|
+
const rendered = lines.map((line, lineIndex) =>
|
|
90
|
+
replaceMatches(
|
|
91
|
+
line,
|
|
92
|
+
RENDERED_PASTE_MARKER,
|
|
93
|
+
boundedWidth,
|
|
94
|
+
theme,
|
|
95
|
+
undefined,
|
|
96
|
+
"paste",
|
|
97
|
+
"info",
|
|
98
|
+
undefined,
|
|
99
|
+
undefined,
|
|
100
|
+
lineIndex,
|
|
101
|
+
pills,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
return { lines: rendered.map((line) => padLine(line, boundedWidth)), pills };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function replaceMatches(
|
|
108
|
+
line: string,
|
|
109
|
+
pattern: RegExp,
|
|
110
|
+
width: number,
|
|
111
|
+
theme: Theme,
|
|
112
|
+
label: string | undefined,
|
|
113
|
+
icon: PillContent["icon"],
|
|
114
|
+
iconTone: TuiForegroundColor | undefined,
|
|
115
|
+
render: EditorTokenPresentation["render"],
|
|
116
|
+
token: string | undefined,
|
|
117
|
+
lineIndex: number,
|
|
118
|
+
pills: EditorTokenPillGeometry[],
|
|
119
|
+
): string {
|
|
120
|
+
let addedWidth = 0;
|
|
121
|
+
return line.replace(
|
|
122
|
+
pattern,
|
|
123
|
+
(match, inverseValue: string | undefined, plainValue: string | undefined, offset: number) => {
|
|
124
|
+
const value = inverseValue ?? plainValue;
|
|
125
|
+
if (!value) return match;
|
|
126
|
+
const originalX = visibleWidth(line.slice(0, offset));
|
|
127
|
+
const x = originalX + addedWidth;
|
|
128
|
+
const destination = backgroundAnsiAtColumn(line, originalX);
|
|
129
|
+
const pillLabel = label ?? value.slice(1, -1);
|
|
130
|
+
const content = { icon, iconTone, label: pillLabel } satisfies PillContent;
|
|
131
|
+
let pill: string;
|
|
132
|
+
if (render) {
|
|
133
|
+
pill = render({
|
|
134
|
+
theme,
|
|
135
|
+
content,
|
|
136
|
+
destinationBackgroundAnsi: destination,
|
|
137
|
+
inverse: inverseValue !== undefined,
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
const colors = tuiTheme(theme);
|
|
141
|
+
const contrast = contrastingPillBackground(theme, destination);
|
|
142
|
+
const background = colors.mixForeground(colors.contrastBackground(contrast), contrast, 0.2);
|
|
143
|
+
pill = renderPill(theme, content, background, "text.primary", undefined, destination);
|
|
144
|
+
}
|
|
145
|
+
const pillWidth = visibleWidth(pill);
|
|
146
|
+
if (x < width) pills.push({ line: lineIndex, x, width: Math.min(pillWidth, width - x), token: token ?? value });
|
|
147
|
+
addedWidth += pillWidth - visibleWidth(value);
|
|
148
|
+
return pill;
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function padLine(line: string, width: number): string {
|
|
154
|
+
const clipped = truncateToWidth(line, width, "");
|
|
155
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
156
|
+
}
|