@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.
- package/LICENSE +22 -0
- package/README.md +223 -0
- package/extensions/starline/config.ts +1317 -0
- package/extensions/starline/editor-cursor.ts +55 -0
- package/extensions/starline/editor-factory-marker.ts +49 -0
- package/extensions/starline/editor-metadata-format.ts +259 -0
- package/extensions/starline/extension-status.ts +88 -0
- package/extensions/starline/footer-format.ts +279 -0
- package/extensions/starline/footer.ts +744 -0
- package/extensions/starline/format.ts +473 -0
- package/extensions/starline/git.ts +467 -0
- package/extensions/starline/icons.ts +294 -0
- package/extensions/starline/index.ts +651 -0
- package/extensions/starline/live-context.ts +75 -0
- package/extensions/starline/mouse/capabilities.ts +207 -0
- package/extensions/starline/mouse/component-tree.ts +230 -0
- package/extensions/starline/mouse/editor-caret.ts +498 -0
- package/extensions/starline/mouse/editor-hit-test.ts +133 -0
- package/extensions/starline/mouse/editor-mouse.ts +142 -0
- package/extensions/starline/mouse/editor-scroll.ts +119 -0
- package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
- package/extensions/starline/mouse/editor-text-edit.ts +90 -0
- package/extensions/starline/mouse/hint.ts +14 -0
- package/extensions/starline/mouse/hit-test.ts +144 -0
- package/extensions/starline/mouse/index.ts +1119 -0
- package/extensions/starline/mouse/selection-state.ts +68 -0
- package/extensions/starline/mouse/tool-box.ts +290 -0
- package/extensions/starline/mouse/transcript-copy.ts +166 -0
- package/extensions/starline/mouse/word-select.ts +145 -0
- package/extensions/starline/package-version.ts +650 -0
- package/extensions/starline/paste-collapse.ts +312 -0
- package/extensions/starline/pill-config.ts +122 -0
- package/extensions/starline/pill.ts +295 -0
- package/extensions/starline/project-refresh.ts +104 -0
- package/extensions/starline/project-state.ts +66 -0
- package/extensions/starline/prototype-patch-registry.ts +136 -0
- package/extensions/starline/runtime.ts +841 -0
- package/extensions/starline/selector-border.ts +77 -0
- package/extensions/starline/session-lifecycle.ts +60 -0
- package/extensions/starline/settings-command.ts +925 -0
- package/extensions/starline/state.ts +65 -0
- package/extensions/starline/style.ts +528 -0
- package/extensions/starline/ui.ts +611 -0
- package/extensions/starline/user-message.ts +267 -0
- 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
|
+
}
|