pi-editor-footer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/AGENTS.md +19 -0
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +29 -0
  4. package/README.md +84 -0
  5. package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
  6. package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
  7. package/docs/agents/domain.md +51 -0
  8. package/docs/agents/issue-tracker.md +45 -0
  9. package/docs/agents/triage-labels.md +15 -0
  10. package/docs/reference/pi-tui-internals.md +144 -0
  11. package/docs/specs/01-config.md +57 -0
  12. package/docs/specs/02-identity.md +20 -0
  13. package/docs/specs/03-border-telemetry.md +48 -0
  14. package/docs/specs/04-header.md +30 -0
  15. package/docs/specs/05-footer.md +30 -0
  16. package/docs/specs/06-git.md +36 -0
  17. package/docs/specs/07-runtime.md +28 -0
  18. package/docs/specs/theme-overview.md +116 -0
  19. package/package.json +16 -0
  20. package/src/config.ts +184 -0
  21. package/src/detail-render.ts +119 -0
  22. package/src/footer.ts +479 -0
  23. package/src/git.ts +170 -0
  24. package/src/header.ts +185 -0
  25. package/src/icons.ts +197 -0
  26. package/src/index.ts +607 -0
  27. package/src/model-info.ts +341 -0
  28. package/src/runtime.ts +318 -0
  29. package/src/state.ts +144 -0
  30. package/src/telemetry.ts +437 -0
  31. package/src/theme-settings.ts +461 -0
  32. package/src/tracking-editor.ts +352 -0
  33. package/src/utils-workspace.ts +48 -0
  34. package/src/utils.ts +388 -0
  35. package/src/window-presentation.ts +56 -0
  36. package/test/config.test.ts +146 -0
  37. package/test/detail-render.test.ts +202 -0
  38. package/test/footer.test.ts +86 -0
  39. package/test/git.test.ts +45 -0
  40. package/test/header.test.ts +169 -0
  41. package/test/icons.test.ts +24 -0
  42. package/test/runtime.test.ts +71 -0
  43. package/test/telemetry.test.ts +199 -0
  44. package/test/utils.test.ts +71 -0
  45. package/test/window-presentation.test.ts +73 -0
  46. package/tsconfig.json +13 -0
@@ -0,0 +1,461 @@
1
+ import {
2
+ Box,
3
+ Key,
4
+ matchesKey,
5
+ SelectList,
6
+ Text,
7
+ type TUI,
8
+ } from "@earendil-works/pi-tui";
9
+
10
+ type Theme = {
11
+ bg(style: string, s: string): string;
12
+ fg(style: string, s: string): string;
13
+ bold(s: string): string;
14
+ dim(s: string): string;
15
+ muted(s: string): string;
16
+ };
17
+ interface ExtensionAPI {
18
+ registerCommand(
19
+ name: string,
20
+ opts: {
21
+ description?: string;
22
+ handler: (args: string, ctx: ExtensionContext) => void | Promise<void>;
23
+ },
24
+ ): void;
25
+ }
26
+ interface ExtensionContext {
27
+ hasUI?: boolean;
28
+ ui: {
29
+ custom<T>(
30
+ fn: (
31
+ tui: TUI,
32
+ theme: Theme,
33
+ kb: unknown,
34
+ done: (v: T) => void,
35
+ ) => unknown,
36
+ opts?: unknown,
37
+ ): Promise<T>;
38
+ };
39
+ }
40
+ import type {
41
+ ThemeConfig,
42
+ CursorStyle,
43
+ IconMode,
44
+ WorkspaceDisplay,
45
+ } from "./config.ts";
46
+
47
+ const TABS = ["general", "appearance", "footer", "telemetry"] as const;
48
+ type Tab = (typeof TABS)[number];
49
+
50
+ interface SettingItem {
51
+ id: string;
52
+ label: string;
53
+ currentValue: string;
54
+ }
55
+
56
+ const COPY = {
57
+ title: "pi-lsz-theme Settings",
58
+ tabs: {
59
+ general: "General",
60
+ appearance: "Appearance",
61
+ footer: "Footer",
62
+ telemetry: "Telemetry",
63
+ },
64
+ hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Esc/q: close",
65
+ labels: {
66
+ enabled: "Enabled",
67
+ cursorStyle: "Cursor style",
68
+ iconMode: "Icon mode",
69
+ workspaceDisplay: "Workspace display",
70
+ cwd: "CWD",
71
+ sessionName: "Session name",
72
+ gitBranch: "Git branch",
73
+ gitStatus: "Git status",
74
+ gitCommit: "Git commit (detached)",
75
+ runtime: "Runtime",
76
+ context: "Context bar",
77
+ tokens: "Tokens",
78
+ cost: "Cost",
79
+ extensionStatuses: "Extension status line",
80
+ tps: "TPS",
81
+ ttft: "TTFT",
82
+ duration: "Total duration",
83
+ stallDetails: "Stall details",
84
+ costRate: "Cost rate",
85
+ },
86
+ values: {
87
+ on: "On",
88
+ off: "Off",
89
+ workspaceDisplays: { path: "Full path", name: "Name only" } as Record<
90
+ WorkspaceDisplay,
91
+ string
92
+ >,
93
+ cursorStyles: {
94
+ block: "Block",
95
+ bar: "Bar",
96
+ underline: "Underline",
97
+ } as Record<CursorStyle, string>,
98
+ icons: { auto: "Auto", nerd: "Nerd", ascii: "ASCII" } as Record<
99
+ IconMode,
100
+ string
101
+ >,
102
+ },
103
+ };
104
+
105
+ function cycleCursor(config: ThemeConfig): ThemeConfig {
106
+ const order: CursorStyle[] = ["block", "bar", "underline"];
107
+ const idx = order.indexOf(config.cursorStyle);
108
+ const next = order[(idx + 1) % order.length]!;
109
+ return { ...config, cursorStyle: next };
110
+ }
111
+ function cycleWorkspaceDisplay(config: ThemeConfig): ThemeConfig {
112
+ const next: WorkspaceDisplay =
113
+ config.workspaceDisplay === "path" ? "name" : "path";
114
+ return { ...config, workspaceDisplay: next };
115
+ }
116
+ function cycleIconMode(config: ThemeConfig): ThemeConfig {
117
+ const order: IconMode[] = ["auto", "nerd", "ascii"];
118
+ const idx = order.indexOf(config.icons.mode);
119
+ const next = order[(idx + 1) % order.length]!;
120
+ return { ...config, icons: { mode: next } };
121
+ }
122
+ function toggleFlag<K extends keyof ThemeConfig["footerSegments"]>(
123
+ config: ThemeConfig,
124
+ key: K,
125
+ ): ThemeConfig {
126
+ return {
127
+ ...config,
128
+ footerSegments: {
129
+ ...config.footerSegments,
130
+ [key]: !config.footerSegments[key],
131
+ },
132
+ };
133
+ }
134
+ function toggleTelemetry<K extends keyof ThemeConfig["telemetry"]>(
135
+ config: ThemeConfig,
136
+ key: K,
137
+ ): ThemeConfig {
138
+ if (key === "enabled") {
139
+ // keep sub-flags as-is when toggling enabled, but allow it
140
+ return {
141
+ ...config,
142
+ telemetry: { ...config.telemetry, enabled: !config.telemetry.enabled },
143
+ };
144
+ }
145
+ return {
146
+ ...config,
147
+ telemetry: { ...config.telemetry, [key]: !config.telemetry[key] },
148
+ };
149
+ }
150
+
151
+ function buildGeneralItems(config: ThemeConfig): SettingItem[] {
152
+ return [
153
+ {
154
+ id: "enabled",
155
+ label: COPY.labels.enabled,
156
+ currentValue: config.enabled ? COPY.values.on : COPY.values.off,
157
+ },
158
+ {
159
+ id: "workspaceDisplay",
160
+ label: COPY.labels.workspaceDisplay,
161
+ currentValue: COPY.values.workspaceDisplays[config.workspaceDisplay],
162
+ },
163
+ {
164
+ id: "cursorStyle",
165
+ label: COPY.labels.cursorStyle,
166
+ currentValue: COPY.values.cursorStyles[config.cursorStyle],
167
+ },
168
+ ];
169
+ }
170
+ function buildAppearanceItems(config: ThemeConfig): SettingItem[] {
171
+ return [
172
+ {
173
+ id: "iconMode",
174
+ label: COPY.labels.iconMode,
175
+ currentValue: COPY.values.icons[config.icons.mode],
176
+ },
177
+ ];
178
+ }
179
+ function buildFooterItems(config: ThemeConfig): SettingItem[] {
180
+ const segs = config.footerSegments;
181
+ const flag = (v: boolean) => (v ? COPY.values.on : COPY.values.off);
182
+ return [
183
+ { id: "cwd", label: COPY.labels.cwd, currentValue: flag(segs.cwd) },
184
+ {
185
+ id: "sessionName",
186
+ label: COPY.labels.sessionName,
187
+ currentValue: flag(segs.sessionName),
188
+ },
189
+ {
190
+ id: "gitBranch",
191
+ label: COPY.labels.gitBranch,
192
+ currentValue: flag(segs.gitBranch),
193
+ },
194
+ {
195
+ id: "gitStatus",
196
+ label: COPY.labels.gitStatus,
197
+ currentValue: flag(segs.gitStatus),
198
+ },
199
+ {
200
+ id: "gitCommit",
201
+ label: COPY.labels.gitCommit,
202
+ currentValue: flag(segs.gitCommit),
203
+ },
204
+ {
205
+ id: "runtime",
206
+ label: COPY.labels.runtime,
207
+ currentValue: flag(segs.runtime),
208
+ },
209
+ {
210
+ id: "context",
211
+ label: COPY.labels.context,
212
+ currentValue: flag(segs.context),
213
+ },
214
+ {
215
+ id: "tokens",
216
+ label: COPY.labels.tokens,
217
+ currentValue: flag(segs.tokens),
218
+ },
219
+ { id: "cost", label: COPY.labels.cost, currentValue: flag(segs.cost) },
220
+ {
221
+ id: "extensionStatuses",
222
+ label: COPY.labels.extensionStatuses,
223
+ currentValue: flag(segs.extensionStatuses),
224
+ },
225
+ ];
226
+ }
227
+ function buildTelemetryItems(config: ThemeConfig): SettingItem[] {
228
+ const t = config.telemetry;
229
+ const flag = (v: boolean) => (v ? COPY.values.on : COPY.values.off);
230
+ return [
231
+ {
232
+ id: "enabled",
233
+ label: COPY.labels.enabled,
234
+ currentValue: flag(t.enabled),
235
+ },
236
+ { id: "tps", label: COPY.labels.tps, currentValue: flag(t.tps) },
237
+ { id: "ttft", label: COPY.labels.ttft, currentValue: flag(t.ttft) },
238
+ {
239
+ id: "duration",
240
+ label: COPY.labels.duration,
241
+ currentValue: flag(t.duration),
242
+ },
243
+ { id: "tokens", label: COPY.labels.tokens, currentValue: flag(t.tokens) },
244
+ {
245
+ id: "stalls",
246
+ label: COPY.labels.stallDetails,
247
+ currentValue: flag(t.stalls),
248
+ },
249
+ { id: "cost", label: COPY.labels.costRate, currentValue: flag(t.cost) },
250
+ ];
251
+ }
252
+ function buildItems(tab: Tab, config: ThemeConfig): SettingItem[] {
253
+ switch (tab) {
254
+ case "general":
255
+ return buildGeneralItems(config);
256
+ case "appearance":
257
+ return buildAppearanceItems(config);
258
+ case "footer":
259
+ return buildFooterItems(config);
260
+ case "telemetry":
261
+ return buildTelemetryItems(config);
262
+ }
263
+ }
264
+
265
+ function handleSettingChange(
266
+ tab: Tab,
267
+ itemId: string,
268
+ config: ThemeConfig,
269
+ ): ThemeConfig {
270
+ if (tab === "general") {
271
+ if (itemId === "enabled") return { ...config, enabled: !config.enabled };
272
+ if (itemId === "workspaceDisplay") return cycleWorkspaceDisplay(config);
273
+ if (itemId === "cursorStyle") return cycleCursor(config);
274
+ }
275
+ if (tab === "appearance") {
276
+ if (itemId === "iconMode") return cycleIconMode(config);
277
+ }
278
+ if (tab === "footer") {
279
+ return toggleFlag(config, itemId as keyof ThemeConfig["footerSegments"]);
280
+ }
281
+ if (tab === "telemetry") {
282
+ return toggleTelemetry(config, itemId as keyof ThemeConfig["telemetry"]);
283
+ }
284
+ return config;
285
+ }
286
+
287
+ class SettingsUi {
288
+ private tab: Tab = "general";
289
+ private config: ThemeConfig;
290
+ private selectList!: SelectList;
291
+ private readonly selectedItemByTab: Partial<Record<Tab, string>> = {};
292
+ private readonly container: Box;
293
+ private readonly theme: Theme;
294
+ private readonly onChange: (config: ThemeConfig) => void;
295
+ private readonly onClose: () => void;
296
+ private cachedWidth: number | undefined;
297
+ private cachedLines: string[] | undefined;
298
+ private compact = false;
299
+
300
+ constructor(
301
+ theme: Theme,
302
+ config: ThemeConfig,
303
+ onChange: (config: ThemeConfig) => void,
304
+ onClose: () => void,
305
+ ) {
306
+ this.theme = theme;
307
+ this.config = config;
308
+ this.onChange = onChange;
309
+ this.onClose = onClose;
310
+ this.container = new Box(1, 1, (s: string) =>
311
+ theme.bg("customMessageBg", s),
312
+ );
313
+ this.selectList = new SelectList([], 12, {
314
+ selectedPrefix: (t) => theme.fg("accent", t),
315
+ selectedText: (t) => theme.fg("accent", t),
316
+ description: (t) => theme.fg("muted", t),
317
+ scrollInfo: (t) => theme.fg("dim", t),
318
+ noMatch: (t) => theme.fg("warning", t),
319
+ });
320
+ this.rebuild();
321
+ }
322
+
323
+ private applySetting(itemId: string): void {
324
+ this.selectedItemByTab[this.tab] = itemId;
325
+ this.config = handleSettingChange(this.tab, itemId, this.config);
326
+ this.onChange(this.config);
327
+ this.rebuild(itemId);
328
+ }
329
+
330
+ private switchTab(offset: number): void {
331
+ const idx = TABS.indexOf(this.tab);
332
+ this.tab = TABS[(idx + offset + TABS.length) % TABS.length]!;
333
+ this.rebuild();
334
+ }
335
+
336
+ private rebuild(preferredItemId = this.selectedItemByTab[this.tab]): void {
337
+ this.container.clear();
338
+ this.container.addChild(
339
+ new Text(this.theme.bold(this.theme.fg("accent", COPY.title)), 1, 0),
340
+ );
341
+ const tabBar = TABS.map((tab) => {
342
+ const active = tab === this.tab;
343
+ const label = active ? `[${COPY.tabs[tab]}]` : ` ${COPY.tabs[tab]} `;
344
+ return active
345
+ ? this.theme.fg("accent", label)
346
+ : this.theme.fg("dim", label);
347
+ }).join(" ");
348
+ this.container.addChild(new Text(tabBar, 1, 0));
349
+ this.container.addChild(new Text(this.theme.fg("dim", COPY.hint), 1, 0));
350
+
351
+ const items = buildItems(this.tab, this.config).map((item) => ({
352
+ value: item.id,
353
+ label: this.compact ? `${item.label}: ${item.currentValue}` : item.label,
354
+ description: this.compact ? undefined : item.currentValue,
355
+ }));
356
+ this.selectList = new SelectList(
357
+ items as never,
358
+ Math.min(items.length, 10),
359
+ {
360
+ selectedPrefix: (t) => this.theme.fg("accent", t),
361
+ selectedText: (t) => this.theme.fg("accent", t),
362
+ description: (t) => this.theme.fg("muted", t),
363
+ scrollInfo: (t) => this.theme.fg("dim", t),
364
+ noMatch: (t) => this.theme.fg("warning", t),
365
+ },
366
+ );
367
+ const selectedIndex = items.findIndex(
368
+ (item) => item.value === preferredItemId,
369
+ );
370
+ if (selectedIndex >= 0) this.selectList.setSelectedIndex(selectedIndex);
371
+ this.selectedItemByTab[this.tab] = this.selectList.getSelectedItem()?.value;
372
+ this.selectList.onSelectionChange = (item) => {
373
+ this.selectedItemByTab[this.tab] = (item as { value: string }).value;
374
+ };
375
+ this.selectList.onSelect = (item) =>
376
+ this.applySetting((item as { value: string }).value);
377
+ this.selectList.onCancel = () => this.onClose();
378
+ this.container.addChild(this.selectList as never);
379
+ this.cachedWidth = undefined;
380
+ this.cachedLines = undefined;
381
+ }
382
+
383
+ handleInput(data: string): void {
384
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
385
+ this.switchTab(1);
386
+ this.invalidate();
387
+ return;
388
+ }
389
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
390
+ this.switchTab(-1);
391
+ this.invalidate();
392
+ return;
393
+ }
394
+ if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
395
+ this.onClose();
396
+ return;
397
+ }
398
+ if (matchesKey(data, Key.space) || data === " ") {
399
+ const selected = this.selectList.getSelectedItem();
400
+ if (selected) this.applySetting((selected as { value: string }).value);
401
+ } else {
402
+ this.selectList.handleInput?.(data);
403
+ }
404
+ this.invalidate();
405
+ }
406
+
407
+ render(width: number): string[] {
408
+ const compact = width <= 60;
409
+ if (compact !== this.compact) {
410
+ this.compact = compact;
411
+ this.rebuild();
412
+ }
413
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
414
+ this.cachedWidth = width;
415
+ this.cachedLines = this.container.render(width);
416
+ return this.cachedLines;
417
+ }
418
+
419
+ invalidate(): void {
420
+ this.cachedWidth = undefined;
421
+ this.cachedLines = undefined;
422
+ this.container.invalidate();
423
+ }
424
+ }
425
+
426
+ export function registerThemeSettingsCommand(
427
+ pi: any,
428
+ hooks: {
429
+ getConfig: () => ThemeConfig;
430
+ onConfigChanged: (config: ThemeConfig) => void;
431
+ onOverlayClosed?: () => void;
432
+ },
433
+ ): void {
434
+ pi.registerCommand("pi-footer", {
435
+ description:
436
+ "Open pi-footer settings (workspace, cursor, footer, telemetry)",
437
+ handler: async (_args: string, ctx: ExtensionContext) => {
438
+ if (!ctx.hasUI) return;
439
+ await ctx.ui.custom<void>(
440
+ (tui: TUI, theme: Theme, _kb: unknown, done: (v: void) => void) => {
441
+ const ui = new SettingsUi(
442
+ theme as Theme,
443
+ hooks.getConfig(),
444
+ (config) => hooks.onConfigChanged(config),
445
+ () => done(undefined),
446
+ );
447
+ return {
448
+ render: (w: number) => ui.render(w),
449
+ invalidate: () => ui.invalidate(),
450
+ handleInput: (data: string) => {
451
+ ui.handleInput(data);
452
+ tui.requestRender();
453
+ },
454
+ };
455
+ },
456
+ { overlay: true },
457
+ );
458
+ hooks.onOverlayClosed?.();
459
+ },
460
+ });
461
+ }