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.
- package/AGENTS.md +19 -0
- package/CHANGELOG.md +26 -0
- package/CONTEXT.md +29 -0
- package/README.md +84 -0
- package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
- package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/reference/pi-tui-internals.md +144 -0
- package/docs/specs/01-config.md +57 -0
- package/docs/specs/02-identity.md +20 -0
- package/docs/specs/03-border-telemetry.md +48 -0
- package/docs/specs/04-header.md +30 -0
- package/docs/specs/05-footer.md +30 -0
- package/docs/specs/06-git.md +36 -0
- package/docs/specs/07-runtime.md +28 -0
- package/docs/specs/theme-overview.md +116 -0
- package/package.json +16 -0
- package/src/config.ts +184 -0
- package/src/detail-render.ts +119 -0
- package/src/footer.ts +479 -0
- package/src/git.ts +170 -0
- package/src/header.ts +185 -0
- package/src/icons.ts +197 -0
- package/src/index.ts +607 -0
- package/src/model-info.ts +341 -0
- package/src/runtime.ts +318 -0
- package/src/state.ts +144 -0
- package/src/telemetry.ts +437 -0
- package/src/theme-settings.ts +461 -0
- package/src/tracking-editor.ts +352 -0
- package/src/utils-workspace.ts +48 -0
- package/src/utils.ts +388 -0
- package/src/window-presentation.ts +56 -0
- package/test/config.test.ts +146 -0
- package/test/detail-render.test.ts +202 -0
- package/test/footer.test.ts +86 -0
- package/test/git.test.ts +45 -0
- package/test/header.test.ts +169 -0
- package/test/icons.test.ts +24 -0
- package/test/runtime.test.ts +71 -0
- package/test/telemetry.test.ts +199 -0
- package/test/utils.test.ts +71 -0
- package/test/window-presentation.test.ts +73 -0
- package/tsconfig.json +13 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Editor,
|
|
3
|
+
type Component,
|
|
4
|
+
type EditorComponent,
|
|
5
|
+
type EditorTheme,
|
|
6
|
+
type KeybindingsManager,
|
|
7
|
+
type SelectItem,
|
|
8
|
+
type TUI,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { TrackingEditor } from "./tracking-editor.js";
|
|
11
|
+
import { installFooter } from "./footer.js";
|
|
12
|
+
import { createInitialState } from "./state.js";
|
|
13
|
+
import type { FooterState } from "./state.js";
|
|
14
|
+
import { TurnTelemetryTracker, formatTurnTelemetry } from "./telemetry.js";
|
|
15
|
+
import { readGitStatus } from "./git.js";
|
|
16
|
+
import { readRuntimeInfo } from "./runtime.js";
|
|
17
|
+
import { type DetailItem, renderDetail, scroll } from "./detail-render.js";
|
|
18
|
+
import type { ModelInfo, ThemeLike } from "./model-info.js";
|
|
19
|
+
import { decorateWindow, type WindowThemeLike } from "./window-presentation.js";
|
|
20
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
21
|
+
import type { ThemeConfig } from "./config.js";
|
|
22
|
+
import { registerThemeSettingsCommand } from "./theme-settings.js";
|
|
23
|
+
import { formatCwd, basenamePath } from "./utils.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Minimal local declarations for the slice of pi's ExtensionAPI this extension
|
|
27
|
+
* uses. The authoritative types live in @earendil-works/pi-coding-agent — a
|
|
28
|
+
* runtime dependency provided by pi, intentionally NOT a devDependency here
|
|
29
|
+
* (the scaffold's package.json is shared across implementation tickets).
|
|
30
|
+
* Extend this surface as the extension grows.
|
|
31
|
+
*/
|
|
32
|
+
export interface ExtensionWidgetOptionsLike {
|
|
33
|
+
placement?: "aboveEditor" | "belowEditor";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExtensionUIContextLike {
|
|
37
|
+
setEditorComponent(
|
|
38
|
+
factory: (
|
|
39
|
+
tui: TUI,
|
|
40
|
+
theme: EditorTheme,
|
|
41
|
+
keybindings: KeybindingsManager,
|
|
42
|
+
) => EditorComponent,
|
|
43
|
+
): void;
|
|
44
|
+
setWidget(
|
|
45
|
+
key: string,
|
|
46
|
+
content:
|
|
47
|
+
| string[]
|
|
48
|
+
| ((tui: TUI, theme: unknown) => Component & { dispose?(): void })
|
|
49
|
+
| undefined,
|
|
50
|
+
options?: ExtensionWidgetOptionsLike,
|
|
51
|
+
): void;
|
|
52
|
+
/** Live getter for the current theme (used by the border glow at render time). */
|
|
53
|
+
readonly theme: ThemeLike;
|
|
54
|
+
/** Show a transient notification to the user. */
|
|
55
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ExtensionContextLike {
|
|
59
|
+
/** Current run mode: "tui" | "rpc" | "print". */
|
|
60
|
+
mode: string;
|
|
61
|
+
ui: ExtensionUIContextLike;
|
|
62
|
+
model?: { provider?: string; id?: string; contextWindow?: number };
|
|
63
|
+
thinkingLevel?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ExtensionAPILike {
|
|
67
|
+
on(
|
|
68
|
+
event: "session_start",
|
|
69
|
+
handler: (event: unknown, ctx: ExtensionContextLike) => void,
|
|
70
|
+
): void;
|
|
71
|
+
on(
|
|
72
|
+
event: "model_select",
|
|
73
|
+
handler: (event: unknown, ctx: ExtensionContextLike) => void,
|
|
74
|
+
): void;
|
|
75
|
+
on(
|
|
76
|
+
event: "thinking_level_select",
|
|
77
|
+
handler: (event: unknown, ctx: ExtensionContextLike) => void,
|
|
78
|
+
): void;
|
|
79
|
+
on(
|
|
80
|
+
event: "session_shutdown",
|
|
81
|
+
handler: (event: unknown, ctx: ExtensionContextLike) => void,
|
|
82
|
+
): void;
|
|
83
|
+
on(
|
|
84
|
+
event: string,
|
|
85
|
+
handler: (event: unknown, ctx: ExtensionContextLike) => void,
|
|
86
|
+
): void;
|
|
87
|
+
registerShortcut(
|
|
88
|
+
shortcut: string,
|
|
89
|
+
options: { description?: string; handler: () => void },
|
|
90
|
+
): void;
|
|
91
|
+
registerCommand(
|
|
92
|
+
name: string,
|
|
93
|
+
options: {
|
|
94
|
+
description?: string;
|
|
95
|
+
handler: (args: string, ctx: ExtensionContextLike) => void | Promise<void>;
|
|
96
|
+
},
|
|
97
|
+
): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Height cap of the detail window (the user's spec: up to 5 lines). */
|
|
101
|
+
const MAX_LINES = 5;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Window state shared between the highlight callback, the widget component,
|
|
105
|
+
* and the scroll shortcuts. Kept at module scope: only one editor/window is
|
|
106
|
+
* ever active per session.
|
|
107
|
+
*/
|
|
108
|
+
let currentItem: SelectItem | null = null;
|
|
109
|
+
let scrollOffset = 0;
|
|
110
|
+
let lastWidth = 0;
|
|
111
|
+
let tuiRef: TUI | null = null;
|
|
112
|
+
let shortcutsRegistered = false;
|
|
113
|
+
// eslint-disable-next-line prefer-const -- toggled by the /model-info command
|
|
114
|
+
let glowEnabled = true;
|
|
115
|
+
let footerCleanup: (() => void) | null = null;
|
|
116
|
+
let installedEditor: TrackingEditor | null = null;
|
|
117
|
+
let lastSessionCtx: ExtensionContextLike | null = null;
|
|
118
|
+
let currentConfig: ThemeConfig = loadConfig();
|
|
119
|
+
const telemetryTracker = new TurnTelemetryTracker();
|
|
120
|
+
let footerState: FooterState = createInitialState();
|
|
121
|
+
let currentModelInfo: ModelInfo = {
|
|
122
|
+
provider: "",
|
|
123
|
+
modelId: "unknown",
|
|
124
|
+
level: "off",
|
|
125
|
+
contextWindow: 0,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Kind tag for the header — derived from the candidate's command prefix. */
|
|
129
|
+
function kindOf(value: string): string {
|
|
130
|
+
return value.startsWith("skill:") ? "skill" : "command";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function detailItemOf(item: SelectItem): DetailItem {
|
|
134
|
+
return {
|
|
135
|
+
label: item.label,
|
|
136
|
+
kind: kindOf(item.value),
|
|
137
|
+
description: item.description ?? "",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Content-line count for the current description, derived from the renderer's
|
|
143
|
+
* own output so it can never drift from T1's wrapping logic:
|
|
144
|
+
* - Overflowing: the header carries a ` offset/total` marker — read `total`.
|
|
145
|
+
* - Fitting: renderDetail at offset 0 returns `[header, ...content]` — count.
|
|
146
|
+
*/
|
|
147
|
+
function contentLinesFrom(lines: string[]): number {
|
|
148
|
+
if (lines.length === 0) {
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const marker = lines[0].match(/ (\d+)\/(\d+)$/);
|
|
152
|
+
if (marker) {
|
|
153
|
+
return Number.parseInt(marker[2], 10);
|
|
154
|
+
}
|
|
155
|
+
return lines.length - 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The widget component: renders the current window at the TUI's actual width. */
|
|
159
|
+
/**
|
|
160
|
+
* The widget component: renders the bordered window at the TUI's actual width,
|
|
161
|
+
* reading the LIVE theme at render time (so theme swaps apply immediately).
|
|
162
|
+
*/
|
|
163
|
+
function makeWidget(ctx: ExtensionUIContextLike): Component {
|
|
164
|
+
const themeOf = (theme: unknown): WindowThemeLike => {
|
|
165
|
+
const t = theme as {
|
|
166
|
+
fg(color: string, s: string): string;
|
|
167
|
+
bold(s: string): string;
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
border: (s) => t.fg("border", s),
|
|
171
|
+
highlight: (s) => t.fg("accent", t.bold(s)),
|
|
172
|
+
dim: (s) => t.fg("dim", s),
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
invalidate(): void {
|
|
177
|
+
// No cached render state — nothing to invalidate.
|
|
178
|
+
},
|
|
179
|
+
render(width: number): string[] {
|
|
180
|
+
lastWidth = width;
|
|
181
|
+
if (!currentItem) {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
// Two border columns on each side: content wraps at width - 4.
|
|
185
|
+
const innerWidth = Math.max(1, width - 4);
|
|
186
|
+
const lines = renderDetail(
|
|
187
|
+
detailItemOf(currentItem),
|
|
188
|
+
innerWidth,
|
|
189
|
+
MAX_LINES,
|
|
190
|
+
scrollOffset,
|
|
191
|
+
);
|
|
192
|
+
return decorateWindow(lines, width, themeOf(ctx.theme));
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function installWidget(ctx: ExtensionUIContextLike): void {
|
|
198
|
+
ctx.setWidget(
|
|
199
|
+
"pi-skill-desc",
|
|
200
|
+
(tui) => {
|
|
201
|
+
tuiRef = tui;
|
|
202
|
+
return makeWidget(ctx);
|
|
203
|
+
},
|
|
204
|
+
{ placement: "aboveEditor" },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function removeWidget(ctx: ExtensionUIContextLike): void {
|
|
209
|
+
ctx.setWidget("pi-skill-desc", undefined);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Reflect the current highlight in the widget: install, remove, or repaint. */
|
|
213
|
+
function updateWidget(ctx: ExtensionUIContextLike): void {
|
|
214
|
+
const hasContent =
|
|
215
|
+
currentItem !== null && (currentItem.description ?? "").trim() !== "";
|
|
216
|
+
if (hasContent) {
|
|
217
|
+
installWidget(ctx);
|
|
218
|
+
} else {
|
|
219
|
+
removeWidget(ctx);
|
|
220
|
+
}
|
|
221
|
+
tuiRef?.requestRender();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** shift+up/down handler: scroll the detail window one line, clamped. */
|
|
225
|
+
function scrollWindow(delta: -1 | 1): void {
|
|
226
|
+
if (!currentItem || (currentItem.description ?? "").trim() === "") {
|
|
227
|
+
return; // window not shown — keys stay inert
|
|
228
|
+
}
|
|
229
|
+
const width = lastWidth > 0 ? lastWidth : 80;
|
|
230
|
+
// Match the widget's content width (borders take 4 columns).
|
|
231
|
+
const innerWidth = Math.max(1, width - 4);
|
|
232
|
+
const lines = renderDetail(
|
|
233
|
+
detailItemOf(currentItem),
|
|
234
|
+
innerWidth,
|
|
235
|
+
MAX_LINES,
|
|
236
|
+
0,
|
|
237
|
+
);
|
|
238
|
+
scrollOffset = scroll(scrollOffset, delta, contentLinesFrom(lines), MAX_LINES);
|
|
239
|
+
tuiRef?.requestRender();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function modelInfoOf(ctx: ExtensionContextLike): ModelInfo {
|
|
243
|
+
return {
|
|
244
|
+
provider: ctx.model?.provider ?? "",
|
|
245
|
+
modelId: ctx.model?.id ?? "unknown",
|
|
246
|
+
level: ctx.thinkingLevel ?? "off",
|
|
247
|
+
contextWindow: ctx.model?.contextWindow ?? 0,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Install the TrackingEditor as the input editor and wire it up.
|
|
253
|
+
*
|
|
254
|
+
* Deferred (setTimeout 0) so it runs after every other extension's SYNCHRONOUS
|
|
255
|
+
* session_start handler: pi allows exactly one custom editor — last writer
|
|
256
|
+
* wins — and other extensions (e.g. the user's model-info-widget) also claim
|
|
257
|
+
* the slot. We must win it, or the highlight tracking never sees the popup.
|
|
258
|
+
*/
|
|
259
|
+
function installEditor(ctx: ExtensionUIContextLike): void {
|
|
260
|
+
ctx.setEditorComponent((tui, theme, keybindings) => {
|
|
261
|
+
const editor = new TrackingEditor(tui, theme, keybindings, () => ctx.theme);
|
|
262
|
+
installedEditor = editor;
|
|
263
|
+
editor.setModelInfo(currentModelInfo);
|
|
264
|
+
editor.glowEnabled = glowEnabled;
|
|
265
|
+
editor.setCursorStyle(currentConfig.cursorStyle);
|
|
266
|
+
// bottom border left is intentionally empty (cwd removed per user request; cwd lives in footer)
|
|
267
|
+
editor.setBottomLeftText("");
|
|
268
|
+
editor.onHighlight = (item) => {
|
|
269
|
+
currentItem = item;
|
|
270
|
+
scrollOffset = 0; // a new candidate restarts the scroll
|
|
271
|
+
updateWidget(ctx);
|
|
272
|
+
};
|
|
273
|
+
return editor;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Watchdog: if another extension takes the editor slot mid-session (their
|
|
279
|
+
* install ran after ours, or a /reload reordered things), take it back.
|
|
280
|
+
* Only fights when the focused component is an input editor (has a Map
|
|
281
|
+
* `actionHandlers`, the CustomEditor duck-type) that is not ours — selectors,
|
|
282
|
+
* dialogs and overlays are left alone.
|
|
283
|
+
*/
|
|
284
|
+
function ensureEditorOwnership(ctx: ExtensionUIContextLike): void {
|
|
285
|
+
const tui = tuiRef as unknown as {
|
|
286
|
+
getFocusedComponent?: () => unknown;
|
|
287
|
+
} | null;
|
|
288
|
+
const focused = tui?.getFocusedComponent?.();
|
|
289
|
+
if (focused === null || focused === undefined) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const maybeEditor = focused as {
|
|
293
|
+
handleInput?: unknown;
|
|
294
|
+
actionHandlers?: unknown;
|
|
295
|
+
};
|
|
296
|
+
const isInputEditor =
|
|
297
|
+
typeof maybeEditor.handleInput === "function" &&
|
|
298
|
+
maybeEditor.actionHandlers instanceof Map;
|
|
299
|
+
if (isInputEditor && focused !== installedEditor) {
|
|
300
|
+
installEditor(ctx);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Load-time self-check (ADR-0001 blast radius): the extension reaches into two
|
|
306
|
+
* private pi-tui internals. If pi renames them, warn loudly at load instead of
|
|
307
|
+
* silently showing stale/wrong descriptions.
|
|
308
|
+
*/
|
|
309
|
+
function assertInternals(): void {
|
|
310
|
+
const missing: string[] = [];
|
|
311
|
+
const proto = Editor.prototype as unknown as Record<string, unknown>;
|
|
312
|
+
if (typeof proto.applyAutocompleteSuggestions !== "function") {
|
|
313
|
+
missing.push("applyAutocompleteSuggestions (method)");
|
|
314
|
+
}
|
|
315
|
+
// autocompleteList is an instance field, not a prototype member; check its
|
|
316
|
+
// presence in the compiled class source. A minifier that renames it would
|
|
317
|
+
// break tracking for real, so the warning firing is the correct outcome.
|
|
318
|
+
if (!Editor.prototype.constructor.toString().includes("autocompleteList")) {
|
|
319
|
+
missing.push("autocompleteList (field)");
|
|
320
|
+
}
|
|
321
|
+
if (missing.length > 0) {
|
|
322
|
+
console.warn(
|
|
323
|
+
`[pi-skill-desc] pi-tui internals changed — highlight tracking may be broken ` +
|
|
324
|
+
`(missing: ${missing.join(", ")}). See docs/adr/0001-tracking-editor-for-skill-descriptions.md.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
assertInternals();
|
|
330
|
+
|
|
331
|
+
export default function (pi: ExtensionAPILike): void {
|
|
332
|
+
let watchTimer: ReturnType<typeof setInterval> | null = null;
|
|
333
|
+
let deferredInstallTimer: ReturnType<typeof setTimeout> | null = null;
|
|
334
|
+
let headerCleanupInner: (() => void) | null = null;
|
|
335
|
+
|
|
336
|
+
// Toggle the border glow + model label (off restores pi's stock border).
|
|
337
|
+
// Replaces model-info-widget's command, which is inert now that we own the
|
|
338
|
+
// editor slot (ADR-0002).
|
|
339
|
+
pi.registerCommand("model-info", {
|
|
340
|
+
description: "Toggle the model label + glow on the input border",
|
|
341
|
+
handler: async (_args, ctx) => {
|
|
342
|
+
glowEnabled = !glowEnabled;
|
|
343
|
+
installedEditor?.setGlowEnabled(glowEnabled);
|
|
344
|
+
ctx.ui.notify(
|
|
345
|
+
`Model info border ${glowEnabled ? "shown" : "hidden"}`,
|
|
346
|
+
"info",
|
|
347
|
+
);
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
// Lightweight theme command (identity stub, spec 02) — reads config; full dialog lands later.
|
|
351
|
+
// pi-lsz-theme settings window (like tui-theme)
|
|
352
|
+
registerThemeSettingsCommand(pi, {
|
|
353
|
+
getConfig: () => currentConfig,
|
|
354
|
+
onConfigChanged: (cfg) => {
|
|
355
|
+
const prevEnabled = currentConfig.enabled;
|
|
356
|
+
currentConfig = saveConfig(cfg as unknown as Partial<ThemeConfig>);
|
|
357
|
+
// handle enabled toggle — recover or remove footer immediately
|
|
358
|
+
if (prevEnabled !== currentConfig.enabled) {
|
|
359
|
+
if (!currentConfig.enabled) {
|
|
360
|
+
footerCleanup?.();
|
|
361
|
+
footerCleanup = null;
|
|
362
|
+
(globalThis as unknown as { __footerRender?: () => void }).__footerRender = undefined;
|
|
363
|
+
} else if (lastSessionCtx) {
|
|
364
|
+
try {
|
|
365
|
+
footerCleanup?.();
|
|
366
|
+
const ctx2 = lastSessionCtx;
|
|
367
|
+
footerCleanup = installFooter(
|
|
368
|
+
ctx2 as unknown as Parameters<typeof installFooter>[0],
|
|
369
|
+
() => footerState,
|
|
370
|
+
() => currentConfig,
|
|
371
|
+
() => ({
|
|
372
|
+
provider: currentModelInfo.provider,
|
|
373
|
+
model: currentModelInfo.modelId,
|
|
374
|
+
effort: currentModelInfo.level,
|
|
375
|
+
}),
|
|
376
|
+
{
|
|
377
|
+
setRequestRender: (fn) => {
|
|
378
|
+
(globalThis as unknown as { __footerRender?: () => void }).__footerRender = fn ?? undefined;
|
|
379
|
+
},
|
|
380
|
+
scheduleGitRefresh: () => {
|
|
381
|
+
void (async () => {
|
|
382
|
+
try {
|
|
383
|
+
const cwd = (ctx2 as unknown as { sessionManager?: { getCwd: () => string } }).sessionManager?.getCwd?.() ?? (ctx2 as unknown as { cwd?: string }).cwd ?? process.cwd();
|
|
384
|
+
const git = await readGitStatus(cwd);
|
|
385
|
+
footerState = { ...footerState, git } as FooterState;
|
|
386
|
+
installedEditor?.setBottomLeftText("");
|
|
387
|
+
(globalThis as unknown as { __footerRender?: () => void }).__footerRender?.();
|
|
388
|
+
const runtime = await readRuntimeInfo(cwd);
|
|
389
|
+
footerState = { ...footerState, runtime } as FooterState;
|
|
390
|
+
(globalThis as unknown as { __footerRender?: () => void }).__footerRender?.();
|
|
391
|
+
} catch (_e) { void _e; }
|
|
392
|
+
})();
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
// immediate population
|
|
397
|
+
void (async () => {
|
|
398
|
+
try {
|
|
399
|
+
const cwd = (ctx2 as unknown as { sessionManager?: { getCwd: () => string } }).sessionManager?.getCwd?.() ?? (ctx2 as unknown as { cwd?: string }).cwd ?? process.cwd();
|
|
400
|
+
const git = await readGitStatus(cwd);
|
|
401
|
+
footerState = { ...footerState, git } as FooterState;
|
|
402
|
+
(globalThis as unknown as { __footerRender?: () => void }).__footerRender?.();
|
|
403
|
+
const runtime = await readRuntimeInfo(cwd);
|
|
404
|
+
footerState = { ...footerState, runtime } as FooterState;
|
|
405
|
+
(globalThis as unknown as { __footerRender?: () => void }).__footerRender?.();
|
|
406
|
+
} catch (_e) { void _e; }
|
|
407
|
+
})();
|
|
408
|
+
} catch (_e) { void _e; }
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
installedEditor?.setCursorStyle(currentConfig.cursorStyle);
|
|
412
|
+
tuiRef?.requestRender();
|
|
413
|
+
},
|
|
414
|
+
onOverlayClosed: () => {
|
|
415
|
+
tuiRef?.requestRender();
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
pi.on("session_start", (_event, ctx) => {
|
|
419
|
+
// Only the interactive TUI has an editor component to replace.
|
|
420
|
+
if (ctx.mode !== "tui") {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!shortcutsRegistered) {
|
|
425
|
+
shortcutsRegistered = true;
|
|
426
|
+
pi.registerShortcut("shift+up", {
|
|
427
|
+
description: "Scroll the pi-skill-desc detail window up",
|
|
428
|
+
handler: () => scrollWindow(-1),
|
|
429
|
+
});
|
|
430
|
+
pi.registerShortcut("shift+down", {
|
|
431
|
+
description: "Scroll the pi-skill-desc detail window down",
|
|
432
|
+
handler: () => scrollWindow(1),
|
|
433
|
+
});
|
|
434
|
+
// Fallbacks that work on every terminal (no Kitty/modified-arrow
|
|
435
|
+
// protocol needed): ESC+j / ESC+k are universally distinguishable.
|
|
436
|
+
pi.registerShortcut("alt+j", {
|
|
437
|
+
description: "Scroll the pi-skill-desc detail window up (fallback)",
|
|
438
|
+
handler: () => scrollWindow(-1),
|
|
439
|
+
});
|
|
440
|
+
pi.registerShortcut("alt+k", {
|
|
441
|
+
description: "Scroll the pi-skill-desc detail window down (fallback)",
|
|
442
|
+
handler: () => scrollWindow(1),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
currentModelInfo = modelInfoOf(ctx);
|
|
447
|
+
lastSessionCtx = ctx;
|
|
448
|
+
|
|
449
|
+
// Deferred so we win the single editor slot (see installEditor).
|
|
450
|
+
deferredInstallTimer = setTimeout(() => installEditor(ctx.ui), 0);
|
|
451
|
+
|
|
452
|
+
// header disabled — first line workspace/hints removed per user request; cwd preserved in footer below input
|
|
453
|
+
if (!currentConfig.enabled) {
|
|
454
|
+
footerCleanup?.();
|
|
455
|
+
footerCleanup = null;
|
|
456
|
+
(globalThis as unknown as { __footerRender?: () => void }).__footerRender = undefined;
|
|
457
|
+
} else {
|
|
458
|
+
try {
|
|
459
|
+
// footer
|
|
460
|
+
try {
|
|
461
|
+
footerCleanup?.();
|
|
462
|
+
footerCleanup = installFooter(
|
|
463
|
+
ctx as unknown as Parameters<typeof installFooter>[0],
|
|
464
|
+
() => footerState,
|
|
465
|
+
() => currentConfig,
|
|
466
|
+
() => ({
|
|
467
|
+
provider: currentModelInfo.provider,
|
|
468
|
+
model: currentModelInfo.modelId,
|
|
469
|
+
effort: currentModelInfo.level,
|
|
470
|
+
}),
|
|
471
|
+
{
|
|
472
|
+
setRequestRender: (fn) => {
|
|
473
|
+
(
|
|
474
|
+
globalThis as unknown as { __footerRender?: () => void }
|
|
475
|
+
).__footerRender = fn ?? undefined;
|
|
476
|
+
},
|
|
477
|
+
scheduleGitRefresh: () => {
|
|
478
|
+
void (async () => {
|
|
479
|
+
try {
|
|
480
|
+
const cwd =
|
|
481
|
+
(
|
|
482
|
+
ctx as unknown as { sessionManager?: { getCwd: () => string } }
|
|
483
|
+
).sessionManager?.getCwd?.() ??
|
|
484
|
+
(ctx as unknown as { cwd?: string }).cwd ??
|
|
485
|
+
process.cwd();
|
|
486
|
+
const git = await readGitStatus(cwd);
|
|
487
|
+
footerState = { ...footerState, git } as FooterState;
|
|
488
|
+
// bottom border left: location + git (right of cwd)
|
|
489
|
+
installedEditor?.setBottomLeftText("");
|
|
490
|
+
(
|
|
491
|
+
globalThis as unknown as { __footerRender?: () => void }
|
|
492
|
+
).__footerRender?.();
|
|
493
|
+
const runtime = await readRuntimeInfo(cwd);
|
|
494
|
+
footerState = { ...footerState, runtime } as FooterState;
|
|
495
|
+
(
|
|
496
|
+
globalThis as unknown as { __footerRender?: () => void }
|
|
497
|
+
).__footerRender?.();
|
|
498
|
+
} catch (_e) {
|
|
499
|
+
void _e;
|
|
500
|
+
}
|
|
501
|
+
})();
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
);
|
|
505
|
+
} catch (_e) {
|
|
506
|
+
void _e;
|
|
507
|
+
}
|
|
508
|
+
// initial git/runtime population so footer isn't empty at startup (onBranchChange only fires on change)
|
|
509
|
+
void (async () => {
|
|
510
|
+
try {
|
|
511
|
+
const cwd =
|
|
512
|
+
(
|
|
513
|
+
ctx as unknown as { sessionManager?: { getCwd: () => string } }
|
|
514
|
+
).sessionManager?.getCwd?.() ??
|
|
515
|
+
(ctx as unknown as { cwd?: string }).cwd ??
|
|
516
|
+
process.cwd();
|
|
517
|
+
const git = await readGitStatus(cwd);
|
|
518
|
+
footerState = { ...footerState, git } as FooterState;
|
|
519
|
+
installedEditor?.setBottomLeftText("");
|
|
520
|
+
(
|
|
521
|
+
globalThis as unknown as { __footerRender?: () => void }
|
|
522
|
+
).__footerRender?.();
|
|
523
|
+
const runtime = await readRuntimeInfo(cwd);
|
|
524
|
+
footerState = { ...footerState, runtime } as FooterState;
|
|
525
|
+
(
|
|
526
|
+
globalThis as unknown as { __footerRender?: () => void }
|
|
527
|
+
).__footerRender?.();
|
|
528
|
+
} catch (_e) {
|
|
529
|
+
void _e;
|
|
530
|
+
}
|
|
531
|
+
})();
|
|
532
|
+
installedEditor?.setCursorStyle(currentConfig.cursorStyle);
|
|
533
|
+
installedEditor?.setBottomLeftText("");
|
|
534
|
+
} catch (_e) {
|
|
535
|
+
void _e;
|
|
536
|
+
}
|
|
537
|
+
} // end if (!enabled) else
|
|
538
|
+
|
|
539
|
+
// Re-arm the ownership watchdog with this session's ctx.
|
|
540
|
+
if (watchTimer !== null) {
|
|
541
|
+
clearInterval(watchTimer);
|
|
542
|
+
}
|
|
543
|
+
watchTimer = setInterval(() => ensureEditorOwnership(ctx.ui), 1000);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Teardown: pi emits session_shutdown BEFORE invalidating this runner (and
|
|
547
|
+
// before re-evaluating the module on /reload). Any timer that captured this
|
|
548
|
+
// session's ctx must be dead before then — otherwise its next tick hits the
|
|
549
|
+
// stale `ctx.ui` getter and assertActive() throws, crashing the process.
|
|
550
|
+
pi.on("session_shutdown", () => {
|
|
551
|
+
lastSessionCtx = null;
|
|
552
|
+
headerCleanupInner?.();
|
|
553
|
+
headerCleanupInner = null;
|
|
554
|
+
footerCleanup?.();
|
|
555
|
+
footerCleanup = null;
|
|
556
|
+
if (deferredInstallTimer !== null) {
|
|
557
|
+
clearTimeout(deferredInstallTimer);
|
|
558
|
+
deferredInstallTimer = null;
|
|
559
|
+
}
|
|
560
|
+
if (watchTimer !== null) {
|
|
561
|
+
clearInterval(watchTimer);
|
|
562
|
+
watchTimer = null;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// telemetry wiring — right-bottom border (live, theme-respecting)
|
|
567
|
+
pi.on("agent_start", (e) => telemetryTracker.handle(e as never));
|
|
568
|
+
pi.on("turn_start", (e) => telemetryTracker.handle(e as never));
|
|
569
|
+
pi.on("message_start", (e) => telemetryTracker.handle(e as never));
|
|
570
|
+
pi.on("message_update", (e) => telemetryTracker.handle(e as never));
|
|
571
|
+
pi.on("message_end", (e) => telemetryTracker.handle(e as never));
|
|
572
|
+
pi.on("turn_end", (e) => telemetryTracker.handle(e as never));
|
|
573
|
+
pi.on("agent_settled", (e, c) => {
|
|
574
|
+
const tel = telemetryTracker.handle(e as never);
|
|
575
|
+
if (tel && installedEditor && currentConfig.telemetry.enabled) {
|
|
576
|
+
try {
|
|
577
|
+
const themeArg = (c as unknown as { ui?: { theme?: unknown } })?.ui?.theme;
|
|
578
|
+
const text = formatTurnTelemetry(
|
|
579
|
+
tel,
|
|
580
|
+
themeArg as never,
|
|
581
|
+
currentConfig.telemetry,
|
|
582
|
+
);
|
|
583
|
+
installedEditor.setTelemetryText(text);
|
|
584
|
+
} catch (_e) {
|
|
585
|
+
void _e;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// Keep the border label/glow current when the model or thinking level changes.
|
|
591
|
+
pi.on("model_select", (_event, ctx) => {
|
|
592
|
+
if (ctx.mode !== "tui") {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
currentModelInfo = modelInfoOf(ctx);
|
|
596
|
+
lastSessionCtx = ctx;
|
|
597
|
+
installedEditor?.setModelInfo(currentModelInfo);
|
|
598
|
+
});
|
|
599
|
+
pi.on("thinking_level_select", (_event, ctx) => {
|
|
600
|
+
if (ctx.mode !== "tui") {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
currentModelInfo = modelInfoOf(ctx);
|
|
604
|
+
lastSessionCtx = ctx;
|
|
605
|
+
installedEditor?.setModelInfo(currentModelInfo);
|
|
606
|
+
});
|
|
607
|
+
}
|