pi-editor-footer 0.1.0

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 (46) hide show
  1. package/AGENTS.md +19 -0
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +29 -0
  4. package/README.md +84 -0
  5. package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
  6. package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
  7. package/docs/agents/domain.md +51 -0
  8. package/docs/agents/issue-tracker.md +45 -0
  9. package/docs/agents/triage-labels.md +15 -0
  10. package/docs/reference/pi-tui-internals.md +144 -0
  11. package/docs/specs/01-config.md +57 -0
  12. package/docs/specs/02-identity.md +20 -0
  13. package/docs/specs/03-border-telemetry.md +48 -0
  14. package/docs/specs/04-header.md +30 -0
  15. package/docs/specs/05-footer.md +30 -0
  16. package/docs/specs/06-git.md +36 -0
  17. package/docs/specs/07-runtime.md +28 -0
  18. package/docs/specs/theme-overview.md +116 -0
  19. package/package.json +16 -0
  20. package/src/config.ts +184 -0
  21. package/src/detail-render.ts +119 -0
  22. package/src/footer.ts +479 -0
  23. package/src/git.ts +170 -0
  24. package/src/header.ts +185 -0
  25. package/src/icons.ts +197 -0
  26. package/src/index.ts +607 -0
  27. package/src/model-info.ts +341 -0
  28. package/src/runtime.ts +318 -0
  29. package/src/state.ts +144 -0
  30. package/src/telemetry.ts +437 -0
  31. package/src/theme-settings.ts +461 -0
  32. package/src/tracking-editor.ts +352 -0
  33. package/src/utils-workspace.ts +48 -0
  34. package/src/utils.ts +388 -0
  35. package/src/window-presentation.ts +56 -0
  36. package/test/config.test.ts +146 -0
  37. package/test/detail-render.test.ts +202 -0
  38. package/test/footer.test.ts +86 -0
  39. package/test/git.test.ts +45 -0
  40. package/test/header.test.ts +169 -0
  41. package/test/icons.test.ts +24 -0
  42. package/test/runtime.test.ts +71 -0
  43. package/test/telemetry.test.ts +199 -0
  44. package/test/utils.test.ts +71 -0
  45. package/test/window-presentation.test.ts +73 -0
  46. package/tsconfig.json +13 -0
@@ -0,0 +1,352 @@
1
+ import {
2
+ Editor,
3
+ type EditorOptions,
4
+ type EditorTheme,
5
+ type SelectItem,
6
+ type SelectList,
7
+ type TUI,
8
+ } from "@earendil-works/pi-tui";
9
+ import { CURSOR_MARKER, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import {
11
+ applyModelInfo,
12
+ type ModelInfo,
13
+ type ThemeLike,
14
+ } from "./model-info.js";
15
+
16
+ export type CursorStyle = "block" | "bar" | "underline";
17
+
18
+ const CURSOR_STYLE_SEQUENCES: Partial<Record<CursorStyle, string>> = {
19
+ bar: "\x1b[6 q",
20
+ underline: "\x1b[4 q",
21
+ };
22
+ const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
23
+
24
+ function stripAnsi(s: string): string {
25
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
26
+ }
27
+
28
+ function removeSoftwareCursor(line: string, cursorMarker = ""): string {
29
+ return line.replace(
30
+ /\x1b\[7m([\s\S]*?)\x1b\[0m/g,
31
+ (_match, cursor: string) => {
32
+ const replacement = `${cursorMarker}${cursor}`;
33
+ cursorMarker = "";
34
+ return replacement;
35
+ },
36
+ );
37
+ }
38
+
39
+ function configureCursor(tui: TUI, cursorStyle: CursorStyle): void {
40
+ if (cursorStyle === "block") return;
41
+ const setShow = (
42
+ tui as unknown as { setShowHardwareCursor?: (v: boolean) => void }
43
+ ).setShowHardwareCursor;
44
+ if (typeof setShow === "function") setShow.call(tui, true);
45
+ const seq = CURSOR_STYLE_SEQUENCES[cursorStyle];
46
+ const term = (tui as unknown as { terminal?: { write: (s: string) => void } })
47
+ .terminal;
48
+ if (seq && term && typeof term.write === "function") term.write(seq);
49
+ }
50
+
51
+ function isPlainBorder(line: string): boolean {
52
+ return /^─+$/.test(stripAnsi(line));
53
+ }
54
+ function isScrollBorder(line: string): boolean {
55
+ return /^─── [↑↓] \d+ more/.test(stripAnsi(line));
56
+ }
57
+ function isBorderLine(line: string): boolean {
58
+ const plain = stripAnsi(line);
59
+ return /^─+$/.test(plain) || /^─── [↑↓] \d+ more/.test(plain);
60
+ }
61
+
62
+ function embedBottomBorder(
63
+ lines: string[],
64
+ width: number,
65
+ leftText: string,
66
+ rightText: string,
67
+ getGlow: (s: string) => string,
68
+ ): string[] {
69
+ if ((!leftText && !rightText) || lines.length === 0) return lines;
70
+ let bottomIdx = -1;
71
+ for (let i = lines.length - 1; i >= 0; i--) {
72
+ if (isBorderLine(lines[i] ?? "")) {
73
+ bottomIdx = i;
74
+ break;
75
+ }
76
+ }
77
+ if (bottomIdx === -1) return lines;
78
+ const leftW = leftText ? visibleWidth(leftText) : 0;
79
+ const rightW = rightText ? visibleWidth(rightText) : 0;
80
+ const maxLeft = rightText ? Math.max(0, width - rightW - 6) : width - 3;
81
+ const maxRight = leftText ? Math.max(0, width - leftW - 6) : width - 3;
82
+ let displayLeft = leftText;
83
+ let displayRight = rightText;
84
+ if (leftW > maxLeft) displayLeft = truncateToWidth(leftText, maxLeft, "");
85
+ if (rightW > maxRight) displayRight = truncateToWidth(rightText, maxRight, "");
86
+ const leftSegment = displayLeft
87
+ ? `${getGlow("─")}${" "}${displayLeft}${" "}`
88
+ : getGlow("─");
89
+ const rightSegment = displayRight
90
+ ? `${" "}${displayRight}${" "}${getGlow("─")}`
91
+ : getGlow("─");
92
+ const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
93
+ const middleWidth = Math.max(0, width - used);
94
+ const middle = getGlow("─".repeat(middleWidth));
95
+ const embedded = `${leftSegment}${middle}${rightSegment}`;
96
+ const result = [...lines];
97
+ result[bottomIdx] = truncateToWidth(embedded, width, "");
98
+ if (visibleWidth(embedded) !== width) result[bottomIdx] = embedded;
99
+ return result;
100
+ }
101
+
102
+ interface KeybindingsLike {
103
+ matches(data: string, keybinding: string): boolean;
104
+ }
105
+
106
+ interface AutocompleteSuggestionsLike {
107
+ prefix: string;
108
+ items: SelectItem[];
109
+ }
110
+
111
+ interface EditorInternals {
112
+ autocompleteList?: SelectList;
113
+ applyAutocompleteSuggestions?: (
114
+ suggestions: AutocompleteSuggestionsLike,
115
+ state: "force" | "regular",
116
+ ) => void;
117
+ }
118
+
119
+ export class TrackingEditor extends Editor {
120
+ keybindings: KeybindingsLike;
121
+ actionHandlers = new Map<string, () => void>();
122
+
123
+ onEscape?: () => void;
124
+ onCtrlD?: () => void;
125
+ onPasteImage?: () => void;
126
+ onExtensionShortcut?: (data: string) => boolean;
127
+
128
+ onHighlight?: (item: SelectItem | null) => void;
129
+
130
+ glowEnabled = true;
131
+
132
+ private lastWiredList?: SelectList;
133
+
134
+ private modelInfo: ModelInfo = {
135
+ provider: "",
136
+ modelId: "unknown",
137
+ level: "off",
138
+ contextWindow: 0,
139
+ };
140
+
141
+ private readonly getLiveTheme: () => ThemeLike;
142
+
143
+ private cursorStyle: CursorStyle = "block";
144
+ private telemetryText = "";
145
+ private bottomLeftText = "";
146
+ private previewHardwareCursor = false;
147
+
148
+ constructor(
149
+ tui: TUI,
150
+ theme: EditorTheme,
151
+ keybindings: KeybindingsLike,
152
+ getLiveTheme: () => ThemeLike,
153
+ options?: EditorOptions,
154
+ ) {
155
+ super(tui, theme, options);
156
+ this.keybindings = keybindings;
157
+ this.getLiveTheme = getLiveTheme;
158
+ this.patchApplyAutocompleteSuggestions();
159
+ }
160
+
161
+ onAction(action: string, handler: () => void): void {
162
+ this.actionHandlers.set(action, handler);
163
+ }
164
+
165
+ setModelInfo(info: ModelInfo): void {
166
+ this.modelInfo = info;
167
+ this.tui.requestRender();
168
+ }
169
+
170
+ setGlowEnabled(enabled: boolean): void {
171
+ this.glowEnabled = enabled;
172
+ this.tui.requestRender();
173
+ }
174
+
175
+ setCursorStyle(style: CursorStyle): void {
176
+ const changed = style !== this.cursorStyle;
177
+ this.previewHardwareCursor = style !== "block";
178
+ this.cursorStyle = style;
179
+ if (changed) {
180
+ const tuiAny = this.tui as unknown as {
181
+ setShowHardwareCursor?: (v: boolean) => void;
182
+ terminal?: { write: (s: string) => void };
183
+ };
184
+ if (style === "block") {
185
+ if (tuiAny.terminal) tuiAny.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
186
+ if (typeof tuiAny.setShowHardwareCursor === "function")
187
+ tuiAny.setShowHardwareCursor(false);
188
+ } else {
189
+ configureCursor(this.tui, style);
190
+ }
191
+ }
192
+ this.tui.requestRender();
193
+ }
194
+
195
+ getCursorStyle(): CursorStyle {
196
+ return this.cursorStyle;
197
+ }
198
+
199
+ setTelemetryText(text: string): void {
200
+ this.telemetryText = text;
201
+ this.tui.requestRender();
202
+ }
203
+
204
+ setBottomLeftText(text: string): void {
205
+ this.bottomLeftText = text;
206
+ this.tui.requestRender();
207
+ }
208
+
209
+ getBottomLeftText(): string {
210
+ return this.bottomLeftText;
211
+ }
212
+
213
+ getTelemetryText(): string {
214
+ return this.telemetryText;
215
+ }
216
+
217
+ private patchApplyAutocompleteSuggestions(): void {
218
+ const internals = this as unknown as EditorInternals;
219
+ const original = internals.applyAutocompleteSuggestions;
220
+ if (typeof original !== "function") {
221
+ console.warn(
222
+ "[pi-skill-desc] pi-tui internals changed: `applyAutocompleteSuggestions` not found on Editor — highlight tracking disabled.",
223
+ );
224
+ return;
225
+ }
226
+ internals.applyAutocompleteSuggestions = (suggestions, state) => {
227
+ original.call(this, suggestions, state);
228
+ this.syncHighlight();
229
+ };
230
+ }
231
+
232
+ private syncHighlight(): void {
233
+ const list = this.currentAutocompleteList();
234
+ if (!list) {
235
+ this.onHighlight?.(null);
236
+ return;
237
+ }
238
+ if (list !== this.lastWiredList) {
239
+ this.lastWiredList = list;
240
+ list.onSelectionChange = (item) => this.onHighlight?.(item);
241
+ }
242
+ this.onHighlight?.(list.getSelectedItem());
243
+ }
244
+
245
+ private currentAutocompleteList(): SelectList | undefined {
246
+ return (this as unknown as EditorInternals).autocompleteList;
247
+ }
248
+
249
+ private renderBase(width: number): string[] {
250
+ const lines = super.render(width);
251
+ if (this.cursorStyle === "block") return lines;
252
+ let cursorMarker =
253
+ this.previewHardwareCursor &&
254
+ !(this as unknown as { focused?: boolean }).focused
255
+ ? CURSOR_MARKER
256
+ : "";
257
+ if ((this as unknown as { focused?: boolean }).focused)
258
+ this.previewHardwareCursor = false;
259
+ return lines.map((line) => {
260
+ const rendered = removeSoftwareCursor(line, cursorMarker);
261
+ if (rendered !== line) cursorMarker = "";
262
+ return rendered;
263
+ });
264
+ }
265
+
266
+ override render(width: number): string[] {
267
+ let lines = this.renderBase(width);
268
+ if (lines.length === 0) return lines;
269
+
270
+ // Apply model-info glow/label on top (and bottom recolor) if enabled
271
+ if (this.glowEnabled) {
272
+ lines = applyModelInfo(lines, width, this.getLiveTheme(), this.modelInfo);
273
+ }
274
+
275
+ // Embed telemetry on bottom border (theme-respecting, right-aligned, truncated)
276
+ // Embed location (left) and telemetry (right) on bottom border
277
+ if (this.bottomLeftText || this.telemetryText) {
278
+ const theme = this.getLiveTheme();
279
+ const glow = (s: string): string => {
280
+ try {
281
+ const maybeGlow = (
282
+ theme as unknown as {
283
+ getThinkingBorderColor?: (l: string) => (s: string) => string;
284
+ }
285
+ ).getThinkingBorderColor;
286
+ if (typeof maybeGlow === "function")
287
+ return maybeGlow.call(theme, this.modelInfo.level)(s);
288
+ } catch (_err) {
289
+ void _err;
290
+ }
291
+ return s;
292
+ };
293
+ lines = embedBottomBorder(
294
+ lines,
295
+ width,
296
+ this.bottomLeftText,
297
+ this.telemetryText,
298
+ glow,
299
+ );
300
+ }
301
+
302
+ return lines;
303
+ }
304
+
305
+ override handleInput(data: string): void {
306
+ if (this.onExtensionShortcut?.(data)) {
307
+ return;
308
+ }
309
+ if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
310
+ this.onPasteImage?.();
311
+ return;
312
+ }
313
+ if (this.keybindings.matches(data, "app.interrupt")) {
314
+ if (!this.isShowingAutocomplete()) {
315
+ const handler = this.onEscape ?? this.actionHandlers.get("app.interrupt");
316
+ if (handler) {
317
+ handler();
318
+ return;
319
+ }
320
+ }
321
+ super.handleInput(data);
322
+ this.syncHighlight();
323
+ return;
324
+ }
325
+ if (this.keybindings.matches(data, "app.exit")) {
326
+ if (this.getText().length === 0) {
327
+ const handler = this.onCtrlD ?? this.actionHandlers.get("app.exit");
328
+ if (handler) handler();
329
+ return;
330
+ }
331
+ }
332
+ if (
333
+ this.keybindings.matches(data, "tui.editor.historyPrevious") ||
334
+ this.keybindings.matches(data, "tui.editor.historyNext")
335
+ ) {
336
+ super.handleInput(data);
337
+ return;
338
+ }
339
+ for (const [action, handler] of this.actionHandlers) {
340
+ if (
341
+ action !== "app.interrupt" &&
342
+ action !== "app.exit" &&
343
+ this.keybindings.matches(data, action)
344
+ ) {
345
+ handler();
346
+ return;
347
+ }
348
+ }
349
+ super.handleInput(data);
350
+ this.syncHighlight();
351
+ }
352
+ }
@@ -0,0 +1,48 @@
1
+ /** Pure workspace-path helpers — rebuilt bespoke from tmp/pi-open-tui utils. */
2
+
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+
5
+ /** Resolve cwd to ~-prefixed form when inside HOME. */
6
+ export function formatCwd(cwd: string): string {
7
+ const home = process.env.HOME || process.env.USERPROFILE;
8
+ if (!home) return cwd;
9
+ const resolvedCwd = resolve(cwd);
10
+ const resolvedHome = resolve(home);
11
+ const rel = relative(resolvedHome, resolvedCwd);
12
+ const insideHome =
13
+ rel === "" ||
14
+ (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
15
+ if (!insideHome) return cwd;
16
+ return rel === "" ? "~" : `~${sep}${rel}`;
17
+ }
18
+
19
+ /** Last path segment (basename) — platform-agnostic. */
20
+ export function basenamePath(path: string): string {
21
+ return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
22
+ }
23
+
24
+ /** Truncate a path to maxLen, keeping head and tail with ... in the middle. */
25
+ export function truncatePath(path: string, maxLen: number): string {
26
+ if (path.length <= maxLen) return path;
27
+ if (maxLen <= 3) return "...".slice(0, maxLen);
28
+ const sepChar = path.includes("/") ? "/" : "\\";
29
+ const parts = path.split(/[\\/]/);
30
+ if (parts.length <= 2) return path.slice(0, maxLen - 3) + "...";
31
+ const tail: string[] = [];
32
+ let tailLen = 0;
33
+ for (let i = parts.length - 1; i >= 1; i--) {
34
+ const seg = parts[i]!;
35
+ if (tailLen + seg.length + 4 > maxLen) break;
36
+ tail.unshift(seg);
37
+ tailLen += seg.length + 1;
38
+ }
39
+ const head = parts[0]!;
40
+ const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
41
+ return result.length > maxLen ? result.slice(0, maxLen - 3) + "..." : result;
42
+ }
43
+
44
+ /** Resolve display string for a cwd given a workspaceDisplay mode. */
45
+ export function displayCwd(cwd: string, mode: "path" | "name"): string {
46
+ const formatted = formatCwd(cwd);
47
+ return mode === "name" ? basenamePath(formatted) : formatted;
48
+ }