@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.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/native-revision +1 -0
- package/package.json +58 -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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { HUNK_HEADER } from "@pierre/diffs";
|
|
2
|
+
import { sanitizeTuiText } from "../content/terminal-text.ts";
|
|
3
|
+
import type {
|
|
4
|
+
CreateUnifiedDiffModelOptions,
|
|
5
|
+
UnifiedDiffFile,
|
|
6
|
+
UnifiedDiffFileInput,
|
|
7
|
+
UnifiedDiffHunk,
|
|
8
|
+
UnifiedDiffLine,
|
|
9
|
+
UnifiedDiffModel,
|
|
10
|
+
} from "./model.ts";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MAX_CHARACTERS = 1_000_000;
|
|
13
|
+
export const DEFAULT_MAX_ROWS = 20_000;
|
|
14
|
+
export const DEFAULT_MAX_FILES = 2_000;
|
|
15
|
+
|
|
16
|
+
export function createStructuredDiffModel(
|
|
17
|
+
files: readonly UnifiedDiffFileInput[],
|
|
18
|
+
revisionOrOptions?: string | CreateUnifiedDiffModelOptions,
|
|
19
|
+
): UnifiedDiffModel {
|
|
20
|
+
const options = typeof revisionOrOptions === "string" ? { revision: revisionOrOptions } : (revisionOrOptions ?? {});
|
|
21
|
+
const maxCharacters = positiveInteger(options.maxCharacters, DEFAULT_MAX_CHARACTERS);
|
|
22
|
+
const budget = new StructuredBudget(maxCharacters, positiveInteger(options.maxRows, DEFAULT_MAX_ROWS));
|
|
23
|
+
const converted: UnifiedDiffFile[] = [];
|
|
24
|
+
let additions = 0;
|
|
25
|
+
let removals = 0;
|
|
26
|
+
for (const [index, input] of files.slice(0, DEFAULT_MAX_FILES).entries()) {
|
|
27
|
+
const file = convertFile(input, index, budget);
|
|
28
|
+
if (!file) break;
|
|
29
|
+
converted.push(file);
|
|
30
|
+
additions += file.additions;
|
|
31
|
+
removals += file.removals;
|
|
32
|
+
if (budget.truncated) break;
|
|
33
|
+
}
|
|
34
|
+
const modelFiles = Object.freeze(converted);
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
revision: hash(options.revision ?? JSON.stringify(modelFiles)),
|
|
37
|
+
files: modelFiles,
|
|
38
|
+
preamble: Object.freeze([]),
|
|
39
|
+
additions,
|
|
40
|
+
removals,
|
|
41
|
+
sourceRows: budget.rows,
|
|
42
|
+
truncated:
|
|
43
|
+
budget.truncated ||
|
|
44
|
+
budget.characters >= maxCharacters ||
|
|
45
|
+
Boolean(options.truncated) ||
|
|
46
|
+
files.length > DEFAULT_MAX_FILES,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hash(value: string): string {
|
|
51
|
+
let result = 2_166_136_261;
|
|
52
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
53
|
+
result ^= value.charCodeAt(index);
|
|
54
|
+
result = Math.imul(result, 16_777_619);
|
|
55
|
+
}
|
|
56
|
+
return (result >>> 0).toString(36);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type StructuredRow = UnifiedDiffFileInput["hunks"][number]["rows"][number];
|
|
60
|
+
|
|
61
|
+
function convertFile(
|
|
62
|
+
input: UnifiedDiffFileInput,
|
|
63
|
+
index: number,
|
|
64
|
+
budget: StructuredBudget,
|
|
65
|
+
): UnifiedDiffFile | undefined {
|
|
66
|
+
const fallback = `file-${index}`;
|
|
67
|
+
const bothPathsMissing = input.oldPath === undefined && input.newPath === undefined;
|
|
68
|
+
const oldPath = budget.path(bothPathsMissing ? fallback : input.oldPath);
|
|
69
|
+
const newPath = budget.path(bothPathsMissing ? fallback : input.newPath);
|
|
70
|
+
const headers = input.headerLines ?? [
|
|
71
|
+
`--- ${oldPath ? `a/${oldPath}` : "/dev/null"}`,
|
|
72
|
+
`+++ ${newPath ? `b/${newPath}` : "/dev/null"}`,
|
|
73
|
+
];
|
|
74
|
+
const headerLines: string[] = [];
|
|
75
|
+
for (const header of headers) {
|
|
76
|
+
const bounded = budget.row(sanitizeTuiText(header), 16_384);
|
|
77
|
+
if (bounded === undefined) return undefined;
|
|
78
|
+
headerLines.push(bounded);
|
|
79
|
+
if (budget.truncated) break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hunks: UnifiedDiffHunk[] = [];
|
|
83
|
+
let additions = 0;
|
|
84
|
+
let removals = 0;
|
|
85
|
+
for (const [hunkIndex, inputHunk] of input.hunks.entries()) {
|
|
86
|
+
if (budget.exhausted) {
|
|
87
|
+
budget.truncate();
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
const hunk = convertHunk(inputHunk, `f${index}:h${hunkIndex}`, budget);
|
|
91
|
+
if (!hunk) break;
|
|
92
|
+
hunks.push(hunk);
|
|
93
|
+
for (const line of hunk.lines) {
|
|
94
|
+
if (line.kind === "added") additions += 1;
|
|
95
|
+
if (line.kind === "removed") removals += 1;
|
|
96
|
+
}
|
|
97
|
+
if (budget.truncated) break;
|
|
98
|
+
}
|
|
99
|
+
return Object.freeze({
|
|
100
|
+
ref: `f${index}`,
|
|
101
|
+
oldPath: oldPath || undefined,
|
|
102
|
+
newPath: newPath || undefined,
|
|
103
|
+
headerLines: Object.freeze(headerLines),
|
|
104
|
+
hunks: Object.freeze(hunks),
|
|
105
|
+
additions,
|
|
106
|
+
removals,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function convertHunk(
|
|
111
|
+
input: UnifiedDiffFileInput["hunks"][number],
|
|
112
|
+
ref: string,
|
|
113
|
+
budget: StructuredBudget,
|
|
114
|
+
): UnifiedDiffHunk | undefined {
|
|
115
|
+
const shape = hunkShape(input);
|
|
116
|
+
const header = budget.row(
|
|
117
|
+
cleanLine(
|
|
118
|
+
input.header ??
|
|
119
|
+
`@@ -${shape.oldStart ?? 0},${input.oldCount ?? shape.oldRows} +${shape.newStart ?? 0},${input.newCount ?? shape.newRows} @@`,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
if (header === undefined) return undefined;
|
|
123
|
+
|
|
124
|
+
const lines: UnifiedDiffLine[] = [];
|
|
125
|
+
let oldLine = shape.oldStart;
|
|
126
|
+
let newLine = shape.newStart;
|
|
127
|
+
for (const row of input.rows) {
|
|
128
|
+
const prefix = rowPrefix(row.kind);
|
|
129
|
+
const text = budget.row(`${prefix}${cleanLine(row.text)}`);
|
|
130
|
+
if (text === undefined) break;
|
|
131
|
+
const [old, currentNew, nextOld, nextNew] = lineNumbers(row, oldLine, newLine);
|
|
132
|
+
lines.push(
|
|
133
|
+
Object.freeze({
|
|
134
|
+
ref: `${ref}:l${lines.length}`,
|
|
135
|
+
kind: row.kind,
|
|
136
|
+
text: text.slice(prefix.length),
|
|
137
|
+
oldLine: old,
|
|
138
|
+
newLine: currentNew,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
oldLine = nextOld;
|
|
142
|
+
newLine = nextNew;
|
|
143
|
+
if (budget.truncated) break;
|
|
144
|
+
}
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
ref,
|
|
147
|
+
header,
|
|
148
|
+
oldStart: shape.oldStart,
|
|
149
|
+
oldCount: shape.oldCount,
|
|
150
|
+
newStart: shape.newStart,
|
|
151
|
+
newCount: shape.newCount,
|
|
152
|
+
lines: Object.freeze(lines),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function hunkShape(input: UnifiedDiffFileInput["hunks"][number]): {
|
|
157
|
+
readonly oldStart: number | undefined;
|
|
158
|
+
readonly oldCount: number | undefined;
|
|
159
|
+
readonly newStart: number | undefined;
|
|
160
|
+
readonly newCount: number | undefined;
|
|
161
|
+
readonly oldRows: number;
|
|
162
|
+
readonly newRows: number;
|
|
163
|
+
} {
|
|
164
|
+
const header = input.header ? HUNK_HEADER.exec(input.header) : null;
|
|
165
|
+
let oldStart = input.oldStart ?? headerNumber(header?.[1]);
|
|
166
|
+
let newStart = input.newStart ?? headerNumber(header?.[3]);
|
|
167
|
+
let oldRows = 0;
|
|
168
|
+
let newRows = 0;
|
|
169
|
+
for (const row of input.rows) {
|
|
170
|
+
if (row.kind === "context" || row.kind === "removed") {
|
|
171
|
+
oldRows += 1;
|
|
172
|
+
oldStart ??= row.oldLine;
|
|
173
|
+
}
|
|
174
|
+
if (row.kind === "context" || row.kind === "added") {
|
|
175
|
+
newRows += 1;
|
|
176
|
+
newStart ??= row.newLine;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
oldStart,
|
|
181
|
+
oldCount: input.oldCount ?? headerCount(header?.[2], header),
|
|
182
|
+
newStart,
|
|
183
|
+
newCount: input.newCount ?? headerCount(header?.[4], header),
|
|
184
|
+
oldRows,
|
|
185
|
+
newRows,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function headerNumber(value: string | undefined): number | undefined {
|
|
190
|
+
return value === undefined ? undefined : Number(value);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function headerCount(value: string | undefined, header: RegExpExecArray | null): number | undefined {
|
|
194
|
+
return header === null ? undefined : (headerNumber(value) ?? 1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function rowPrefix(kind: UnifiedDiffLine["kind"]): string {
|
|
198
|
+
return kind === "added" ? "+" : kind === "removed" ? "-" : kind === "context" ? " " : "\\";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function lineNumbers(
|
|
202
|
+
row: StructuredRow,
|
|
203
|
+
oldLine: number | undefined,
|
|
204
|
+
newLine: number | undefined,
|
|
205
|
+
): readonly [number | undefined, number | undefined, number | undefined, number | undefined] {
|
|
206
|
+
if (row.kind === "metadata" || row.kind === "malformed") return [row.oldLine, row.newLine, oldLine, newLine];
|
|
207
|
+
const old = row.kind === "added" ? row.oldLine : (row.oldLine ?? oldLine);
|
|
208
|
+
const currentNew = row.kind === "removed" ? row.newLine : (row.newLine ?? newLine);
|
|
209
|
+
const nextOld = row.kind === "context" || row.kind === "removed" ? increment(old) : oldLine;
|
|
210
|
+
const nextNew = row.kind === "context" || row.kind === "added" ? increment(currentNew) : newLine;
|
|
211
|
+
return [old, currentNew, nextOld, nextNew];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function increment(value: number | undefined): number | undefined {
|
|
215
|
+
return value === undefined ? undefined : value + 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function cleanLine(text: string): string {
|
|
219
|
+
return sanitizeTuiText(text.replace(/\r?\n$/u, ""));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
class StructuredBudget {
|
|
223
|
+
characters = 0;
|
|
224
|
+
rows = 0;
|
|
225
|
+
truncated = false;
|
|
226
|
+
|
|
227
|
+
constructor(
|
|
228
|
+
private readonly maxCharacters: number,
|
|
229
|
+
private readonly maxRows: number,
|
|
230
|
+
) {}
|
|
231
|
+
|
|
232
|
+
get exhausted(): boolean {
|
|
233
|
+
return this.characters >= this.maxCharacters || this.rows >= this.maxRows;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
path(value: string | undefined): string | undefined {
|
|
237
|
+
return value === undefined ? undefined : this.take(sanitizeTuiText(value).slice(0, 4_096), false);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
row(value: string, maxLength = Number.POSITIVE_INFINITY): string | undefined {
|
|
241
|
+
return this.take(value.slice(0, maxLength), true);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private take(value: string, row: boolean): string | undefined {
|
|
245
|
+
if (row && this.rows >= this.maxRows) return this.truncate();
|
|
246
|
+
const separator = row && this.rows > 0 ? 1 : 0;
|
|
247
|
+
const available = this.maxCharacters - this.characters - separator;
|
|
248
|
+
if (available <= 0) return this.truncate();
|
|
249
|
+
const bounded = value.slice(0, available);
|
|
250
|
+
this.characters += separator + bounded.length;
|
|
251
|
+
if (row) this.rows += 1;
|
|
252
|
+
if (bounded.length < value.length) this.truncated = true;
|
|
253
|
+
return bounded;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
truncate(): undefined {
|
|
257
|
+
this.truncated = true;
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function positiveInteger(value: number | undefined, fallback: number): number {
|
|
263
|
+
return value === undefined || !Number.isFinite(value) ? fallback : Math.max(1, Math.floor(value));
|
|
264
|
+
}
|
package/src/diff/view.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { loadedDiffHighlighter, whenSyntaxReady } from "../syntax.ts";
|
|
3
|
+
import type { UnifiedDiffModel } from "./model.ts";
|
|
4
|
+
import { type RenderUnifiedDiffOptions, renderUnifiedDiff, type UnifiedDiffViewport } from "./render.ts";
|
|
5
|
+
|
|
6
|
+
export interface UnifiedDiffViewOptions extends Omit<RenderUnifiedDiffOptions, "width"> {
|
|
7
|
+
readonly model: UnifiedDiffModel;
|
|
8
|
+
readonly requestRender?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Small Pi component wrapper around the pure cached renderer. */
|
|
12
|
+
export class UnifiedDiffView implements Component {
|
|
13
|
+
private options: UnifiedDiffViewOptions;
|
|
14
|
+
private renderedSource: readonly string[] | undefined;
|
|
15
|
+
private renderedLines: string[] | undefined;
|
|
16
|
+
private omissionRow: number | undefined;
|
|
17
|
+
private omissionRowHovered = false;
|
|
18
|
+
private syntaxReadyRequested = false;
|
|
19
|
+
|
|
20
|
+
constructor(options: UnifiedDiffViewOptions) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.omissionRow = undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setModel(model: UnifiedDiffModel): void {
|
|
26
|
+
this.options = { ...this.options, model };
|
|
27
|
+
this.invalidate();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getOmissionRow(): number | undefined {
|
|
31
|
+
return this.omissionRow;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setOmissionRowHovered(hovered: boolean): void {
|
|
35
|
+
if (this.omissionRowHovered === hovered) return;
|
|
36
|
+
this.omissionRowHovered = hovered;
|
|
37
|
+
this.invalidate();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setViewport(viewport: UnifiedDiffViewport): void {
|
|
41
|
+
this.options = { ...this.options, viewport };
|
|
42
|
+
this.invalidate();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
render(width: number): string[] {
|
|
46
|
+
this.requestSyntaxReady();
|
|
47
|
+
const result = renderUnifiedDiff(this.options.model, {
|
|
48
|
+
...this.options,
|
|
49
|
+
width,
|
|
50
|
+
omissionRowHovered: this.omissionRowHovered,
|
|
51
|
+
});
|
|
52
|
+
const source = result.lines;
|
|
53
|
+
this.omissionRow = result.omissionRow;
|
|
54
|
+
if (source !== this.renderedSource) {
|
|
55
|
+
this.renderedSource = source;
|
|
56
|
+
this.renderedLines = [...source];
|
|
57
|
+
}
|
|
58
|
+
return this.renderedLines ?? [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
invalidate(): void {
|
|
62
|
+
this.renderedSource = undefined;
|
|
63
|
+
this.renderedLines = undefined;
|
|
64
|
+
this.omissionRow = undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private requestSyntaxReady(): void {
|
|
68
|
+
if (this.syntaxReadyRequested || loadedDiffHighlighter() || !this.options.requestRender) return;
|
|
69
|
+
this.syntaxReadyRequested = true;
|
|
70
|
+
whenSyntaxReady(() => {
|
|
71
|
+
this.invalidate();
|
|
72
|
+
this.options.requestRender?.();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
CURSOR_MARKER,
|
|
5
|
+
stripTerminalSequences,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import { getTuiAppearance } from "../appearance.ts";
|
|
10
|
+
import { BackgroundSurface } from "../background-surface.ts";
|
|
11
|
+
import { type TuiBackgroundToken, tuiTheme } from "../color/theme.ts";
|
|
12
|
+
import { renderPill } from "../decoration/powerline-pill.ts";
|
|
13
|
+
import { animationSmoothnessCadenceMs, animationSpeedMultiplier, pulseGlyphFrame } from "../motion.ts";
|
|
14
|
+
|
|
15
|
+
export type EditorSurfaceStyle = "transparent" | "base" | "editor" | "raised" | "inset" | "accent";
|
|
16
|
+
export type EditorTopTreatment = "none" | "half-block" | "rule" | "status-band";
|
|
17
|
+
export type EditorBottomTreatment = "none" | "rule";
|
|
18
|
+
export type EditorRailStyle = "off" | "static" | "animated";
|
|
19
|
+
export type EditorPromptMarkerMotion = "static" | "working" | "always";
|
|
20
|
+
export type EditorStatusSeparator = "space" | "dot" | "chevron" | "powerline";
|
|
21
|
+
export type EditorStatusBandStyle = "transparent" | "filled" | "powerline";
|
|
22
|
+
|
|
23
|
+
export interface EditorCompositionStyle {
|
|
24
|
+
readonly surface: EditorSurfaceStyle;
|
|
25
|
+
readonly top: EditorTopTreatment;
|
|
26
|
+
readonly bottom: EditorBottomTreatment;
|
|
27
|
+
readonly leftRail: EditorRailStyle;
|
|
28
|
+
readonly rightRail: EditorRailStyle;
|
|
29
|
+
readonly promptMarker: readonly string[];
|
|
30
|
+
readonly promptMarkerMotion: EditorPromptMarkerMotion;
|
|
31
|
+
readonly bottomStatus: boolean;
|
|
32
|
+
readonly statusSeparator: EditorStatusSeparator;
|
|
33
|
+
readonly statusBand: EditorStatusBandStyle;
|
|
34
|
+
readonly inactiveRailTone: "accent" | "border";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EditorCompositionStatus {
|
|
38
|
+
readonly left?: string;
|
|
39
|
+
readonly right?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EditorCompositionRenderOptions {
|
|
43
|
+
readonly width: number;
|
|
44
|
+
readonly content: readonly string[];
|
|
45
|
+
/** Segments rendered in the top-left and top-right regions. */
|
|
46
|
+
readonly topStatus?: EditorCompositionStatus;
|
|
47
|
+
readonly active?: boolean;
|
|
48
|
+
readonly elapsedMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface EditorCompositionPreview {
|
|
52
|
+
readonly style: EditorCompositionStyle;
|
|
53
|
+
readonly prompt?: string;
|
|
54
|
+
readonly topStatus?: EditorCompositionStatus;
|
|
55
|
+
readonly bottomStatus?: EditorCompositionStatus;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class Rows implements Component {
|
|
59
|
+
constructor(private readonly lines: readonly string[]) {}
|
|
60
|
+
render(width: number): string[] {
|
|
61
|
+
return this.lines.map((line) => truncateToWidth(line, width, ""));
|
|
62
|
+
}
|
|
63
|
+
invalidate(): void {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function surfaceToken(surface: EditorSurfaceStyle): TuiBackgroundToken | undefined {
|
|
67
|
+
switch (surface) {
|
|
68
|
+
case "transparent":
|
|
69
|
+
return undefined;
|
|
70
|
+
case "base":
|
|
71
|
+
return "surface.base";
|
|
72
|
+
case "editor":
|
|
73
|
+
return "surface.editor";
|
|
74
|
+
case "raised":
|
|
75
|
+
return "surface.raised";
|
|
76
|
+
case "inset":
|
|
77
|
+
return "surface.inset";
|
|
78
|
+
case "accent":
|
|
79
|
+
return "surface.accent";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fit(line: string, width: number): string {
|
|
84
|
+
const clipped = truncateToWidth(line, Math.max(0, width), "");
|
|
85
|
+
return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function editorContentUsedWidth(line: string): number {
|
|
89
|
+
const trimmedWidth = visibleWidth(stripTerminalSequences(line).trimEnd());
|
|
90
|
+
const cursor = line.indexOf(CURSOR_MARKER);
|
|
91
|
+
const cursorWidth = cursor < 0 ? 0 : visibleWidth(line.slice(0, cursor)) + 1;
|
|
92
|
+
return Math.max(trimmedWidth, cursorWidth);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function paintRows(theme: Theme, surface: EditorSurfaceStyle, width: number, lines: readonly string[]): string[] {
|
|
96
|
+
const token = surfaceToken(surface);
|
|
97
|
+
if (!token) return lines.map((line) => fit(line, width));
|
|
98
|
+
return new BackgroundSurface({ theme, component: new Rows(lines), background: token }).render(width);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function rail(
|
|
102
|
+
theme: Theme,
|
|
103
|
+
style: EditorRailStyle,
|
|
104
|
+
glyph: string,
|
|
105
|
+
active: boolean,
|
|
106
|
+
elapsedMs: number,
|
|
107
|
+
inactiveTone: "accent" | "border",
|
|
108
|
+
): string {
|
|
109
|
+
if (style === "off") return "";
|
|
110
|
+
const colors = tuiTheme(theme);
|
|
111
|
+
if (style !== "animated" || !active) return colors.fg(inactiveTone, glyph);
|
|
112
|
+
const appearance = getTuiAppearance();
|
|
113
|
+
return pulseGlyphFrame(colors, glyph, elapsedMs, {
|
|
114
|
+
periodMs: 1_600 / animationSpeedMultiplier(appearance.animationSpeed),
|
|
115
|
+
baseTone: "border",
|
|
116
|
+
highlightTone: "accent",
|
|
117
|
+
reducedMotion: false,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function markerFrame(style: EditorCompositionStyle, active: boolean, elapsedMs: number): string {
|
|
122
|
+
const frames = style.promptMarker;
|
|
123
|
+
if (frames.length === 0) return "";
|
|
124
|
+
const moving = style.promptMarkerMotion === "always" || (style.promptMarkerMotion === "working" && active);
|
|
125
|
+
if (!moving || frames.length === 1) return frames[0] ?? "";
|
|
126
|
+
const appearance = getTuiAppearance();
|
|
127
|
+
const frameMs = Math.max(40, 180 / animationSpeedMultiplier(appearance.animationSpeed));
|
|
128
|
+
return frames[Math.floor(elapsedMs / frameMs) % frames.length] ?? frames[0] ?? "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Cells removed from Pi's native editor width by the selected chrome. */
|
|
132
|
+
export function editorCompositionContentWidth(style: EditorCompositionStyle, width: number): number {
|
|
133
|
+
const left = style.leftRail === "off" ? 0 : 2;
|
|
134
|
+
const right = style.rightRail === "off" ? 0 : 2;
|
|
135
|
+
const marker = style.promptMarker.length === 0 ? 0 : Math.max(...style.promptMarker.map(visibleWidth)) + 1;
|
|
136
|
+
return Math.max(1, Math.floor(width) - left - right - marker);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function editorStatusSeparator(style: EditorStatusSeparator): string {
|
|
140
|
+
switch (style) {
|
|
141
|
+
case "space":
|
|
142
|
+
return " ";
|
|
143
|
+
case "dot":
|
|
144
|
+
return " · ";
|
|
145
|
+
case "chevron":
|
|
146
|
+
return " > ";
|
|
147
|
+
case "powerline":
|
|
148
|
+
return " ";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function composeEditorStatus(left: string, right: string, width: number): string {
|
|
153
|
+
const safeWidth = Math.max(0, width);
|
|
154
|
+
if (safeWidth === 0) return "";
|
|
155
|
+
const fittedRight = truncateToWidth(right, safeWidth, "");
|
|
156
|
+
if (!left) return `${" ".repeat(Math.max(0, safeWidth - visibleWidth(fittedRight)))}${fittedRight}`;
|
|
157
|
+
if (!right) return truncateToWidth(left, safeWidth, "");
|
|
158
|
+
if (visibleWidth(fittedRight) >= safeWidth) return fittedRight;
|
|
159
|
+
const fittedLeft = truncateToWidth(left, Math.max(0, safeWidth - visibleWidth(fittedRight) - 1), "");
|
|
160
|
+
const gap = Math.max(1, safeWidth - visibleWidth(fittedLeft) - visibleWidth(fittedRight));
|
|
161
|
+
return `${fittedLeft}${" ".repeat(gap)}${fittedRight}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function statusLine(
|
|
165
|
+
theme: Theme,
|
|
166
|
+
style: EditorCompositionStyle,
|
|
167
|
+
status: EditorCompositionStatus,
|
|
168
|
+
width: number,
|
|
169
|
+
): string {
|
|
170
|
+
const left = status.left ?? "";
|
|
171
|
+
const right = status.right ?? "";
|
|
172
|
+
if (style.statusBand !== "powerline") return composeEditorStatus(left, right, width);
|
|
173
|
+
const leftPill = left
|
|
174
|
+
? renderPill(theme, { label: left, icon: false }, "surface.raised", "text.primary", "surface.base")
|
|
175
|
+
: "";
|
|
176
|
+
const rightPill = right
|
|
177
|
+
? renderPill(theme, { label: right, icon: false }, "surface.raised", "text.primary", "surface.base")
|
|
178
|
+
: "";
|
|
179
|
+
return composeEditorStatus(leftPill, rightPill, width);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function ruleLine(theme: Theme, width: number, status?: string): string {
|
|
183
|
+
const colors = tuiTheme(theme);
|
|
184
|
+
if (!status) return colors.fg("border", "─".repeat(Math.max(0, width)));
|
|
185
|
+
const clipped = truncateToWidth(status, Math.max(0, width - 4), "");
|
|
186
|
+
const remaining = Math.max(0, width - visibleWidth(clipped) - 2);
|
|
187
|
+
const left = Math.floor(remaining / 2);
|
|
188
|
+
const right = remaining - left;
|
|
189
|
+
return `${colors.fg("border", "─".repeat(left))} ${clipped} ${colors.fg("border", "─".repeat(right))}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Render generic editor chrome around already-rendered native input rows. */
|
|
193
|
+
export function renderEditorComposition(
|
|
194
|
+
theme: Theme,
|
|
195
|
+
style: EditorCompositionStyle,
|
|
196
|
+
options: EditorCompositionRenderOptions,
|
|
197
|
+
): string[] {
|
|
198
|
+
const width = Math.max(1, Math.floor(options.width));
|
|
199
|
+
const active = options.active ?? false;
|
|
200
|
+
const elapsedMs = options.elapsedMs ?? 0;
|
|
201
|
+
const colors = tuiTheme(theme);
|
|
202
|
+
const leftWidth = style.leftRail === "off" ? 0 : 2;
|
|
203
|
+
const rightWidth = style.rightRail === "off" ? 0 : 2;
|
|
204
|
+
const innerWidth = Math.max(1, width - leftWidth - rightWidth);
|
|
205
|
+
const markerWidth = style.promptMarker.length === 0 ? 0 : Math.max(...style.promptMarker.map(visibleWidth)) + 1;
|
|
206
|
+
const marker = markerFrame(style, active, elapsedMs);
|
|
207
|
+
const topStatus = options.topStatus ?? {};
|
|
208
|
+
const headerLeft = topStatus.left ?? "";
|
|
209
|
+
const headerRight = topStatus.right ?? "";
|
|
210
|
+
const header = headerLeft ? composeEditorStatus(headerLeft, headerRight, innerWidth) : headerRight;
|
|
211
|
+
const headerWidth = visibleWidth(header);
|
|
212
|
+
const firstContentUsedWidth = editorContentUsedWidth(options.content[0] ?? "");
|
|
213
|
+
const inlineHeader =
|
|
214
|
+
style.top === "half-block" &&
|
|
215
|
+
headerLeft.length === 0 &&
|
|
216
|
+
headerWidth > 0 &&
|
|
217
|
+
markerWidth + firstContentUsedWidth + 2 + headerWidth <= innerWidth;
|
|
218
|
+
const renderedTopStatus = statusLine(theme, style, topStatus, width);
|
|
219
|
+
const topRuleStatus = statusLine(theme, style, topStatus, Math.max(1, width - 4));
|
|
220
|
+
const leftRail = (glyph = "┃") =>
|
|
221
|
+
style.leftRail === "off" ? "" : `${rail(theme, style.leftRail, glyph, active, elapsedMs, style.inactiveRailTone)} `;
|
|
222
|
+
const rightRail = (glyph = "┃") =>
|
|
223
|
+
style.rightRail === "off"
|
|
224
|
+
? ""
|
|
225
|
+
: ` ${rail(theme, style.rightRail, glyph, active, elapsedMs, style.inactiveRailTone)}`;
|
|
226
|
+
const rows = options.content.map((line, index) => {
|
|
227
|
+
const prefix =
|
|
228
|
+
markerWidth === 0
|
|
229
|
+
? ""
|
|
230
|
+
: `${index === 0 ? colors.fg("accent", marker) : ""}${" ".repeat(Math.max(0, markerWidth - (index === 0 ? visibleWidth(marker) : 0)))}`;
|
|
231
|
+
let content = `${prefix}${line}`;
|
|
232
|
+
if (index === 0 && inlineHeader) {
|
|
233
|
+
const usedWidth = markerWidth + firstContentUsedWidth;
|
|
234
|
+
content = composeEditorStatus(truncateToWidth(content, usedWidth, ""), header, innerWidth);
|
|
235
|
+
}
|
|
236
|
+
return `${leftRail()}${fit(content, innerWidth)}${rightRail()}`;
|
|
237
|
+
});
|
|
238
|
+
const paintedRows = paintRows(theme, style.surface, width, rows);
|
|
239
|
+
const result: string[] = [];
|
|
240
|
+
if (style.top === "none" && renderedTopStatus) {
|
|
241
|
+
result.push(
|
|
242
|
+
...paintRows(theme, style.statusBand === "filled" ? "raised" : "transparent", width, [renderedTopStatus]),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (style.top === "half-block") {
|
|
246
|
+
const token = surfaceToken(style.surface);
|
|
247
|
+
const capCell = token ? colors.fg(colors.color(token), "▄") : " ";
|
|
248
|
+
const liftedHeader = inlineHeader ? "" : truncateToWidth(header, Math.max(0, innerWidth - 1), "");
|
|
249
|
+
const liftedHeaderWidth = visibleWidth(liftedHeader);
|
|
250
|
+
const fillWidth = Math.max(0, innerWidth - liftedHeaderWidth - (liftedHeader ? 2 : 0));
|
|
251
|
+
const fill = token
|
|
252
|
+
? colors.fg(colors.color(token), `${"▄".repeat(fillWidth)}${liftedHeader ? "▟" : ""}`)
|
|
253
|
+
: " ".repeat(fillWidth);
|
|
254
|
+
const paintedHeader =
|
|
255
|
+
token && liftedHeader ? colors.bg(token, ` ${liftedHeader}`) : liftedHeader ? ` ${liftedHeader}` : "";
|
|
256
|
+
const leftCap =
|
|
257
|
+
style.leftRail === "off"
|
|
258
|
+
? ""
|
|
259
|
+
: `${rail(theme, style.leftRail, "╻", active, elapsedMs, style.inactiveRailTone)}${capCell}`;
|
|
260
|
+
const rightCap =
|
|
261
|
+
style.rightRail === "off"
|
|
262
|
+
? ""
|
|
263
|
+
: `${liftedHeader && token ? colors.bg(token, " ") : capCell}${rail(
|
|
264
|
+
theme,
|
|
265
|
+
style.rightRail,
|
|
266
|
+
liftedHeader ? "┃" : "╻",
|
|
267
|
+
active,
|
|
268
|
+
elapsedMs,
|
|
269
|
+
style.inactiveRailTone,
|
|
270
|
+
)}`;
|
|
271
|
+
result.push(`${leftCap}${fit(`${fill}${paintedHeader}`, innerWidth)}${rightCap}`);
|
|
272
|
+
} else if (style.top === "rule") {
|
|
273
|
+
result.push(ruleLine(theme, width, topRuleStatus));
|
|
274
|
+
} else if (style.top === "status-band" && renderedTopStatus) {
|
|
275
|
+
result.push(
|
|
276
|
+
...paintRows(theme, style.statusBand === "transparent" ? "transparent" : "raised", width, [renderedTopStatus]),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
result.push(...paintedRows);
|
|
280
|
+
if (style.bottom === "rule") result.push(ruleLine(theme, width));
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Render a standalone status row for Pi's footer mount or an xsettings preview. */
|
|
285
|
+
export function renderEditorCompositionStatus(
|
|
286
|
+
theme: Theme,
|
|
287
|
+
style: EditorCompositionStyle,
|
|
288
|
+
status: EditorCompositionStatus,
|
|
289
|
+
width: number,
|
|
290
|
+
options: Pick<EditorCompositionRenderOptions, "active" | "elapsedMs"> = {},
|
|
291
|
+
): string[] {
|
|
292
|
+
if (!style.bottomStatus) return [];
|
|
293
|
+
const safeWidth = Math.max(1, Math.floor(width));
|
|
294
|
+
const leftWidth = style.leftRail === "off" ? 0 : 2;
|
|
295
|
+
const rightWidth = style.rightRail === "off" ? 0 : 2;
|
|
296
|
+
const innerWidth = Math.max(1, safeWidth - leftWidth - rightWidth);
|
|
297
|
+
const line = statusLine(theme, style, status, innerWidth);
|
|
298
|
+
if (!line) return [];
|
|
299
|
+
const active = options.active ?? false;
|
|
300
|
+
const elapsedMs = options.elapsedMs ?? 0;
|
|
301
|
+
const leftRail =
|
|
302
|
+
style.leftRail === "off" ? "" : `${rail(theme, style.leftRail, "┃", active, elapsedMs, style.inactiveRailTone)} `;
|
|
303
|
+
const rightRail =
|
|
304
|
+
style.rightRail === "off" ? "" : ` ${rail(theme, style.rightRail, "┃", active, elapsedMs, style.inactiveRailTone)}`;
|
|
305
|
+
const row = `${leftRail}${fit(line, innerWidth)}${rightRail}`;
|
|
306
|
+
return paintRows(theme, style.surface, safeWidth, [row]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Production composition renderer used by xsettings for candidate previews. */
|
|
310
|
+
export function renderEditorCompositionPreview(
|
|
311
|
+
theme: Theme,
|
|
312
|
+
preview: EditorCompositionPreview,
|
|
313
|
+
width: number,
|
|
314
|
+
elapsedMs = 720,
|
|
315
|
+
): string[] {
|
|
316
|
+
const style = preview.style;
|
|
317
|
+
const prompt = preview.prompt ?? "Ask anything, edit files, run tools";
|
|
318
|
+
const contentWidth = editorCompositionContentWidth(style, width);
|
|
319
|
+
const input = [truncateToWidth(prompt, contentWidth, "…")];
|
|
320
|
+
return [
|
|
321
|
+
...renderEditorComposition(theme, style, {
|
|
322
|
+
width,
|
|
323
|
+
content: input,
|
|
324
|
+
topStatus: preview.topStatus,
|
|
325
|
+
active: true,
|
|
326
|
+
elapsedMs,
|
|
327
|
+
}),
|
|
328
|
+
...renderEditorCompositionStatus(theme, style, preview.bottomStatus ?? {}, width, { active: true, elapsedMs }),
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Repaint cadence needed by always-moving prompt markers. */
|
|
333
|
+
export function editorCompositionCadenceMs(style: EditorCompositionStyle, active: boolean): number | undefined {
|
|
334
|
+
const movingMarker =
|
|
335
|
+
style.promptMarker.length > 1 &&
|
|
336
|
+
(style.promptMarkerMotion === "always" || (style.promptMarkerMotion === "working" && active));
|
|
337
|
+
const movingRail = active && (style.leftRail === "animated" || style.rightRail === "animated");
|
|
338
|
+
if (!movingMarker && !movingRail) return undefined;
|
|
339
|
+
const appearance = getTuiAppearance();
|
|
340
|
+
return animationSmoothnessCadenceMs(appearance.animationSmoothness);
|
|
341
|
+
}
|