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
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
+ }