@xzzpig/pi-starline 0.5.0 → 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.
- package/FORK-CHANGELOG.md +28 -0
- package/README.md +91 -181
- package/dist/public.d.ts +5 -0
- package/extensions/starline/config.ts +156 -326
- package/extensions/starline/context-live.ts +33 -0
- package/extensions/starline/editor-cursor.ts +3 -0
- package/extensions/starline/extension-status.ts +9 -5
- package/extensions/starline/footer.ts +331 -528
- package/extensions/starline/fork-editor-cursor.ts +14 -0
- package/extensions/starline/format.ts +151 -20
- package/extensions/starline/git-live.ts +20 -0
- package/extensions/starline/git-probe.ts +40 -0
- package/extensions/starline/git-segments.ts +175 -0
- package/extensions/starline/git.ts +71 -0
- package/extensions/starline/icons.ts +17 -0
- package/extensions/starline/index.ts +188 -91
- package/extensions/starline/live-session.ts +164 -0
- package/extensions/starline/live-tps.ts +165 -0
- package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
- package/extensions/starline/mouse/cursor-marker.ts +69 -0
- package/extensions/starline/mouse/editor-hit-test.ts +6 -3
- package/extensions/starline/mouse/explicit-expand-target.ts +89 -0
- package/extensions/starline/mouse/external-layer.ts +42 -0
- package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
- package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
- package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
- package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
- package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
- package/extensions/starline/mouse/hint.ts +4 -4
- package/extensions/starline/mouse/index.ts +54 -24
- package/extensions/starline/mouse/pointer-events.ts +73 -0
- package/extensions/starline/mouse/pointer-shape.ts +138 -0
- package/extensions/starline/mouse/tool-box.ts +4 -0
- package/extensions/starline/mouse/transcript-copy.ts +76 -33
- package/extensions/starline/paste-collapse.ts +52 -13
- package/extensions/starline/pill-actions.ts +590 -0
- package/extensions/starline/pill-anchors.ts +26 -0
- package/extensions/starline/pill-api.ts +80 -0
- package/extensions/starline/pill-bubble.ts +607 -0
- package/extensions/starline/pill-config.ts +67 -9
- package/extensions/starline/pill.ts +102 -20
- package/extensions/starline/project-probe.ts +79 -0
- package/extensions/starline/prototype-patch-registry.ts +6 -1
- package/extensions/starline/queue-rows.ts +43 -0
- package/extensions/starline/settings-command.ts +124 -177
- package/extensions/starline/subagent-fleet.ts +246 -0
- package/extensions/starline/turn-clock.ts +81 -0
- package/extensions/starline/ui.ts +297 -156
- package/extensions/starline/working-indicator.ts +189 -0
- package/package.json +18 -12
- package/types/pill-api.d.ts +115 -0
- package/extensions/starline/editor-metadata-format.ts +0 -259
- 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
|
-
|
|
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 {
|
|
43
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
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
|
-
|
|
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
|
|
191
|
-
const
|
|
192
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
+
}
|