@xzzpig/pi-starline 0.5.1 → 0.7.1

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 (50) hide show
  1. package/FORK-CHANGELOG.md +19 -0
  2. package/README.md +68 -181
  3. package/extensions/starline/config.ts +156 -326
  4. package/extensions/starline/context-live.ts +33 -0
  5. package/extensions/starline/editor-cursor.ts +3 -0
  6. package/extensions/starline/extension-status.ts +9 -5
  7. package/extensions/starline/footer.ts +331 -528
  8. package/extensions/starline/fork-editor-cursor.ts +14 -0
  9. package/extensions/starline/format.ts +151 -20
  10. package/extensions/starline/git-live.ts +20 -0
  11. package/extensions/starline/git-probe.ts +40 -0
  12. package/extensions/starline/git-segments.ts +175 -0
  13. package/extensions/starline/git.ts +71 -0
  14. package/extensions/starline/icons.ts +17 -0
  15. package/extensions/starline/index.ts +188 -91
  16. package/extensions/starline/live-session.ts +164 -0
  17. package/extensions/starline/live-tps.ts +165 -0
  18. package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
  19. package/extensions/starline/mouse/cursor-marker.ts +69 -0
  20. package/extensions/starline/mouse/editor-hit-test.ts +6 -3
  21. package/extensions/starline/mouse/external-layer.ts +42 -0
  22. package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
  23. package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
  24. package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
  25. package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
  26. package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
  27. package/extensions/starline/mouse/hint.ts +4 -4
  28. package/extensions/starline/mouse/index.ts +54 -24
  29. package/extensions/starline/mouse/pointer-events.ts +73 -0
  30. package/extensions/starline/mouse/pointer-shape.ts +138 -0
  31. package/extensions/starline/mouse/transcript-copy.ts +76 -33
  32. package/extensions/starline/paste-collapse.ts +52 -13
  33. package/extensions/starline/pill-actions.ts +590 -0
  34. package/extensions/starline/pill-anchors.ts +26 -0
  35. package/extensions/starline/pill-api.ts +80 -0
  36. package/extensions/starline/pill-bubble.ts +607 -0
  37. package/extensions/starline/pill-config.ts +67 -9
  38. package/extensions/starline/pill.ts +102 -20
  39. package/extensions/starline/project-probe.ts +79 -0
  40. package/extensions/starline/prototype-patch-registry.ts +6 -1
  41. package/extensions/starline/queue-rows.ts +43 -0
  42. package/extensions/starline/settings-command.ts +124 -177
  43. package/extensions/starline/subagent-fleet.ts +246 -0
  44. package/extensions/starline/turn-clock.ts +81 -0
  45. package/extensions/starline/ui.ts +297 -156
  46. package/extensions/starline/working-indicator.ts +189 -0
  47. package/package.json +6 -5
  48. package/types/pill-api.d.ts +115 -0
  49. package/extensions/starline/editor-metadata-format.ts +0 -259
  50. package/extensions/starline/footer-format.ts +0 -279
@@ -11,42 +11,51 @@ import {
11
11
  type ExtensionStatusColorMode,
12
12
  type ExtensionStatusPlacement,
13
13
  ensureConfigExists,
14
- type FooterSegmentsConfig,
15
14
  type GitBranchConfig,
16
15
  type IconMode,
17
16
  loadConfig,
18
17
  type MouseConfig,
19
18
  type PathDisplayConfig,
20
19
  type PolishedTuiConfig,
21
- type SeparatorStyle,
22
20
  saveColorSourcesPatch,
23
21
  saveContextStylePatch,
24
22
  saveExtensionStatusColorMode,
25
23
  saveExtensionStatusPlacement,
26
- saveFooterFormatPatch,
27
- saveFooterSegmentsPatch,
28
24
  saveGitBranchPatch,
29
25
  saveIconsModePatch,
30
26
  saveMousePatch,
31
27
  savePathDisplayPatch,
32
- saveSeparatorPatch,
28
+ savePillSegmentsPatch,
33
29
  saveUiFeaturesPatch,
34
30
  type UiFeaturesConfig,
35
31
  } from "./config";
32
+ import { setActiveContextSnapshot } from "./context-live";
36
33
  import {
37
34
  getStarlineEditorBaseFactory,
38
35
  isStarlineEditorFactory,
39
36
  markEditorFactory,
40
37
  } from "./editor-factory-marker";
41
38
  import { installFooter } from "./footer";
42
- import { buildSessionDurationLabel, invalidateUsageTotalsCache } from "./format";
43
- import { emptyGitStatus, readGitHost, readGitStatus } from "./git";
39
+ import {
40
+ buildSessionDurationLabel,
41
+ compactionFailureNotice,
42
+ invalidateUsageTotalsCache,
43
+ resolveSessionStartEpoch,
44
+ } from "./format";
45
+ import { emptyGitStatus } from "./git";
46
+ import { setActiveGitStatus } from "./git-live";
44
47
  import { LiveContextController } from "./live-context";
48
+ import { forgetLiveSession, installLiveSessionCapture } from "./live-session";
49
+ import { LiveTpsController } from "./live-tps";
45
50
  import { installMouseFeaturesOn } from "./mouse";
46
51
  import { getMouseEventsApi } from "./mouse/api-consumer";
47
52
  import { setActiveEditor } from "./mouse/editor-mouse";
48
- import { readPackageVersionResult } from "./package-version";
53
+ import { installSharedPillMouse } from "./mouse/fork-pill-mouse";
49
54
  import { installPasteCollapse } from "./paste-collapse";
55
+ import { setModelSwitcher } from "./pill-actions";
56
+ import { publishPillApi } from "./pill-api";
57
+ import { setPillBubbleAnimation } from "./pill-bubble";
58
+ import { probeProject } from "./project-probe";
50
59
  import {
51
60
  createProjectRefreshScheduler,
52
61
  type ScheduleProjectRefreshOptions,
@@ -54,13 +63,22 @@ import {
54
63
  startProjectRefreshInterval,
55
64
  } from "./project-refresh";
56
65
  import { applyProjectRefreshToState } from "./project-state";
57
- import { readRuntimeInfo } from "./runtime";
66
+ import { installNativeQueueRowHiding } from "./queue-rows";
58
67
  import { installSelectorBorderStyle } from "./selector-border";
59
68
  import { SessionLifecycle } from "./session-lifecycle";
60
69
  import { registerStarlineSettingsCommand } from "./settings-command";
61
70
  import { createInitialState, type FooterState, syncState } from "./state";
71
+ import {
72
+ createSubagentFleet,
73
+ type EventBusLike,
74
+ getActiveSubagentFleet,
75
+ SUBAGENT_TOOL_NAME,
76
+ setActiveSubagentFleet,
77
+ } from "./subagent-fleet";
78
+ import { TurnClock } from "./turn-clock";
62
79
  import { PolishedEditor, WrappedPolishedEditor } from "./ui";
63
80
  import { installUserMessageStyle } from "./user-message";
81
+ import { setWorkingIndicator } from "./working-indicator";
64
82
 
65
83
  type EditorFactory = NonNullable<Parameters<ExtensionContext["ui"]["setEditorComponent"]>[0]>;
66
84
 
@@ -79,8 +97,21 @@ function isTuiContext(ctx: ExtensionContext): boolean {
79
97
  }
80
98
  }
81
99
 
100
+ /**
101
+ * How long a pill's bubble takes to unfold from the pill: long enough to read as
102
+ * growing out of it, short enough never to be waited on.
103
+ */
104
+ const BUBBLE_ANIMATION_MS = 60;
105
+
82
106
  export default function (pi: ExtensionAPI) {
107
+ // Published before anything else so a bridge that registers in `session_start`
108
+ // finds it, whichever order the extensions were loaded in.
109
+ publishPillApi();
110
+
83
111
  const state: FooterState = createInitialState(emptyGitStatus());
112
+ // The git pill's bubble reads the same object the footer draws from, so a
113
+ // click costs no extra \`git status\`.
114
+ setActiveGitStatus(state);
84
115
  const sessionLifecycle = new SessionLifecycle();
85
116
 
86
117
  let currentConfig: PolishedTuiConfig = loadConfig();
@@ -101,13 +132,29 @@ export default function (pi: ExtensionAPI) {
101
132
  let disposePasteCollapse: (() => void) | undefined;
102
133
  let disposeMouse: (() => void) | undefined;
103
134
  let lastTuiCtx: ExtensionContext | undefined;
135
+ let cleanupSessionCapture: (() => void) | undefined;
136
+ let cleanupQueueRows: (() => void) | undefined;
104
137
 
105
138
  const refresh = () => {
106
139
  if (sessionLifecycle.isCurrent()) requestFooterRender?.();
107
140
  };
108
141
  const liveContext = new LiveContextController(sessionLifecycle, refresh);
142
+ const liveTps = new LiveTpsController();
143
+ const turnClock = new TurnClock();
109
144
  const getActiveTheme = () => activeTheme;
110
145
  const getCurrentConfig = () => currentConfig;
146
+ /**
147
+ * The session's own header, or nothing. Read through a guard because this runs on
148
+ * whatever `sessionManager` the running Pi build provides — the header is where a
149
+ * resumed session keeps its original start time.
150
+ */
151
+ const readSessionHeader = (ctx: ExtensionContext): { timestamp?: string } | undefined => {
152
+ try {
153
+ return ctx.sessionManager.getHeader() ?? undefined;
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ };
111
158
  const getThinkingLevel = () =>
112
159
  sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : ("off" as const);
113
160
  const syncFooterState = (ctx: ExtensionContext) =>
@@ -122,39 +169,15 @@ export default function (pi: ExtensionAPI) {
122
169
  type ProjectRefreshTarget = { cwd: string; generation: number };
123
170
  const refreshProjectState = async ({ cwd, generation }: ProjectRefreshTarget) => {
124
171
  if (!sessionLifecycle.isCurrent(generation)) return;
125
- const gitCommitConfig = currentConfig.gitCommit;
126
- const gitMetricsConfig = currentConfig.gitMetrics;
127
- const segments = currentConfig.footerSegments;
128
- const fmt = currentConfig.footerFormat;
129
- // Enable optional probes when the segment is on OR a custom footerFormat
130
- // references the relevant variable. Mirrors the session-duration timer
131
- // pattern so format-only users still get data.
132
- const formatNeedsTag = /\$\{?(?:git_tag|tag)\b/.test(fmt);
133
- const formatNeedsCommit = /\$\{?(?:git_commit|commit)\b/.test(fmt);
134
- const formatNeedsMetrics = /\$\{?(?:git_metrics|git_added|git_deleted)\b/.test(fmt);
135
- const formatNeedsPackage = /\$\{?(?:package|package_version)\b/.test(fmt);
136
- const wantExactTag =
137
- ((segments.gitCommit || formatNeedsCommit) && gitCommitConfig.showTag) || formatNeedsTag;
138
- const wantMetrics = segments.gitMetrics || formatNeedsMetrics;
139
- const wantPackage = segments.packageVersion || formatNeedsPackage;
140
- const [git, runtime, packageVersion, gitHost] = await Promise.all([
141
- readGitStatus(cwd, {
142
- readExactTag: wantExactTag,
143
- readMetrics: wantMetrics,
144
- ignoreSubmodules: gitMetricsConfig.ignoreSubmodules,
145
- }),
146
- readRuntimeInfo(cwd),
147
- wantPackage ? readPackageVersionResult(cwd) : Promise.resolve(undefined),
148
- currentConfig.gitHostIcon ? readGitHost(cwd) : Promise.resolve(undefined),
149
- ]);
172
+ // A bar with no git pill reads no git: `probeProject` answers an empty
173
+ // status rather than starting `git status` and `git stash list` every
174
+ // interval.
175
+ const probe = await probeProject(cwd, currentConfig);
150
176
  if (!sessionLifecycle.isCurrent(generation)) return;
151
177
  lastProjectCwd = applyProjectRefreshToState(state, {
152
178
  cwd,
153
179
  previousCwd: lastProjectCwd,
154
- git,
155
- gitHost,
156
- runtime,
157
- packageVersion,
180
+ ...probe,
158
181
  });
159
182
  };
160
183
 
@@ -187,18 +210,11 @@ export default function (pi: ExtensionAPI) {
187
210
  lastDurationLabel = "";
188
211
  const timer = setInterval(() => {
189
212
  if (!sessionLifecycle.isCurrent()) return;
190
- const segments = currentConfig.footerSegments;
191
- const formatNeedsTimer =
192
- currentConfig.footerFormat &&
193
- /\$\{?(?:time|session_duration|duration)\b/.test(currentConfig.footerFormat);
194
- if (
195
- !(
196
- currentConfig.features.statusLine &&
197
- (segments.sessionDuration || segments.time || formatNeedsTimer)
198
- )
199
- )
213
+ const shown = currentConfig.pill.segments;
214
+ const wantsTime = shown.includes("time");
215
+ if (!(currentConfig.features.statusLine && (wantsTime || shown.includes("sessionDuration"))))
200
216
  return;
201
- if (segments.time || formatNeedsTimer) {
217
+ if (wantsTime) {
202
218
  refresh();
203
219
  return;
204
220
  }
@@ -237,6 +253,73 @@ export default function (pi: ExtensionAPI) {
237
253
  disposeMouse = undefined;
238
254
  };
239
255
 
256
+ /**
257
+ * Capture the live `AgentSession` so the queue bubble can list what is waiting.
258
+ * The context only carries a boolean; the messages are on the session, and one
259
+ * wrapper on its `prompt` is enough to hold the instance. See `live-session.ts`.
260
+ */
261
+ const installSessionCapture = () => {
262
+ cleanupSessionCapture?.();
263
+ cleanupSessionCapture = installLiveSessionCapture();
264
+ };
265
+
266
+ const uninstallSessionCapture = () => {
267
+ cleanupSessionCapture?.();
268
+ cleanupSessionCapture = undefined;
269
+ forgetLiveSession();
270
+ };
271
+
272
+ /**
273
+ * Pi's own `Steering:` rows above the editor are the same information the queue
274
+ * pill carries, so they are hidden exactly while the pill is on screen. The
275
+ * predicate is read on every queue update, so `/starline` changes apply without
276
+ * a restart.
277
+ */
278
+ const queuePillCarriesTheQueue = () =>
279
+ currentConfig.features.statusLine && currentConfig.pill.segments.includes("queue");
280
+
281
+ const installQueueRowHiding = () => {
282
+ cleanupQueueRows?.();
283
+ cleanupQueueRows = installNativeQueueRowHiding(queuePillCarriesTheQueue);
284
+ };
285
+
286
+ const uninstallQueueRowHiding = () => {
287
+ cleanupQueueRows?.();
288
+ cleanupQueueRows = undefined;
289
+ };
290
+
291
+ /**
292
+ * Follow what pi-subagents is running, for the `subagents` pill. Its public RPC
293
+ * answers on `pi.events`; without the package nothing answers and the pill
294
+ * stays off. One request now picks up runs restored with the session.
295
+ */
296
+ const installSubagentFleet = () => {
297
+ const bus = (pi as { events?: EventBusLike }).events;
298
+ if (!bus) return;
299
+ const fleet = createSubagentFleet(bus, { onChange: refresh });
300
+ setActiveSubagentFleet(fleet);
301
+ fleet.refresh();
302
+ };
303
+
304
+ /**
305
+ * The model pill's picker switches through `pi`, which actions do not get.
306
+ * A scoped model that pins a thinking level brings it along, as ctrl+p does.
307
+ */
308
+ const installModelSwitcher = () => {
309
+ setModelSwitcher(async ({ model, thinkingLevel }) => {
310
+ const switched = await pi.setModel(model as Parameters<typeof pi.setModel>[0]);
311
+ if (switched && thinkingLevel) {
312
+ pi.setThinkingLevel(thinkingLevel as Parameters<typeof pi.setThinkingLevel>[0]);
313
+ }
314
+ return switched;
315
+ });
316
+ };
317
+
318
+ /** A foreground run starts with no event of its own, only the tool call. */
319
+ const refreshFleetForTool = (event: { toolName?: string }) => {
320
+ if (event.toolName === SUBAGENT_TOOL_NAME) getActiveSubagentFleet()?.refresh();
321
+ };
322
+
240
323
  /**
241
324
  * Register Starline's mouse features with the `pi-mouse-events` extension.
242
325
  *
@@ -260,7 +343,12 @@ export default function (pi: ExtensionAPI) {
260
343
  warnMouseApiMissing();
261
344
  return;
262
345
  }
263
- disposeMouse = installMouseFeaturesOn(api, ctx, { getConfig: getCurrentConfig });
346
+ const disposeFeatures = installMouseFeaturesOn(api, ctx, { getConfig: getCurrentConfig });
347
+ const disposePillMouse = installSharedPillMouse(api);
348
+ disposeMouse = () => {
349
+ disposePillMouse();
350
+ disposeFeatures();
351
+ };
264
352
  };
265
353
 
266
354
  let mouseApiWarned = false;
@@ -309,22 +397,7 @@ export default function (pi: ExtensionAPI) {
309
397
  const sessionTheme = ctx.ui.theme;
310
398
  const factory = ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
311
399
  syncHardwareCursor(tui);
312
- const editor = new PolishedEditor(
313
- tui,
314
- theme,
315
- keybindings,
316
- sessionTheme,
317
- getCurrentConfig,
318
- () => ({
319
- modelLabel: state.modelLabel,
320
- modelId: state.modelId,
321
- modelName: state.modelName,
322
- providerLabel: state.providerLabel,
323
- sessionName: ctx.sessionManager.getSessionName() ?? "",
324
- }),
325
- getThinkingLevel,
326
- () => getActiveExtensionStatuses(),
327
- );
400
+ const editor = new PolishedEditor(tui, theme, keybindings, sessionTheme, getCurrentConfig);
328
401
  applyPasteCollapse(editor);
329
402
  // Nothing on Pi's renderer points at the live editor, and the
330
403
  // extension API hands out the factory rather than what it built — so
@@ -345,20 +418,7 @@ export default function (pi: ExtensionAPI) {
345
418
  syncHardwareCursor(tui);
346
419
  const base = baseFactory(tui, theme, keybindings);
347
420
  applyPasteCollapse(base);
348
- const wrapped = new WrappedPolishedEditor(
349
- base,
350
- sessionTheme,
351
- getCurrentConfig,
352
- () => ({
353
- modelLabel: state.modelLabel,
354
- modelId: state.modelId,
355
- modelName: state.modelName,
356
- providerLabel: state.providerLabel,
357
- sessionName: ctx.sessionManager.getSessionName() ?? "",
358
- }),
359
- getThinkingLevel,
360
- () => getActiveExtensionStatuses(),
361
- );
421
+ const wrapped = new WrappedPolishedEditor(base, sessionTheme, getCurrentConfig);
362
422
  // Two references, because these are two different objects here: the
363
423
  // wrapper is what gets mounted and hit-tested, while the draft's
364
424
  // visual lines live on the base editor it delegates to.
@@ -414,6 +474,10 @@ export default function (pi: ExtensionAPI) {
414
474
  installedEditorFactory = undefined;
415
475
  editorInstallMode = "none";
416
476
  editorInstalled = false;
477
+ // Pi only clears the indicator it handed to the editor it is replacing, and
478
+ // this editor is on its way out; leaving the stale reference behind would
479
+ // keep a spinner in the footer forever.
480
+ setWorkingIndicator(undefined);
417
481
  // Pi rebuilds the editor from whatever factory is now in place, and that
418
482
  // one is not ours. Leaving the old instance registered would point the
419
483
  // wheel patch at an editor no longer on screen.
@@ -432,6 +496,8 @@ export default function (pi: ExtensionAPI) {
432
496
  getActiveExtensionStatuses = fn ?? (() => new Map());
433
497
  },
434
498
  getLiveContext: () => liveContext.get(),
499
+ getLiveTps: () => liveTps.value(),
500
+ getTurnTiming: () => turnClock.timing(),
435
501
  getThinkingLevel,
436
502
  });
437
503
  footerInstalled = true;
@@ -484,6 +550,11 @@ export default function (pi: ExtensionAPI) {
484
550
  currentConfig = loadConfig();
485
551
  syncFooterState(ctx);
486
552
  stopProjectRefresh();
553
+ installSessionCapture();
554
+ installQueueRowHiding();
555
+ installSubagentFleet();
556
+ installModelSwitcher();
557
+ setPillBubbleAnimation(currentConfig.pill.bubbleAnimation ? BUBBLE_ANIMATION_MS : 0);
487
558
  applyConfiguredUi(ctx);
488
559
  installMouseFeatures(ctx);
489
560
  refresh();
@@ -505,7 +576,14 @@ export default function (pi: ExtensionAPI) {
505
576
  sessionLifecycle.shutdown();
506
577
  try {
507
578
  uninstallMouse();
579
+ uninstallSessionCapture();
580
+ uninstallQueueRowHiding();
581
+ setActiveSubagentFleet(undefined);
582
+ setActiveGitStatus(undefined);
583
+ setActiveContextSnapshot(undefined);
584
+ setModelSwitcher(undefined);
508
585
  uninstallPrototypePatches();
586
+ setWorkingIndicator(undefined);
509
587
  stopSessionTimer();
510
588
  stopProjectRefresh();
511
589
  requestFooterRender = undefined;
@@ -544,7 +622,11 @@ export default function (pi: ExtensionAPI) {
544
622
  pi.on("session_start", async (_event, ctx) => {
545
623
  sessionLifecycle.start();
546
624
  liveContext.clear();
547
- state.sessionStartEpoch = Date.now();
625
+ liveTps.clear();
626
+ turnClock.clear();
627
+ // A resumed session keeps its original header, and reporting the moment this
628
+ // process attached to it would make the elapsed-time segment read 0s forever.
629
+ state.sessionStartEpoch = resolveSessionStartEpoch(readSessionHeader(ctx));
548
630
  invalidateUsageTotalsCache();
549
631
  lastProjectCwd = undefined;
550
632
  installUi(ctx);
@@ -567,27 +649,21 @@ export default function (pi: ExtensionAPI) {
567
649
  : undefined,
568
650
  };
569
651
  },
570
- setFooterSegments(patch: Partial<FooterSegmentsConfig>) {
571
- currentConfig = saveFooterSegmentsPatch(patch);
572
- },
573
- setFooterFormat(value: string) {
574
- currentConfig = saveFooterFormatPatch(value);
575
- },
576
652
  setIconMode(mode: IconMode) {
577
653
  currentConfig = saveIconsModePatch(mode);
578
654
  },
579
655
  setContextStyle(style: ContextStyle) {
580
656
  currentConfig = saveContextStylePatch(style);
581
657
  },
582
- setSeparator(separator: SeparatorStyle) {
583
- currentConfig = saveSeparatorPatch(separator);
584
- },
585
658
  setPathDisplay(patch: Partial<PathDisplayConfig>) {
586
659
  currentConfig = savePathDisplayPatch(patch);
587
660
  },
588
661
  setGitBranch(patch: Partial<GitBranchConfig>) {
589
662
  currentConfig = saveGitBranchPatch(patch);
590
663
  },
664
+ setPillSegments(segments: readonly string[]) {
665
+ currentConfig = savePillSegmentsPatch(segments);
666
+ },
591
667
  getActiveExtensionStatuses() {
592
668
  return getActiveExtensionStatuses();
593
669
  },
@@ -613,6 +689,8 @@ export default function (pi: ExtensionAPI) {
613
689
 
614
690
  pi.on("session_shutdown", async (_event, ctx) => {
615
691
  liveContext.clear();
692
+ liveTps.clear();
693
+ turnClock.clear();
616
694
  cleanupUi(ctx);
617
695
  });
618
696
 
@@ -623,10 +701,14 @@ export default function (pi: ExtensionAPI) {
623
701
 
624
702
  pi.on("agent_start", (event, ctx) => {
625
703
  liveContext.clear();
704
+ // The window resets per run; the value it already measured stays on the pill.
705
+ liveTps.beginTurn();
706
+ turnClock.begin();
626
707
  syncInteractiveState(event, ctx);
627
708
  });
628
709
  pi.on("agent_end", (event, ctx) => {
629
710
  liveContext.clear();
711
+ turnClock.end();
630
712
  syncInteractiveAndProjectState(event, ctx);
631
713
  });
632
714
  pi.on("model_select", (event, ctx) => {
@@ -637,6 +719,7 @@ export default function (pi: ExtensionAPI) {
637
719
  pi.on("session_info_changed", syncInteractiveState);
638
720
  pi.on("message_update", (event) => {
639
721
  liveContext.update(event.message);
722
+ if (liveTps.observe(event.assistantMessageEvent)) refresh();
640
723
  });
641
724
  pi.on("message_end", (event, ctx) => {
642
725
  // Pi notifies extensions before persisting a successful message, so retain its live
@@ -646,18 +729,32 @@ export default function (pi: ExtensionAPI) {
646
729
  (event.message.stopReason === "error" || event.message.stopReason === "aborted")
647
730
  ) {
648
731
  liveContext.clear();
732
+ } else if (event.message.role === "assistant") {
733
+ // The stream's `done` event never reaches an extension, so the output count
734
+ // has to be closed here, where the finished message arrives with its usage.
735
+ liveTps.complete(event.message.usage?.output ?? 0);
649
736
  }
650
737
  syncInteractiveAndProjectStateWithUsage(event, ctx);
651
738
  });
652
739
  pi.on("tool_execution_start", (event, ctx) => {
653
740
  liveContext.clear();
741
+ refreshFleetForTool(event);
654
742
  syncInteractiveState(event, ctx);
655
743
  });
656
- pi.on("tool_execution_end", syncInteractiveAndProjectState);
744
+ pi.on("tool_execution_end", (event, ctx) => {
745
+ refreshFleetForTool(event);
746
+ syncInteractiveAndProjectState(event, ctx);
747
+ });
657
748
  pi.on("session_compact", (event, ctx) => {
658
749
  liveContext.clear();
659
750
  syncInteractiveAndProjectStateWithUsage(event, ctx);
660
751
  });
752
+ // Pi emits this and draws nothing for it: a failed compaction late in a long
753
+ // session otherwise looks like the agent forgetting the conversation.
754
+ pi.on("session_compact_failed", (event, ctx) => {
755
+ const notice = compactionFailureNotice(event);
756
+ if (notice) ctx.ui.notify(notice, "error");
757
+ });
661
758
  pi.on("session_tree", (event, ctx) => {
662
759
  liveContext.clear();
663
760
  syncInteractiveAndProjectStateWithUsage(event, ctx);
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The live `AgentSession`, for the two things the extension context does not
3
+ * expose: the queued messages themselves, and the thinking-level switch.
4
+ *
5
+ * `ctx.hasPendingMessages()` is a boolean, but the data behind it is public on
6
+ * `AgentSession` (`pendingMessageCount`, `getSteeringMessages()`,
7
+ * `getFollowUpMessages()`), and so are the thinking-level getters and setters.
8
+ * What is missing is a reference to the instance — the extension context carries
9
+ * the session manager, not the session.
10
+ *
11
+ * So this captures the instance from any method the footer already reaches:
12
+ * `getContextUsage()` runs on every render, and `prompt()` covers a queue that
13
+ * existed before Starline loaded. Two wrappers, registered through the same
14
+ * prototype-patch registry the mouse layer uses, gone the moment the session
15
+ * ends. The upstream fix is a `pendingMessages` field on the extension context,
16
+ * at which point this file can be deleted.
17
+ */
18
+
19
+ import { AgentSession } from "@earendil-works/pi-coding-agent";
20
+ import { installPrototypePatch } from "./prototype-patch-registry";
21
+
22
+ export type PendingMessages = {
23
+ steering: readonly string[];
24
+ followUp: readonly string[];
25
+ };
26
+
27
+ type QueuedMessageSource = {
28
+ getSteeringMessages?: () => unknown;
29
+ getFollowUpMessages?: () => unknown;
30
+ getAvailableThinkingLevels?: () => unknown;
31
+ setThinkingLevel?: (level: string) => unknown;
32
+ readonly thinkingLevel?: string;
33
+ };
34
+
35
+ let liveSession: QueuedMessageSource | undefined;
36
+
37
+ /**
38
+ * Record the receiver of a patched method. Two methods are wrapped because the
39
+ * queue needs the session before the first prompt too, and the footer asks for
40
+ * context usage on every render.
41
+ */
42
+ function captureVia(
43
+ method: "prompt" | "getContextUsage",
44
+ adapter: "agent-session-capture-prompt" | "agent-session-capture-usage",
45
+ ): () => void {
46
+ try {
47
+ return installPrototypePatch(
48
+ AgentSession.prototype,
49
+ method,
50
+ adapter,
51
+ ({ predecessor, receiver, args }) => {
52
+ liveSession = receiver as QueuedMessageSource;
53
+ return Reflect.apply(predecessor, receiver, args);
54
+ },
55
+ );
56
+ } catch {
57
+ return () => {};
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Wrap the two `AgentSession` methods that put the instance in hand. Returns a
63
+ * disposer; a Pi build without them simply captures nothing and
64
+ * `readPendingMessages()` stays undefined.
65
+ */
66
+ export function installLiveSessionCapture(): () => void {
67
+ const disposers = [
68
+ captureVia("prompt", "agent-session-capture-prompt"),
69
+ captureVia("getContextUsage", "agent-session-capture-usage"),
70
+ ];
71
+ return () => {
72
+ for (const dispose of disposers) dispose();
73
+ forgetLiveSession();
74
+ };
75
+ }
76
+
77
+ export function forgetLiveSession(): void {
78
+ liveSession = undefined;
79
+ }
80
+
81
+ /**
82
+ * What is queued right now: steering messages first, then follow-ups, the same
83
+ * order Pi hands them to the agent. Undefined when the session was never seen or
84
+ * this build does not expose the getters.
85
+ */
86
+ export function readPendingMessages(): PendingMessages | undefined {
87
+ const session = liveSession;
88
+ if (!session) return undefined;
89
+ try {
90
+ const steering = session.getSteeringMessages?.();
91
+ const followUp = session.getFollowUpMessages?.();
92
+ if (!Array.isArray(steering) || !Array.isArray(followUp)) return undefined;
93
+ return {
94
+ steering: steering.filter((message): message is string => typeof message === "string"),
95
+ followUp: followUp.filter((message): message is string => typeof message === "string"),
96
+ };
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ export type ThinkingCycle = { from: string; to: string };
103
+
104
+ export type ThinkingLevels = {
105
+ /** Levels the model supports, in Pi's order, without "off". */
106
+ levels: string[];
107
+ /** The session's level now; "" when it does not say. */
108
+ current: string;
109
+ };
110
+
111
+ /**
112
+ * The levels the thinking pill can switch between.
113
+ *
114
+ * "off" is filtered out on purpose: the thinking pill only exists while thinking
115
+ * is on, so a choice that removes its own pill would be a surprise and leave no
116
+ * way back from the same place. Returns undefined when the session was never
117
+ * seen, cannot set the level, or the model has nothing to choose from.
118
+ */
119
+ export function readThinkingLevels(): ThinkingLevels | undefined {
120
+ const session = liveSession;
121
+ if (!session) return undefined;
122
+ try {
123
+ const readLevels = session.getAvailableThinkingLevels;
124
+ if (typeof readLevels !== "function" || typeof session.setThinkingLevel !== "function") {
125
+ return undefined;
126
+ }
127
+ const levels = readLevels.call(session);
128
+ if (!Array.isArray(levels)) return undefined;
129
+ const usable = levels.filter(
130
+ (level): level is string => typeof level === "string" && level !== "off",
131
+ );
132
+ if (usable.length === 0) return undefined;
133
+ const current = typeof session.thinkingLevel === "string" ? session.thinkingLevel : "";
134
+ return { levels: usable, current };
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ /** Switch the live session to `level`. False when it could not be done. */
141
+ export function setThinkingLevel(level: string): boolean {
142
+ const session = liveSession;
143
+ const setLevel = session?.setThinkingLevel;
144
+ if (!session || typeof setLevel !== "function") return false;
145
+ try {
146
+ setLevel.call(session, level);
147
+ return true;
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Step to the next level `readThinkingLevels` offers, wrapping around. This is
155
+ * the keyboard path; a click opens the list instead.
156
+ */
157
+ export function cycleThinkingLevel(): ThinkingCycle | undefined {
158
+ const read = readThinkingLevels();
159
+ if (!read) return undefined;
160
+ const index = read.levels.indexOf(read.current);
161
+ const to = read.levels[(index + 1) % read.levels.length];
162
+ if (!to || !setThinkingLevel(to)) return undefined;
163
+ return { from: read.current || to, to };
164
+ }