@xzzpig/pi-starline 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 (45) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +223 -0
  3. package/extensions/starline/config.ts +1317 -0
  4. package/extensions/starline/editor-cursor.ts +55 -0
  5. package/extensions/starline/editor-factory-marker.ts +49 -0
  6. package/extensions/starline/editor-metadata-format.ts +259 -0
  7. package/extensions/starline/extension-status.ts +88 -0
  8. package/extensions/starline/footer-format.ts +279 -0
  9. package/extensions/starline/footer.ts +744 -0
  10. package/extensions/starline/format.ts +473 -0
  11. package/extensions/starline/git.ts +467 -0
  12. package/extensions/starline/icons.ts +294 -0
  13. package/extensions/starline/index.ts +651 -0
  14. package/extensions/starline/live-context.ts +75 -0
  15. package/extensions/starline/mouse/capabilities.ts +207 -0
  16. package/extensions/starline/mouse/component-tree.ts +230 -0
  17. package/extensions/starline/mouse/editor-caret.ts +498 -0
  18. package/extensions/starline/mouse/editor-hit-test.ts +133 -0
  19. package/extensions/starline/mouse/editor-mouse.ts +142 -0
  20. package/extensions/starline/mouse/editor-scroll.ts +119 -0
  21. package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
  22. package/extensions/starline/mouse/editor-text-edit.ts +90 -0
  23. package/extensions/starline/mouse/hint.ts +14 -0
  24. package/extensions/starline/mouse/hit-test.ts +144 -0
  25. package/extensions/starline/mouse/index.ts +1119 -0
  26. package/extensions/starline/mouse/selection-state.ts +68 -0
  27. package/extensions/starline/mouse/tool-box.ts +290 -0
  28. package/extensions/starline/mouse/transcript-copy.ts +166 -0
  29. package/extensions/starline/mouse/word-select.ts +145 -0
  30. package/extensions/starline/package-version.ts +650 -0
  31. package/extensions/starline/paste-collapse.ts +312 -0
  32. package/extensions/starline/pill-config.ts +122 -0
  33. package/extensions/starline/pill.ts +295 -0
  34. package/extensions/starline/project-refresh.ts +104 -0
  35. package/extensions/starline/project-state.ts +66 -0
  36. package/extensions/starline/prototype-patch-registry.ts +136 -0
  37. package/extensions/starline/runtime.ts +841 -0
  38. package/extensions/starline/selector-border.ts +77 -0
  39. package/extensions/starline/session-lifecycle.ts +60 -0
  40. package/extensions/starline/settings-command.ts +925 -0
  41. package/extensions/starline/state.ts +65 -0
  42. package/extensions/starline/style.ts +528 -0
  43. package/extensions/starline/ui.ts +611 -0
  44. package/extensions/starline/user-message.ts +267 -0
  45. package/package.json +60 -0
@@ -0,0 +1,651 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ KeybindingsManager,
5
+ Theme,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { type EditorTheme, type TUI, TuiAltScreen } from "@earendil-works/pi-tui";
8
+ import {
9
+ type ColorSourcesConfig,
10
+ type ContextStyle,
11
+ type ExtensionStatusColorMode,
12
+ type ExtensionStatusPlacement,
13
+ ensureConfigExists,
14
+ type FooterSegmentsConfig,
15
+ type GitBranchConfig,
16
+ type IconMode,
17
+ loadConfig,
18
+ type MouseConfig,
19
+ type PathDisplayConfig,
20
+ type PolishedTuiConfig,
21
+ type SeparatorStyle,
22
+ saveColorSourcesPatch,
23
+ saveContextStylePatch,
24
+ saveExtensionStatusColorMode,
25
+ saveExtensionStatusPlacement,
26
+ saveFooterFormatPatch,
27
+ saveFooterSegmentsPatch,
28
+ saveGitBranchPatch,
29
+ saveIconsModePatch,
30
+ saveMousePatch,
31
+ savePathDisplayPatch,
32
+ saveSeparatorPatch,
33
+ saveUiFeaturesPatch,
34
+ type UiFeaturesConfig,
35
+ } from "./config";
36
+ import {
37
+ getStarlineEditorBaseFactory,
38
+ isStarlineEditorFactory,
39
+ markEditorFactory,
40
+ } from "./editor-factory-marker";
41
+ import { installFooter } from "./footer";
42
+ import { buildSessionDurationLabel, invalidateUsageTotalsCache } from "./format";
43
+ import { emptyGitStatus, readGitHost, readGitStatus } from "./git";
44
+ import { LiveContextController } from "./live-context";
45
+ import { installMouse } from "./mouse";
46
+ import { setActiveEditor } from "./mouse/editor-mouse";
47
+ import { readPackageVersionResult } from "./package-version";
48
+ import { installPasteCollapse } from "./paste-collapse";
49
+ import {
50
+ createProjectRefreshScheduler,
51
+ type ScheduleProjectRefreshOptions,
52
+ type StopProjectRefreshInterval,
53
+ startProjectRefreshInterval,
54
+ } from "./project-refresh";
55
+ import { applyProjectRefreshToState } from "./project-state";
56
+ import { readRuntimeInfo } from "./runtime";
57
+ import { installSelectorBorderStyle } from "./selector-border";
58
+ import { SessionLifecycle } from "./session-lifecycle";
59
+ import { registerStarlineSettingsCommand } from "./settings-command";
60
+ import { createInitialState, type FooterState, syncState } from "./state";
61
+ import { PolishedEditor, WrappedPolishedEditor } from "./ui";
62
+ import { installUserMessageStyle } from "./user-message";
63
+
64
+ type EditorFactory = NonNullable<Parameters<ExtensionContext["ui"]["setEditorComponent"]>[0]>;
65
+
66
+ type ApplyUiResult = {
67
+ editorBlocked: boolean;
68
+ };
69
+
70
+ type EditorInstallMode = "none" | "standalone" | "wrapper";
71
+
72
+ function isTuiContext(ctx: ExtensionContext): boolean {
73
+ try {
74
+ const mode = (ctx as ExtensionContext & { mode?: string }).mode;
75
+ return ctx.hasUI && (mode === undefined || mode === "tui");
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ export default function (pi: ExtensionAPI) {
82
+ const state: FooterState = createInitialState(emptyGitStatus());
83
+ const sessionLifecycle = new SessionLifecycle();
84
+
85
+ let currentConfig: PolishedTuiConfig = loadConfig();
86
+ let activeTheme: Theme | undefined;
87
+ let requestFooterRender: (() => void) | undefined;
88
+ let getActiveExtensionStatuses: () => ReadonlyMap<string, string> = () => new Map();
89
+ let stopRefreshInterval: StopProjectRefreshInterval = () => {};
90
+ let cleanupPrototypePatches: () => void = () => {};
91
+ let footerInstalled = false;
92
+ let editorInstalled = false;
93
+ let editorInstallMode: EditorInstallMode = "none";
94
+ let installedEditorFactory: EditorFactory | undefined;
95
+ let wrappedEditorFactory: EditorFactory | undefined;
96
+ let prototypePatchesInstalled = false;
97
+ let stopSessionTimer: () => void = () => {};
98
+ let lastDurationLabel = "";
99
+ let lastProjectCwd: string | undefined;
100
+ let disposePasteCollapse: (() => void) | undefined;
101
+ let disposeMouse: (() => void) | undefined;
102
+
103
+ const refresh = () => {
104
+ if (sessionLifecycle.isCurrent()) requestFooterRender?.();
105
+ };
106
+ const liveContext = new LiveContextController(sessionLifecycle, refresh);
107
+ const getActiveTheme = () => activeTheme;
108
+ const getCurrentConfig = () => currentConfig;
109
+ const getThinkingLevel = () =>
110
+ sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : ("off" as const);
111
+ const syncFooterState = (ctx: ExtensionContext) =>
112
+ syncState(
113
+ state,
114
+ ctx,
115
+ currentConfig.icons.cacheHit,
116
+ currentConfig.editorModelLabel,
117
+ currentConfig.segmentOptions.tokens.cache,
118
+ );
119
+
120
+ type ProjectRefreshTarget = { cwd: string; generation: number };
121
+ const refreshProjectState = async ({ cwd, generation }: ProjectRefreshTarget) => {
122
+ if (!sessionLifecycle.isCurrent(generation)) return;
123
+ const gitCommitConfig = currentConfig.gitCommit;
124
+ const gitMetricsConfig = currentConfig.gitMetrics;
125
+ const segments = currentConfig.footerSegments;
126
+ const fmt = currentConfig.footerFormat;
127
+ // Enable optional probes when the segment is on OR a custom footerFormat
128
+ // references the relevant variable. Mirrors the session-duration timer
129
+ // pattern so format-only users still get data.
130
+ const formatNeedsTag = /\$\{?(?:git_tag|tag)\b/.test(fmt);
131
+ const formatNeedsCommit = /\$\{?(?:git_commit|commit)\b/.test(fmt);
132
+ const formatNeedsMetrics = /\$\{?(?:git_metrics|git_added|git_deleted)\b/.test(fmt);
133
+ const formatNeedsPackage = /\$\{?(?:package|package_version)\b/.test(fmt);
134
+ const wantExactTag =
135
+ ((segments.gitCommit || formatNeedsCommit) && gitCommitConfig.showTag) || formatNeedsTag;
136
+ const wantMetrics = segments.gitMetrics || formatNeedsMetrics;
137
+ const wantPackage = segments.packageVersion || formatNeedsPackage;
138
+ const [git, runtime, packageVersion, gitHost] = await Promise.all([
139
+ readGitStatus(cwd, {
140
+ readExactTag: wantExactTag,
141
+ readMetrics: wantMetrics,
142
+ ignoreSubmodules: gitMetricsConfig.ignoreSubmodules,
143
+ }),
144
+ readRuntimeInfo(cwd),
145
+ wantPackage ? readPackageVersionResult(cwd) : Promise.resolve(undefined),
146
+ currentConfig.gitHostIcon ? readGitHost(cwd) : Promise.resolve(undefined),
147
+ ]);
148
+ if (!sessionLifecycle.isCurrent(generation)) return;
149
+ lastProjectCwd = applyProjectRefreshToState(state, {
150
+ cwd,
151
+ previousCwd: lastProjectCwd,
152
+ git,
153
+ gitHost,
154
+ runtime,
155
+ packageVersion,
156
+ });
157
+ };
158
+
159
+ const projectRefreshScheduler = createProjectRefreshScheduler(refreshProjectState, refresh);
160
+ const scheduleProjectRefresh = (
161
+ ctx: ExtensionContext,
162
+ options?: ScheduleProjectRefreshOptions,
163
+ ) => {
164
+ const generation = sessionLifecycle.currentGeneration();
165
+ if (!sessionLifecycle.isCurrent(generation)) return;
166
+ const cwd = ctx.cwd;
167
+ projectRefreshScheduler.schedule({ cwd, generation }, options);
168
+ };
169
+
170
+ const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
171
+ if (!sessionLifecycle.isCurrent() || !ctx.hasUI) return;
172
+ syncFooterState(ctx);
173
+ if (project && currentConfig.features.statusLine) scheduleProjectRefresh(ctx);
174
+ refresh();
175
+ };
176
+
177
+ const stopProjectRefresh = () => {
178
+ stopRefreshInterval();
179
+ stopRefreshInterval = () => {};
180
+ projectRefreshScheduler.stop();
181
+ };
182
+
183
+ const startSessionTimer = () => {
184
+ stopSessionTimer();
185
+ lastDurationLabel = "";
186
+ const timer = setInterval(() => {
187
+ if (!sessionLifecycle.isCurrent()) return;
188
+ const segments = currentConfig.footerSegments;
189
+ const formatNeedsTimer =
190
+ currentConfig.footerFormat &&
191
+ /\$\{?(?:time|session_duration|duration)\b/.test(currentConfig.footerFormat);
192
+ if (
193
+ !(
194
+ currentConfig.features.statusLine &&
195
+ (segments.sessionDuration || segments.time || formatNeedsTimer)
196
+ )
197
+ )
198
+ return;
199
+ if (segments.time || formatNeedsTimer) {
200
+ refresh();
201
+ return;
202
+ }
203
+ const label = state.sessionStartEpoch
204
+ ? buildSessionDurationLabel(state.sessionStartEpoch)
205
+ : "";
206
+ if (label === lastDurationLabel) return;
207
+ lastDurationLabel = label;
208
+ refresh();
209
+ }, 1000);
210
+ stopSessionTimer = () => {
211
+ clearInterval(timer);
212
+ stopSessionTimer = () => {};
213
+ };
214
+ };
215
+
216
+ const installPrototypePatches = () => {
217
+ if (prototypePatchesInstalled) return;
218
+ const cleanupSelectorBorderStyle = installSelectorBorderStyle(getActiveTheme, getCurrentConfig);
219
+ const cleanupUserMessageStyle = installUserMessageStyle(getActiveTheme, getCurrentConfig);
220
+ cleanupPrototypePatches = () => {
221
+ cleanupSelectorBorderStyle();
222
+ cleanupUserMessageStyle();
223
+ };
224
+ prototypePatchesInstalled = true;
225
+ };
226
+
227
+ const uninstallPrototypePatches = () => {
228
+ cleanupPrototypePatches();
229
+ cleanupPrototypePatches = () => {};
230
+ prototypePatchesInstalled = false;
231
+ };
232
+
233
+ const uninstallMouse = () => {
234
+ disposeMouse?.();
235
+ disposeMouse = undefined;
236
+ };
237
+
238
+ /**
239
+ * Install the mouse features on `TuiAltScreen.prototype`.
240
+ *
241
+ * The prototype, not an instance: from 0.84 Pi hands extensions a Proxy over
242
+ * the live renderer and swaps the renderer itself when the TUI mode changes,
243
+ * so an instance captured at session start is not necessarily the one drawing
244
+ * later. The shared prototype survives both.
245
+ *
246
+ * Reinstalling is already safe — `installPrototypePatch` keeps one wrapper per
247
+ * adapter and only swaps the behaviour behind it — but disposing first keeps
248
+ * exactly one live registration, so the disposer held here always removes
249
+ * everything this extension put on the prototype.
250
+ *
251
+ * `mouse.enabled` is read here rather than captured, and every sub-option is
252
+ * read inside the patches themselves, so `/starline` toggles apply without a
253
+ * restart.
254
+ */
255
+ const installMousePatches = () => {
256
+ uninstallMouse();
257
+ if (!getCurrentConfig().mouse.enabled) return;
258
+ // Repaints are not wired from here: each patch asks its own receiver —
259
+ // the live renderer it is running inside — to render. Handing it the
260
+ // extension's `refresh` instead tied the pending hint to the footer's
261
+ // existence, and the hint is in the editor.
262
+ disposeMouse = installMouse(TuiAltScreen.prototype, { getConfig: getCurrentConfig });
263
+ };
264
+
265
+ /**
266
+ * `editorCursor: "terminal"` hides the software cursor so the real one shows
267
+ * through, which needs the hardware cursor to actually be on. Pi re-applies
268
+ * its own setting at several points, so an extension cannot turn it on
269
+ * reliably everywhere — set it here for the user asking for the terminal
270
+ * cursor, which is exactly the intent. Nothing is touched in other modes.
271
+ */
272
+ const syncHardwareCursor = (tui: TUI) => {
273
+ if (getCurrentConfig().editorCursor !== "terminal") return;
274
+ try {
275
+ (tui as TUI & { setShowHardwareCursor?: (on: boolean) => void }).setShowHardwareCursor?.(
276
+ true,
277
+ );
278
+ } catch {
279
+ // Older Pi builds may not expose it; the software cursor stays hidden either way.
280
+ }
281
+ };
282
+
283
+ /**
284
+ * Lower the paste-collapse threshold on a freshly built editor. Patching the
285
+ * instance rather than subclassing covers the wrapped path too, where the
286
+ * base editor is somebody else's. A no-op when the editor does not expose
287
+ * what it needs, or when the option is left at Pi's own threshold.
288
+ */
289
+ const applyPasteCollapse = (editor: unknown) => {
290
+ disposePasteCollapse?.();
291
+ disposePasteCollapse = installPasteCollapse(
292
+ editor,
293
+ () => getCurrentConfig().pasteCollapseLines,
294
+ );
295
+ };
296
+
297
+ const makeEditorFactory = (ctx: ExtensionContext): EditorFactory => {
298
+ const sessionTheme = ctx.ui.theme;
299
+ const factory = ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
300
+ syncHardwareCursor(tui);
301
+ const editor = new PolishedEditor(
302
+ tui,
303
+ theme,
304
+ keybindings,
305
+ sessionTheme,
306
+ getCurrentConfig,
307
+ () => ({
308
+ modelLabel: state.modelLabel,
309
+ modelId: state.modelId,
310
+ modelName: state.modelName,
311
+ providerLabel: state.providerLabel,
312
+ sessionName: ctx.sessionManager.getSessionName() ?? "",
313
+ }),
314
+ getThinkingLevel,
315
+ );
316
+ applyPasteCollapse(editor);
317
+ // Nothing on Pi's renderer points at the live editor, and the
318
+ // extension API hands out the factory rather than what it built — so
319
+ // the mouse patches learn about it here, from the one place in the
320
+ // process that has it. See `mouse/editor-mouse.ts`.
321
+ setActiveEditor({ component: editor, scrollable: editor });
322
+ return editor;
323
+ }) as EditorFactory;
324
+ return markEditorFactory(factory);
325
+ };
326
+
327
+ const makeWrappedEditorFactory = (
328
+ ctx: ExtensionContext,
329
+ baseFactory: EditorFactory,
330
+ ): EditorFactory => {
331
+ const sessionTheme = ctx.ui.theme;
332
+ const factory = ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
333
+ syncHardwareCursor(tui);
334
+ const base = baseFactory(tui, theme, keybindings);
335
+ applyPasteCollapse(base);
336
+ const wrapped = new WrappedPolishedEditor(
337
+ base,
338
+ sessionTheme,
339
+ getCurrentConfig,
340
+ () => ({
341
+ modelLabel: state.modelLabel,
342
+ modelId: state.modelId,
343
+ modelName: state.modelName,
344
+ providerLabel: state.providerLabel,
345
+ sessionName: ctx.sessionManager.getSessionName() ?? "",
346
+ }),
347
+ getThinkingLevel,
348
+ );
349
+ // Two references, because these are two different objects here: the
350
+ // wrapper is what gets mounted and hit-tested, while the draft's
351
+ // visual lines live on the base editor it delegates to.
352
+ setActiveEditor({ component: wrapped, scrollable: base });
353
+ return wrapped;
354
+ }) as EditorFactory;
355
+ return markEditorFactory(factory, baseFactory);
356
+ };
357
+
358
+ const installEditor = (ctx: ExtensionContext): boolean => {
359
+ const currentFactory = ctx.ui.getEditorComponent();
360
+ if (currentFactory && currentFactory === installedEditorFactory) {
361
+ editorInstalled = true;
362
+ return true;
363
+ }
364
+
365
+ installPrototypePatches();
366
+ const currentStarlineBaseFactory = getStarlineEditorBaseFactory<EditorFactory>(currentFactory);
367
+ if (currentFactory && isStarlineEditorFactory(currentFactory)) {
368
+ wrappedEditorFactory = currentStarlineBaseFactory;
369
+ const nextFactory = currentStarlineBaseFactory
370
+ ? makeWrappedEditorFactory(ctx, currentStarlineBaseFactory)
371
+ : makeEditorFactory(ctx);
372
+ ctx.ui.setEditorComponent(nextFactory);
373
+ installedEditorFactory = nextFactory;
374
+ editorInstallMode = currentStarlineBaseFactory ? "wrapper" : "standalone";
375
+ } else if (currentFactory) {
376
+ wrappedEditorFactory = currentFactory;
377
+ const nextFactory = makeWrappedEditorFactory(ctx, currentFactory);
378
+ ctx.ui.setEditorComponent(nextFactory);
379
+ installedEditorFactory = nextFactory;
380
+ editorInstallMode = "wrapper";
381
+ } else {
382
+ wrappedEditorFactory = undefined;
383
+ const nextFactory = makeEditorFactory(ctx);
384
+ ctx.ui.setEditorComponent(nextFactory);
385
+ installedEditorFactory = nextFactory;
386
+ editorInstallMode = "standalone";
387
+ }
388
+ editorInstalled = true;
389
+ return true;
390
+ };
391
+
392
+ const uninstallEditor = (ctx: ExtensionContext): boolean => {
393
+ const currentFactory = ctx.ui.getEditorComponent();
394
+ if (currentFactory && !isStarlineEditorFactory(currentFactory)) return false;
395
+
396
+ uninstallPrototypePatches();
397
+ ctx.ui.setEditorComponent(
398
+ editorInstallMode === "wrapper" && wrappedEditorFactory ? wrappedEditorFactory : undefined,
399
+ );
400
+ wrappedEditorFactory = undefined;
401
+ installedEditorFactory = undefined;
402
+ editorInstallMode = "none";
403
+ editorInstalled = false;
404
+ // Pi rebuilds the editor from whatever factory is now in place, and that
405
+ // one is not ours. Leaving the old instance registered would point the
406
+ // wheel patch at an editor no longer on screen.
407
+ setActiveEditor(undefined);
408
+ return true;
409
+ };
410
+
411
+ const installStatusLine = (ctx: ExtensionContext) => {
412
+ if (footerInstalled) return;
413
+ installFooter(ctx, state, getCurrentConfig, {
414
+ setRequestRender: (fn) => {
415
+ requestFooterRender = fn;
416
+ },
417
+ scheduleProjectRefresh,
418
+ setExtensionStatusesGetter(fn) {
419
+ getActiveExtensionStatuses = fn ?? (() => new Map());
420
+ },
421
+ getLiveContext: () => liveContext.get(),
422
+ getThinkingLevel,
423
+ });
424
+ footerInstalled = true;
425
+ stopProjectRefresh();
426
+ stopRefreshInterval = startProjectRefreshInterval(currentConfig.projectRefreshIntervalMs, () =>
427
+ scheduleProjectRefresh(ctx),
428
+ );
429
+ scheduleProjectRefresh(ctx, { force: true });
430
+ refresh();
431
+ startSessionTimer();
432
+ };
433
+
434
+ const uninstallStatusLine = (ctx: ExtensionContext) => {
435
+ stopSessionTimer();
436
+ stopProjectRefresh();
437
+ ctx.ui.setFooter(undefined);
438
+ footerInstalled = false;
439
+ requestFooterRender = undefined;
440
+ getActiveExtensionStatuses = () => new Map();
441
+ };
442
+
443
+ const applyConfiguredUi = (ctx: ExtensionContext): ApplyUiResult => {
444
+ const result: ApplyUiResult = { editorBlocked: false };
445
+ if (!isTuiContext(ctx)) return result;
446
+ activeTheme = ctx.ui.theme;
447
+ if (currentConfig.features.editor) {
448
+ const currentFactory = ctx.ui.getEditorComponent();
449
+ const editorMissingOrReplaced = !editorInstalled || !isStarlineEditorFactory(currentFactory);
450
+ if (editorMissingOrReplaced) result.editorBlocked = !installEditor(ctx);
451
+ } else if (editorInstalled || prototypePatchesInstalled) {
452
+ result.editorBlocked = !uninstallEditor(ctx);
453
+ }
454
+
455
+ if (currentConfig.features.statusLine) {
456
+ installStatusLine(ctx);
457
+ } else if (footerInstalled) {
458
+ uninstallStatusLine(ctx);
459
+ }
460
+ return result;
461
+ };
462
+
463
+ const installUi = (ctx: ExtensionContext) => {
464
+ if (!isTuiContext(ctx)) return;
465
+ activeTheme = ctx.ui.theme;
466
+ uninstallPrototypePatches();
467
+ footerInstalled = false;
468
+ editorInstalled = false;
469
+ installedEditorFactory = undefined;
470
+ ensureConfigExists();
471
+ currentConfig = loadConfig();
472
+ syncFooterState(ctx);
473
+ stopProjectRefresh();
474
+ applyConfiguredUi(ctx);
475
+ installMousePatches();
476
+ refresh();
477
+ };
478
+
479
+ const scheduleEditorReconciliation = (ctx: ExtensionContext) => {
480
+ sessionLifecycle.defer(() => {
481
+ if (!isTuiContext(ctx) || !currentConfig.features.editor) return;
482
+ const currentFactory = ctx.ui.getEditorComponent();
483
+ if (currentFactory && currentFactory !== installedEditorFactory) {
484
+ applyConfiguredUi(ctx);
485
+ refresh();
486
+ }
487
+ });
488
+ };
489
+
490
+ const cleanupUi = (ctx?: ExtensionContext) => {
491
+ if (!ctx || !sessionLifecycle.isCurrent()) return;
492
+ sessionLifecycle.shutdown();
493
+ try {
494
+ uninstallMouse();
495
+ uninstallPrototypePatches();
496
+ stopSessionTimer();
497
+ stopProjectRefresh();
498
+ requestFooterRender = undefined;
499
+ getActiveExtensionStatuses = () => new Map();
500
+ if (isTuiContext(ctx)) {
501
+ ctx.ui.setFooter(undefined);
502
+ const currentFactory = ctx.ui.getEditorComponent();
503
+ if (!currentFactory || isStarlineEditorFactory(currentFactory)) {
504
+ ctx.ui.setEditorComponent(
505
+ getStarlineEditorBaseFactory<EditorFactory>(currentFactory) ??
506
+ (editorInstallMode === "wrapper" && wrappedEditorFactory
507
+ ? wrappedEditorFactory
508
+ : undefined),
509
+ );
510
+ }
511
+ }
512
+ wrappedEditorFactory = undefined;
513
+ installedEditorFactory = undefined;
514
+ editorInstallMode = "none";
515
+ footerInstalled = false;
516
+ editorInstalled = false;
517
+ activeTheme = undefined;
518
+ } finally {
519
+ requestFooterRender = undefined;
520
+ }
521
+ };
522
+
523
+ const syncInteractiveState = (_event: unknown, ctx: ExtensionContext) => {
524
+ refreshInteractiveState(ctx);
525
+ };
526
+ const syncInteractiveAndProjectState = (_event: unknown, ctx: ExtensionContext) => {
527
+ refreshInteractiveState(ctx, true);
528
+ };
529
+
530
+ pi.on("session_start", async (_event, ctx) => {
531
+ sessionLifecycle.start();
532
+ liveContext.clear();
533
+ state.sessionStartEpoch = Date.now();
534
+ invalidateUsageTotalsCache();
535
+ lastProjectCwd = undefined;
536
+ installUi(ctx);
537
+ scheduleEditorReconciliation(ctx);
538
+ });
539
+
540
+ registerStarlineSettingsCommand(pi, {
541
+ sessionLifecycle,
542
+ getConfig: getCurrentConfig,
543
+ setColorSources(patch: Partial<ColorSourcesConfig>) {
544
+ currentConfig = saveColorSourcesPatch(patch);
545
+ },
546
+ setUiFeatures(patch: Partial<UiFeaturesConfig>, ctx: ExtensionContext) {
547
+ currentConfig = saveUiFeaturesPatch(patch);
548
+ const result = applyConfiguredUi(ctx);
549
+ return {
550
+ applied: !(patch.editor !== undefined && result.editorBlocked),
551
+ reason: result.editorBlocked
552
+ ? "another extension is currently managing the editor; reload Pi to apply this change"
553
+ : undefined,
554
+ };
555
+ },
556
+ setFooterSegments(patch: Partial<FooterSegmentsConfig>) {
557
+ currentConfig = saveFooterSegmentsPatch(patch);
558
+ },
559
+ setFooterFormat(value: string) {
560
+ currentConfig = saveFooterFormatPatch(value);
561
+ },
562
+ setIconMode(mode: IconMode) {
563
+ currentConfig = saveIconsModePatch(mode);
564
+ },
565
+ setContextStyle(style: ContextStyle) {
566
+ currentConfig = saveContextStylePatch(style);
567
+ },
568
+ setSeparator(separator: SeparatorStyle) {
569
+ currentConfig = saveSeparatorPatch(separator);
570
+ },
571
+ setPathDisplay(patch: Partial<PathDisplayConfig>) {
572
+ currentConfig = savePathDisplayPatch(patch);
573
+ },
574
+ setGitBranch(patch: Partial<GitBranchConfig>) {
575
+ currentConfig = saveGitBranchPatch(patch);
576
+ },
577
+ getActiveExtensionStatuses() {
578
+ return getActiveExtensionStatuses();
579
+ },
580
+ setExtensionStatusPlacement(key: string, placement: ExtensionStatusPlacement) {
581
+ currentConfig = saveExtensionStatusPlacement(key, placement);
582
+ },
583
+ setExtensionStatusColorMode(key: string, colorMode: ExtensionStatusColorMode) {
584
+ currentConfig = saveExtensionStatusColorMode(key, colorMode);
585
+ },
586
+ setMouseConfig(patch: Partial<MouseConfig>, _ctx: ExtensionContext) {
587
+ currentConfig = saveMousePatch(patch);
588
+ if (patch.enabled === true) {
589
+ installMousePatches();
590
+ } else if (patch.enabled === false) {
591
+ uninstallMouse();
592
+ }
593
+ refresh();
594
+ },
595
+ requestRender() {
596
+ refresh();
597
+ },
598
+ });
599
+
600
+ pi.on("session_shutdown", async (_event, ctx) => {
601
+ liveContext.clear();
602
+ cleanupUi(ctx);
603
+ });
604
+
605
+ const syncInteractiveAndProjectStateWithUsage = (_event: unknown, ctx: ExtensionContext) => {
606
+ invalidateUsageTotalsCache();
607
+ refreshInteractiveState(ctx, true);
608
+ };
609
+
610
+ pi.on("agent_start", (event, ctx) => {
611
+ liveContext.clear();
612
+ syncInteractiveState(event, ctx);
613
+ });
614
+ pi.on("agent_end", (event, ctx) => {
615
+ liveContext.clear();
616
+ syncInteractiveAndProjectState(event, ctx);
617
+ });
618
+ pi.on("model_select", (event, ctx) => {
619
+ liveContext.clear();
620
+ syncInteractiveState(event, ctx);
621
+ });
622
+ pi.on("thinking_level_select", syncInteractiveState);
623
+ pi.on("session_info_changed", syncInteractiveState);
624
+ pi.on("message_update", (event) => {
625
+ liveContext.update(event.message);
626
+ });
627
+ pi.on("message_end", (event, ctx) => {
628
+ // Pi notifies extensions before persisting a successful message, so retain its live
629
+ // context until agent_end; failed messages clear immediately instead of showing stale usage.
630
+ if (
631
+ event.message.role === "assistant" &&
632
+ (event.message.stopReason === "error" || event.message.stopReason === "aborted")
633
+ ) {
634
+ liveContext.clear();
635
+ }
636
+ syncInteractiveAndProjectStateWithUsage(event, ctx);
637
+ });
638
+ pi.on("tool_execution_start", (event, ctx) => {
639
+ liveContext.clear();
640
+ syncInteractiveState(event, ctx);
641
+ });
642
+ pi.on("tool_execution_end", syncInteractiveAndProjectState);
643
+ pi.on("session_compact", (event, ctx) => {
644
+ liveContext.clear();
645
+ syncInteractiveAndProjectStateWithUsage(event, ctx);
646
+ });
647
+ pi.on("session_tree", (event, ctx) => {
648
+ liveContext.clear();
649
+ syncInteractiveAndProjectStateWithUsage(event, ctx);
650
+ });
651
+ }