pi-cockpit 0.24.3 → 0.24.5

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/package.json CHANGED
@@ -1,88 +1,88 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.24.3",
4
- "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
- "type": "module",
6
- "engines": {
7
- "node": ">=22.19.0"
8
- },
9
- "keywords": [
10
- "pi-package",
11
- "pi",
12
- "pi-extension",
13
- "tui",
14
- "teammate",
15
- "todo"
16
- ],
17
- "license": "MIT",
18
- "files": [
19
- "src/",
20
- "themes/",
21
- "README.md"
22
- ],
23
- "scripts": {
24
- "test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/ui-ownership-runtime.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/compaction-style.test.ts tests/quiet-tools.test.ts tests/native-host.test.ts tests/native-host-entry.test.ts tests/native-host-quiet.test.ts tests/edit-guard.test.ts tests/agents-store.test.ts tests/endpoint-store.test.ts tests/session-ui-state.test.ts tests/session-tabs.test.ts tests/agent-bar.test.ts tests/supervision-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-overlay.test.ts tests/agent-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/teammate-message.test.ts tests/input-routing.test.ts tests/input-history-store.test.ts tests/unified-editor.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/zen-render.test.ts tests/zen-browse.test.ts tests/zen-sheet.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/model-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/tui-i18n.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.test.ts tests/capturing-overlay.test.ts tests/claude-editor.test.ts tests/fullscreen-controller.test.ts tests/transcript-selection.test.ts tests/terminal-cleanup.test.ts tests/terminal-capability.test.ts tests/usage-core.test.ts tests/usage-extension.test.ts tests/usage-history.test.ts tests/usage-sparkline.test.ts tests/patch-health.test.ts",
25
- "typecheck": "tsc --noEmit",
26
- "prepack": "node ../../scripts/bundle-settings-core.mjs",
27
- "postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
28
- },
29
- "pi": {
30
- "extensions": [
31
- "./src/extension/index.ts"
32
- ],
33
- "themes": [
34
- "./themes"
35
- ]
36
- },
37
- "dependencies": {
38
- "pi-maestro-settings-core": "0.2.3",
39
- "proper-lockfile": "4.1.2"
40
- },
41
- "bundledDependencies": [
42
- "pi-maestro-settings-core"
43
- ],
44
- "peerDependencies": {
45
- "@earendil-works/pi-agent-core": "*",
46
- "@earendil-works/pi-ai": "*",
47
- "@earendil-works/pi-coding-agent": "*",
48
- "@earendil-works/pi-tui": "*",
49
- "pi-maestro-teammate": "^2.8.1",
50
- "typebox": "*"
51
- },
52
- "peerDependenciesMeta": {
53
- "@earendil-works/pi-agent-core": {
54
- "optional": true
55
- },
56
- "@earendil-works/pi-ai": {
57
- "optional": true
58
- },
59
- "@earendil-works/pi-coding-agent": {
60
- "optional": true
61
- },
62
- "@earendil-works/pi-tui": {
63
- "optional": true
64
- },
65
- "pi-maestro-teammate": {
66
- "optional": true
67
- },
68
- "typebox": {
69
- "optional": true
70
- }
71
- },
72
- "devDependencies": {
73
- "@earendil-works/pi-agent-core": "0.99.0",
74
- "@earendil-works/pi-ai": "0.99.0",
75
- "@earendil-works/pi-coding-agent": "0.99.0",
76
- "@earendil-works/pi-tui": "0.99.0",
77
- "@types/node": "^22.0.0",
78
- "pi-maestro-teammate": "2.8.1",
79
- "typebox": "1.3.27",
80
- "typescript": "^5.5.0"
81
- },
82
- "main": "./src/index.ts",
83
- "exports": {
84
- ".": "./src/index.ts",
85
- "./v1/events": "./src/public/v1/events.ts",
86
- "./src/*": "./src/*"
87
- }
88
- }
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.24.5",
4
+ "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "keywords": [
10
+ "pi-package",
11
+ "pi",
12
+ "pi-extension",
13
+ "tui",
14
+ "teammate",
15
+ "todo"
16
+ ],
17
+ "license": "MIT",
18
+ "files": [
19
+ "src/",
20
+ "themes/",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/ui-ownership-runtime.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/terminal-emulator.test.ts tests/compaction-style.test.ts tests/quiet-tools.test.ts tests/native-host.test.ts tests/native-host-entry.test.ts tests/native-host-quiet.test.ts tests/edit-guard.test.ts tests/agents-store.test.ts tests/endpoint-store.test.ts tests/session-ui-state.test.ts tests/session-tabs.test.ts tests/agent-bar.test.ts tests/supervision-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-overlay.test.ts tests/agent-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/teammate-message.test.ts tests/input-routing.test.ts tests/input-history-store.test.ts tests/unified-editor.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/zen-render.test.ts tests/zen-browse.test.ts tests/zen-sheet.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/model-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/tui-i18n.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.test.ts tests/capturing-overlay.test.ts tests/claude-editor.test.ts tests/fullscreen-controller.test.ts tests/transcript-selection.test.ts tests/terminal-cleanup.test.ts tests/terminal-capability.test.ts tests/usage-core.test.ts tests/usage-extension.test.ts tests/usage-history.test.ts tests/usage-sparkline.test.ts tests/patch-health.test.ts",
25
+ "typecheck": "tsc --noEmit",
26
+ "prepack": "node ../../scripts/bundle-settings-core.mjs",
27
+ "postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/extension/index.ts"
32
+ ],
33
+ "themes": [
34
+ "./themes"
35
+ ]
36
+ },
37
+ "dependencies": {
38
+ "pi-maestro-settings-core": "0.2.3",
39
+ "proper-lockfile": "4.1.2"
40
+ },
41
+ "bundledDependencies": [
42
+ "pi-maestro-settings-core"
43
+ ],
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-agent-core": "*",
46
+ "@earendil-works/pi-ai": "*",
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*",
49
+ "pi-maestro-teammate": "^2.8.1",
50
+ "typebox": "*"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@earendil-works/pi-agent-core": {
54
+ "optional": true
55
+ },
56
+ "@earendil-works/pi-ai": {
57
+ "optional": true
58
+ },
59
+ "@earendil-works/pi-coding-agent": {
60
+ "optional": true
61
+ },
62
+ "@earendil-works/pi-tui": {
63
+ "optional": true
64
+ },
65
+ "pi-maestro-teammate": {
66
+ "optional": true
67
+ },
68
+ "typebox": {
69
+ "optional": true
70
+ }
71
+ },
72
+ "devDependencies": {
73
+ "@earendil-works/pi-agent-core": "0.99.0",
74
+ "@earendil-works/pi-ai": "0.99.0",
75
+ "@earendil-works/pi-coding-agent": "0.99.0",
76
+ "@earendil-works/pi-tui": "0.99.0",
77
+ "@types/node": "^22.0.0",
78
+ "pi-maestro-teammate": "2.10.1",
79
+ "typebox": "1.3.27",
80
+ "typescript": "^5.5.0"
81
+ },
82
+ "main": "./src/index.ts",
83
+ "exports": {
84
+ ".": "./src/index.ts",
85
+ "./v1/events": "./src/public/v1/events.ts",
86
+ "./src/*": "./src/*"
87
+ }
88
+ }
@@ -169,6 +169,13 @@ class RuntimeReadModelProjectionV2 {
169
169
 
170
170
  get cursor(): number { return this.#cursor; }
171
171
 
172
+ clear(): void {
173
+ this.#agents.clear();
174
+ this.#cursor = 0;
175
+ this.#source = { streamId: "uninitialized", revision: 1, generation: 1 };
176
+ this.#initialized = false;
177
+ }
178
+
172
179
  applySnapshot(input: unknown): boolean {
173
180
  const snapshot = parseRuntimeReadModelSnapshotV2(input);
174
181
  if (!snapshot) return false;
@@ -817,12 +824,21 @@ export class AgentsStore {
817
824
  changed = true;
818
825
  }
819
826
  }
827
+ // Do not delete raw V2 entities here: their generation fences and parent
828
+ // relationships belong to the source stream, not the display TTL. A later
829
+ // delta may wake the same logical agent. Only source tombstones, replacement
830
+ // snapshots and clear() can safely release that canonical history.
820
831
  for (const [id, at] of this.completedAt) {
821
832
  if (now - at >= COMPLETED_TOMBSTONE_MS) this.completedAt.delete(id);
822
833
  }
823
834
  return changed;
824
835
  }
825
836
 
837
+ /** Tombstones need cleanup even after the last visible terminal row expires. */
838
+ hasPendingCleanup(): boolean {
839
+ return this.completedAt.size > 0 || this.hasLingering();
840
+ }
841
+
826
842
  /** True while a terminal row is counting down, so the redraw loop can expire it. */
827
843
  hasLingering(): boolean {
828
844
  for (const row of this.roster.values()) {
@@ -1025,6 +1041,7 @@ export class AgentsStore {
1025
1041
 
1026
1042
  clear(): void {
1027
1043
  this.roster.clear();
1044
+ this.runtimeProjection.clear();
1028
1045
  this.completedAt.clear();
1029
1046
  this.canonicalRoster = false;
1030
1047
  this.viewingId = undefined;
@@ -1149,6 +1166,17 @@ export class AgentReadStoreRouter {
1149
1166
  return this.applyRuntimeDeltaResult(payload) === "applied";
1150
1167
  }
1151
1168
 
1169
+ /** Cleanup is independent of which read source is rendered. Never prune on event ingestion. */
1170
+ prune(now = Date.now()): boolean {
1171
+ const legacyChanged = this.legacy.prune(now);
1172
+ const runtimeChanged = this.runtime.prune(now);
1173
+ return legacyChanged || runtimeChanged;
1174
+ }
1175
+
1176
+ hasPendingCleanup(): boolean {
1177
+ return this.legacy.hasPendingCleanup() || this.runtime.hasPendingCleanup();
1178
+ }
1179
+
1152
1180
  fallback(): void {
1153
1181
  this.active = this.legacy;
1154
1182
  }
package/src/footer.ts CHANGED
@@ -34,6 +34,7 @@ export interface FooterParts {
34
34
  currencyRate?: number;
35
35
  git?: string;
36
36
  agentSummary?: string;
37
+ bashBgStatus?: string;
37
38
  workflowStatus?: string;
38
39
  /** Maestro workflow session/run snapshot from the UI projection channel. */
39
40
  maestroWorkflow?: MaestroWorkflowV1 | null;
@@ -469,6 +470,9 @@ export function renderFooter(p: FooterParts): string[] {
469
470
 
470
471
  const line1 = alignRight(fittedLeft.join(identitySeparator), right1, width, utils.measure);
471
472
  const lines = [utils.clip(line1, width, ell)];
473
+ if (p.bashBgStatus) {
474
+ lines.push(utils.clip(p.bashBgStatus, width, ell));
475
+ }
472
476
  if (p.usageStatus) {
473
477
  lines.push(utils.clip(p.usageStatus, width, ell));
474
478
  }
@@ -6,7 +6,7 @@ import {
6
6
  createTranscriptSelectionController,
7
7
  type TranscriptSelectionController,
8
8
  } from "./transcript-selection.ts";
9
- import { registerTerminalCleanup } from "./terminal-cleanup.ts";
9
+ import { ensureProcessTerminalCleanup } from "./terminal-cleanup.ts";
10
10
  import { readStableReference } from "./stable-reference.ts";
11
11
 
12
12
  export const COCKPIT_FULLSCREEN_WIDGET_KEY = "cockpit-fullscreen-anchor";
@@ -280,12 +280,10 @@ export function createFullscreenController(options: FullscreenControllerOptions
280
280
  } catch (error) {
281
281
  reportError(error);
282
282
  }
283
- // Crash safety: restore the terminal if the process dies while the alternate
284
- // screen is active (reload/exit/SIGINT). Ref-counted owner; kept for the
285
- // process lifetime so the comprehensive restore also covers graceful exits
286
- // where pi's own restore may be incomplete.
283
+ // Crash/graceful-exit safety survives UI disposal through one stdout writer,
284
+ // not one closure retaining each attached TUI and its render history.
287
285
  try {
288
- registerTerminalCleanup((sequence) => nextTui.terminal.write(sequence));
286
+ ensureProcessTerminalCleanup();
289
287
  } catch (error) {
290
288
  reportError(error);
291
289
  }
@@ -341,11 +339,8 @@ export function createFullscreenController(options: FullscreenControllerOptions
341
339
  unsubscribeInput = undefined;
342
340
  }
343
341
  if (tui) flushMouseReportingWrites(tui);
344
- // Keep the terminal cleanup registered for the process lifetime. On a
345
- // graceful exit, dispose runs BEFORE process.exit, so releasing the
346
- // cleanup here would remove the comprehensive exit-flush (kitty/mouse/
347
- // raw restore) that must run as a safety net in case pi's own restore is
348
- // incomplete on the user's terminal.
342
+ // The singleton process-output cleanup survives dispose, which runs before
343
+ // exit. Preserve that comprehensive kitty/mouse/raw restore safety net.
349
344
  selection.clear();
350
345
  // Leaving the alternate screen returns to the main screen; force a full
351
346
  // redraw so the main-screen document is repainted instead of diffed
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  type FullscreenController,
47
47
  } from "./fullscreen-controller.ts";
48
48
  import { BashBgOverlay } from "./bash-bg-overlay.ts";
49
+ import { renderBashBgSummary } from "./bash-bg-widget.ts";
49
50
  import { TodoOverlay } from "./todo-overlay.ts";
50
51
  import { AgentOverlay } from "./agent-overlay.ts";
51
52
  import { registerQuietTools } from "./quiet-tools.ts";
@@ -697,7 +698,7 @@ export default function (pi: ExtensionAPI): void {
697
698
  running,
698
699
  agentActive: agents.hasActive(),
699
700
  bashActive: bashBg.hasActive(),
700
- lingering: agents.hasLingering(),
701
+ lingering: agentReads.hasPendingCleanup(),
701
702
  ticking: tick !== undefined,
702
703
  });
703
704
  // True only while a redraw loop is actually running; widgets use it to avoid
@@ -715,13 +716,11 @@ export default function (pi: ExtensionAPI): void {
715
716
  const now = Date.now();
716
717
  nowSnapshot = now;
717
718
  activeBashBgOverlay?.tick(now);
718
- const agentCountBeforePrune = agents.size;
719
- agents.snapshot(now);
720
- const agentsPruned = agents.size !== agentCountBeforePrune;
719
+ const agentsPruned = agentReads.prune(now);
721
720
  if (agentsPruned) endpoints.refreshLegacy();
722
721
  if (needsTick()) {
723
722
  // Static mode keeps the loop alive only so lingering rows can expire
724
- // (prune is read-driven). The rows themselves are static, so skip the
723
+ // (including the hidden shadow and tombstones). The rows are static, so skip the
725
724
  // full repaint and repaint just once when a prune removed something.
726
725
  if (config.staticMode) {
727
726
  if (agentsPruned) req();
@@ -1574,6 +1573,19 @@ export default function (pi: ExtensionAPI): void {
1574
1573
  const branch = footerData.getGitBranch();
1575
1574
  const extensionStatuses = collectExtensionStatuses(footerData.getExtensionStatuses());
1576
1575
  const glyphs = resolveGlyphs(config.icons.mode);
1576
+ const now = nowSnapshot;
1577
+ const bashBgStatus = renderBashBgSummary(
1578
+ bashBg.snapshot(),
1579
+ Math.max(1, width - 1),
1580
+ theme,
1581
+ FOOTER_UTILS,
1582
+ {
1583
+ glyphs,
1584
+ spin: spinFrame(glyphs, now, isAnimating()),
1585
+ now,
1586
+ hideLiveDuration: config.staticMode,
1587
+ },
1588
+ )[0];
1577
1589
  const totals = getUsageTotals(ctx.sessionManager.getEntries());
1578
1590
  const usageStatus = config.usage.enabled && config.usage.footer
1579
1591
  ? usageSubsystem?.getStatus(theme, config.usage.barWidth)
@@ -1587,7 +1599,7 @@ export default function (pi: ExtensionAPI): void {
1587
1599
  formatCwd(ctx.sessionManager.getCwd()),
1588
1600
  cu?.percent ?? 0, cu?.tokens ?? 0, cu?.contextWindow ?? ctx.model?.contextWindow ?? 0,
1589
1601
  refId(totals), config.currency ?? "", config.currencyRate ?? "",
1590
- branch ?? "", usageStatus ?? "",
1602
+ branch ?? "", bashBgStatus ?? "", usageStatus ?? "",
1591
1603
  extensionStatuses.map((status) => `${status.key}:${status.text}`).join("|"),
1592
1604
  refId(maestroWorkflow), maestro.contentRevision,
1593
1605
  ].join(";");
@@ -1604,6 +1616,7 @@ export default function (pi: ExtensionAPI): void {
1604
1616
  currency: config.currency,
1605
1617
  currencyRate: config.currencyRate,
1606
1618
  git: branch ?? undefined,
1619
+ bashBgStatus,
1607
1620
  usageStatus,
1608
1621
  workflowStatus: extensionStatuses.find((status) => status.key === WORKFLOW_STATUS_KEY)?.text,
1609
1622
  maestroWorkflow,
@@ -1856,31 +1869,30 @@ export default function (pi: ExtensionAPI): void {
1856
1869
  // session_start broadcast (and its query may have raced startup).
1857
1870
  publishUiOwnership();
1858
1871
  if (!isStartedPayload(payload) || !agentReads.applyLegacyStarted(payload)) return;
1872
+ syncTick();
1859
1873
  if (usingRuntimeV2) return;
1860
1874
  agents = agentReads.current;
1861
1875
  endpoints.refreshLegacy();
1862
1876
  if (!syncActiveAgentSurface()) return;
1863
- syncTick();
1864
1877
  req();
1865
1878
  }),
1866
1879
  pi.events.on(TEAMMATE_MESSAGE_EVENT, (payload) => {
1867
1880
  if (!isProgressPayload(payload) || !agentReads.applyLegacyMessage(payload)) return;
1881
+ syncTick();
1868
1882
  if (usingRuntimeV2) return;
1869
1883
  agents = agentReads.current;
1870
1884
  endpoints.refreshLegacy();
1871
1885
  if (!syncActiveAgentSurface()) return;
1872
- syncTick();
1873
1886
  req();
1874
1887
  }),
1875
1888
  pi.events.on(TEAMMATE_COMPLETE_EVENT, (payload) => {
1889
+ // Hidden legacy completions still need row/tombstone expiry while V2 renders.
1876
1890
  if (!isCompletePayload(payload) || !agentReads.applyLegacyComplete(payload)) return;
1891
+ syncTick();
1877
1892
  if (usingRuntimeV2) return;
1878
1893
  agents = agentReads.current;
1879
1894
  endpoints.refreshLegacy();
1880
1895
  if (!syncActiveAgentSurface()) return;
1881
- // A failure that arrives after the session went idle still needs a loop to
1882
- // expire it, so the tick is re-evaluated rather than assumed to be running.
1883
- syncTick();
1884
1896
  req();
1885
1897
  }),
1886
1898
  pi.events.on(RUNTIME_READ_MODEL_SNAPSHOT_EVENT, (payload) => {
@@ -11,7 +11,7 @@
11
11
  * host; unknown modes are ignored by terminals.
12
12
  *
13
13
  * Exactly one owner registers the handlers; they are removed when the last
14
- * consumer unregisters. SIGKILL / power loss cannot run any code — the
14
+ * consumer unregisters unless the bounded process-output fallback is retained. SIGKILL / power loss cannot run any code — the
15
15
  * documented recovery for that physical case is `reset` / `stty sane`.
16
16
  */
17
17
 
@@ -32,6 +32,17 @@ export const TERMINAL_RESTORE_SEQUENCE =
32
32
  const writers = new Set<WriteFn>();
33
33
  let handlersInstalled = false;
34
34
 
35
+ // Pi's ProcessTerminal writes to stdout. This singleton references only the
36
+ // process output capability, never a controller/TUI or its render history.
37
+ const writeProcessOutput: WriteFn = (sequence) => { process.stdout.write(sequence); };
38
+
39
+ /** Keep one comprehensive exit/crash fallback across fullscreen recreation.
40
+ * Unlike consumer leases, this process-lifetime writer survives UI disposal. */
41
+ export function ensureProcessTerminalCleanup(): void {
42
+ writers.add(writeProcessOutput);
43
+ install();
44
+ }
45
+
35
46
  /** Restore raw mode to cooked, unconditionally. Primary path is Node's stream
36
47
  * API; `stty sane` is a best-effort fallback for platforms where the console
37
48
  * state is not fully reflected by setRawMode (e.g. Windows). */
@@ -100,7 +111,8 @@ function uninstall(): void {
100
111
  /**
101
112
  * Register a terminal whose alternate-screen state must be restored on process
102
113
  * exit or external SIGINT. Returns an idempotent disposer; the process handlers
103
- * are installed on first use and removed once the last consumer unregisters.
114
+ * are installed on first use and removed once the last consumer unregisters
115
+ * (unless the process-output fallback is retained).
104
116
  */
105
117
  export function registerTerminalCleanup(write: WriteFn): () => void {
106
118
  writers.add(write);
package/src/usage/core.ts CHANGED
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
 
5
5
  export type ProviderKey =
6
+ | "chatgpt"
6
7
  | "codex"
7
8
  | "claude"
8
9
  | "zai"
@@ -15,6 +16,7 @@ export type ProviderKey =
15
16
  | "moonshot"
16
17
  | "moonshot-cn";
17
18
  export type PiProviderId =
19
+ | "openai"
18
20
  | "openai-codex"
19
21
  | "anthropic"
20
22
  | "zai"
@@ -570,6 +572,16 @@ function readCodexAccountId(token: string): string | undefined {
570
572
  }
571
573
  }
572
574
 
575
+ export function getChatGPTUsage(): UsageData {
576
+ // Pi's direct-login grant is not a Codex/WHAM credential.
577
+ return {
578
+ session: 0,
579
+ weekly: 0,
580
+ quotaHidden: true,
581
+ notice: "Subscription quota is not exposed by Pi; check https://chatgpt.com/settings/usage",
582
+ };
583
+ }
584
+
573
585
  export async function fetchCodexUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
574
586
  const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
575
587
  // Without account routing, WHAM can return a different account's quota.
@@ -1183,6 +1195,7 @@ export function detectProvider(
1183
1195
  ): ProviderKey | null {
1184
1196
  if (!model || typeof model === "string") return null;
1185
1197
  switch ((model.provider || "").toLowerCase()) {
1198
+ case "openai": return "chatgpt";
1186
1199
  case "openai-codex": return "codex";
1187
1200
  case "anthropic": return "claude";
1188
1201
  case "zai": return "zai";
@@ -1200,6 +1213,7 @@ export function detectProvider(
1200
1213
 
1201
1214
  export function providerToPiProviderId(provider: ProviderKey): PiProviderId {
1202
1215
  switch (provider) {
1216
+ case "chatgpt": return "openai";
1203
1217
  case "codex": return "openai-codex";
1204
1218
  case "claude": return "anthropic";
1205
1219
  case "zai": return "zai";
@@ -1231,6 +1245,7 @@ export async function fetchAllUsages(
1231
1245
  ): Promise<UsageByProvider> {
1232
1246
  const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1233
1247
  const results: UsageByProvider = {
1248
+ chatgpt: null,
1234
1249
  codex: null,
1235
1250
  claude: null,
1236
1251
  zai: null,
@@ -1253,6 +1268,7 @@ export async function fetchAllUsages(
1253
1268
  }));
1254
1269
  };
1255
1270
 
1271
+ if (tokens.chatgpt) results.chatgpt = getChatGPTUsage();
1256
1272
  if (tokens.codex) assign("codex", fetchCodexUsage(tokens.codex, config));
1257
1273
  if (tokens.claude) {
1258
1274
  assign("claude", fetchClaudeUsageWithFallback(tokens.claude, {
@@ -44,6 +44,7 @@ import {
44
44
  fetchAllUsages,
45
45
  fetchClaudeUsageWithFallback,
46
46
  fetchCodexUsage,
47
+ getChatGPTUsage,
47
48
  fetchDeepSeekBalance,
48
49
  fetchKimiUsage,
49
50
  fetchMiniMaxUsage,
@@ -65,6 +66,7 @@ import { type CockpitConfig, DEFAULT_CONFIG } from "../types.ts";
65
66
  const EXTENSION_ID = "pi-cockpit:usage-bars";
66
67
  const USAGE_UPDATE_EVENT = `${EXTENSION_ID}:update`;
67
68
  const PROVIDERS: readonly ProviderKey[] = [
69
+ "chatgpt",
68
70
  "codex",
69
71
  "claude",
70
72
  "zai",
@@ -79,6 +81,7 @@ const PROVIDERS: readonly ProviderKey[] = [
79
81
  ];
80
82
 
81
83
  const PROVIDER_LABELS: Record<ProviderKey, string> = {
84
+ chatgpt: "ChatGPT (official)",
82
85
  codex: "Codex",
83
86
  claude: "Claude",
84
87
  zai: "ZAI Coding Plan (Global)",
@@ -624,6 +627,7 @@ export function createUsageSubsystem(options: CreateUsageSubsystemOptions): Usag
624
627
 
625
628
  const endpoints = resolveUsageEndpoints();
626
629
  const state: UsageState = {
630
+ chatgpt: null,
627
631
  codex: null,
628
632
  claude: null,
629
633
  zai: null,
@@ -731,7 +735,7 @@ export function createUsageSubsystem(options: CreateUsageSubsystemOptions): Usag
731
735
  return false;
732
736
  }
733
737
 
734
- function isClaudeSubscriptionAuth(source: string | undefined): boolean {
738
+ function isSubscriptionAuth(source: string | undefined): boolean {
735
739
  // Pi 0.84 exposes AuthResult.source as a human-readable label rather than a
736
740
  // credential-type discriminator. Keep the compatibility assumption in one
737
741
  // place until ModelRegistry exposes the resolved credential type directly.
@@ -746,7 +750,7 @@ export function createUsageSubsystem(options: CreateUsageSubsystemOptions): Usag
746
750
 
747
751
  try {
748
752
  const resolved = await ctx.modelRegistry.getProviderAuth(providerId);
749
- if (provider === "claude" && !isClaudeSubscriptionAuth(resolved?.source)) return {};
753
+ if ((provider === "claude" || provider === "chatgpt") && !isSubscriptionAuth(resolved?.source)) return {};
750
754
  const token = resolved?.auth.apiKey;
751
755
  if (token) return { token };
752
756
  // Some OAuth flows (e.g. kimi-coding) expose the token only as a Bearer
@@ -778,6 +782,7 @@ export function createUsageSubsystem(options: CreateUsageSubsystemOptions): Usag
778
782
  return;
779
783
  }
780
784
 
785
+ if (provider === "chatgpt") state.chatgpt = getChatGPTUsage();
781
786
  if (provider === "codex") state.codex = await fetchCodexUsage(credential.token, { signal });
782
787
  if (provider === "claude") state.claude = await fetchClaudeUsageWithFallback(credential.token, { signal });
783
788
  if (provider === "zai") state.zai = await fetchZaiUsage(credential.token, "zai", { endpoints, signal });
@@ -886,6 +891,20 @@ export function createUsageSubsystem(options: CreateUsageSubsystemOptions): Usag
886
891
  pollTimer.unref?.();
887
892
  }
888
893
 
894
+ const unsubscribeAccountChanged = pi.events.on("pi:account-changed", (payload: unknown) => {
895
+ if (!payload || typeof payload !== "object" || !("providerId" in payload) ||
896
+ typeof payload.providerId !== "string") return;
897
+ const provider = detectProvider({ provider: payload.providerId });
898
+ if (!provider) return;
899
+ state[provider] = null;
900
+ delete state.available[provider];
901
+ if (provider === state.activeProvider) {
902
+ providerPollController?.abort();
903
+ onStatusChange();
904
+ if (!isManualRefresh()) void poll();
905
+ }
906
+ });
907
+
889
908
  return {
890
909
  async start(ctx) {
891
910
  currentContext = ctx;
@@ -983,6 +1002,7 @@ export function createUsageSubsystem(options: CreateUsageSubsystemOptions): Usag
983
1002
  },
984
1003
 
985
1004
  dispose() {
1005
+ unsubscribeAccountChanged();
986
1006
  providerPollController?.abort();
987
1007
  providerPollController = undefined;
988
1008
  sessionController?.abort();
@@ -1,11 +1,28 @@
1
- import type { TUI } from "@earendil-works/pi-tui";
1
+ import { Container, type TUI } from "@earendil-works/pi-tui";
2
2
  import { readStableReference } from "./stable-reference.ts";
3
3
 
4
4
  const VIEWPORT_STABILITY_MARKER = Symbol.for("pi-cockpit.viewport-stability");
5
5
 
6
6
  type ApplyLineResets = (lines: string[]) => string[];
7
7
 
8
- interface ViewportTuiInternals {
8
+ interface ComponentLayout {
9
+ width: number;
10
+ children: Array<{ component: LayoutComponent; height: number }>;
11
+ }
12
+
13
+ interface LayoutComponent {
14
+ render?: (width: number) => string[];
15
+ children?: LayoutComponent[];
16
+ mouseLayout?: ComponentLayout;
17
+ }
18
+
19
+ interface HiddenComponent {
20
+ component: LayoutComponent;
21
+ start: number;
22
+ end: number;
23
+ }
24
+
25
+ interface ViewportTuiInternals extends LayoutComponent {
9
26
  applyLineResets?: ApplyLineResets;
10
27
  previousLines?: string[];
11
28
  previousViewportTop?: number;
@@ -51,67 +68,48 @@ function isKittyImageLine(line: string | undefined): boolean {
51
68
  return typeof line === "string" && line.includes("\x1b_G");
52
69
  }
53
70
 
54
- const ANSI_ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g;
55
-
56
- function plainLine(line: string | undefined): string {
57
- return line?.replace(ANSI_ESCAPE, "").trim() ?? "";
71
+ // Container records these heights during the actual render. Never render a
72
+ // component twice or infer row identity from its changing text.
73
+ function measuredComponents(
74
+ root: LayoutComponent,
75
+ lineCount: number,
76
+ width: number,
77
+ ): HiddenComponent[] | undefined {
78
+ if (root.render !== Container.prototype.render) return undefined;
79
+ const result: HiddenComponent[] = [];
80
+ const seen = new Set<LayoutComponent>();
81
+ const walk = (node: LayoutComponent, start: number, height: number): boolean => {
82
+ if (height === 0) return true;
83
+ if (seen.has(node)) return false;
84
+ seen.add(node);
85
+ const layout = node.mouseLayout;
86
+ if (node.render === Container.prototype.render && Array.isArray(node.children)) {
87
+ if (!layout || layout.width !== width || layout.children.length !== node.children.length) return false;
88
+ let total = 0;
89
+ for (let index = 0; index < layout.children.length; index += 1) {
90
+ const child = layout.children[index]!;
91
+ if (child.component !== node.children[index] || !Number.isSafeInteger(child.height) || child.height < 0) return false;
92
+ total += child.height;
93
+ }
94
+ if (total !== height) return false;
95
+ let offset = start;
96
+ for (const child of layout.children) {
97
+ if (!walk(child.component, offset, child.height)) return false;
98
+ offset += child.height;
99
+ }
100
+ return true;
101
+ }
102
+ result.push({ component: node, start, end: start + height });
103
+ return true;
104
+ };
105
+ return Array.isArray(root.children) && walk(root, 0, lineCount) ? result : undefined;
58
106
  }
59
107
 
60
- function lineCounts(lines: readonly string[], start: number, end: number): Map<string, number> {
61
- const counts = new Map<string, number>();
62
- const lo = Math.max(0, start);
63
- const hi = Math.min(end, lines.length);
64
- for (let index = lo; index < hi; index += 1) {
65
- const key = plainLine(lines[index]);
66
- if (!key) continue;
67
- counts.set(key, (counts.get(key) ?? 0) + 1);
68
- }
69
- return counts;
70
- }
71
-
72
- function mutableLineIdentity(line: string | undefined): string {
73
- return plainLine(line).toLowerCase().replace(/\d+(?:\.\d+)?/g, "#");
74
- }
75
-
76
- function mutableRowsCrossViewportBoundary(
77
- previousLines: readonly string[],
78
- nextLines: readonly string[],
79
- hiddenEnd: number,
80
- ): boolean {
81
- if (hiddenEnd <= 0 || hiddenEnd >= previousLines.length || hiddenEnd >= nextLines.length) return false;
82
- const previousHidden = mutableLineIdentity(previousLines[hiddenEnd - 1]);
83
- const previousVisible = mutableLineIdentity(previousLines[hiddenEnd]);
84
- const nextHidden = mutableLineIdentity(nextLines[hiddenEnd - 1]);
85
- const nextVisible = mutableLineIdentity(nextLines[hiddenEnd]);
86
- return previousHidden !== ""
87
- && previousVisible !== ""
88
- && previousHidden !== previousVisible
89
- && previousHidden === nextVisible
90
- && previousVisible === nextHidden;
91
- }
92
-
93
- function crossesViewportBoundary(
94
- previousLines: readonly string[],
95
- nextLines: readonly string[],
96
- hiddenEnd: number,
97
- ): boolean {
98
- if (mutableRowsCrossViewportBoundary(previousLines, nextLines, hiddenEnd)) return true;
99
- // A row crosses the boundary when its hidden- and visible-side counts move
100
- // in opposite directions (net flow across). Counting both regions — rather
101
- // than tracking unique positions — detects crossings for duplicated lines
102
- // (identical agent rows, separators) that a position map silently drops,
103
- // while in-place edits of hidden rows change only one side and stay frozen.
104
- const prevHidden = lineCounts(previousLines, 0, hiddenEnd);
105
- const prevVisible = lineCounts(previousLines, hiddenEnd, previousLines.length);
106
- const nextHidden = lineCounts(nextLines, 0, hiddenEnd);
107
- const nextVisible = lineCounts(nextLines, hiddenEnd, nextLines.length);
108
- const keys = new Set([...prevHidden.keys(), ...prevVisible.keys(), ...nextHidden.keys(), ...nextVisible.keys()]);
109
- for (const key of keys) {
110
- const hiddenDelta = (nextHidden.get(key) ?? 0) - (prevHidden.get(key) ?? 0);
111
- const visibleDelta = (nextVisible.get(key) ?? 0) - (prevVisible.get(key) ?? 0);
112
- if (hiddenDelta !== 0 && visibleDelta !== 0 && Math.sign(hiddenDelta) !== Math.sign(visibleDelta)) return true;
113
- }
114
- return false;
108
+ function sameHiddenComponents(previous: readonly HiddenComponent[], next: readonly HiddenComponent[]): boolean {
109
+ return previous.length === next.length && previous.every((span, index) => {
110
+ const other = next[index]!;
111
+ return span.component === other.component && span.start === other.start && span.end === other.end;
112
+ });
115
113
  }
116
114
 
117
115
  function prototypeMethodSlot(target: object): ApplyLineResetsSlot | undefined {
@@ -164,10 +162,9 @@ function resolveApplyLineResetsSlot(internals: ViewportTuiInternals): ApplyLineR
164
162
  }
165
163
 
166
164
  /**
167
- * Treats the hidden viewport prefix as immutable scrollback while frame size is
168
- * stable. The next frame keeps the terminal's actual hidden text, so visible
169
- * changes can use pi-tui's differential path instead of clearing and replaying
170
- * the screen. Structural, resize and image changes retain the native path.
165
+ * Preserves fully hidden components only while their instances and measured
166
+ * positions stay unchanged. Boundary-spanning components, missing layout
167
+ * evidence, structural changes, resize and images retain the native path.
171
168
  */
172
169
  export function attachViewportStability(tui: TUI): ViewportStabilityPatch {
173
170
  try {
@@ -183,11 +180,17 @@ export function attachViewportStability(tui: TUI): ViewportStabilityPatch {
183
180
  if (existing) return { active: true, detach: existing.retain() };
184
181
 
185
182
  let dispatches = 0;
183
+ const frames = new WeakMap<ViewportTuiInternals, { baseline: string[]; spans: HiddenComponent[] }>();
186
184
  const wrapped: ApplyLineResets = function (this: ViewportTuiInternals, lines: string[]): string[] {
187
185
  dispatches += 1;
188
186
  const nextLines = original.call(this, lines);
189
187
  const previousLines = this.previousLines;
190
188
  const viewportTop = this.previousViewportTop;
189
+ const hiddenEnd = typeof viewportTop === "number" && Number.isFinite(viewportTop)
190
+ ? Math.max(0, Math.min(nextLines.length, Math.trunc(viewportTop)))
191
+ : 0;
192
+ const spans = measuredComponents(this, nextLines.length, this.terminal?.columns ?? 0);
193
+ const previousFrame = frames.get(this);
191
194
  if (
192
195
  Array.isArray(previousLines)
193
196
  && previousLines.length === nextLines.length
@@ -196,20 +199,30 @@ export function attachViewportStability(tui: TUI): ViewportStabilityPatch {
196
199
  && viewportTop > 0
197
200
  && this.previousHeight === this.terminal?.rows
198
201
  && this.previousWidth === this.terminal?.columns
202
+ && spans && previousFrame?.baseline === previousLines
203
+ && sameHiddenComponents(previousFrame.spans, spans)
199
204
  ) {
200
- const hiddenEnd = Math.min(previousLines.length, Math.trunc(viewportTop));
201
- const hiddenHasKittyImage = previousLines.slice(0, hiddenEnd).some(isKittyImageLine)
202
- || nextLines.slice(0, hiddenEnd).some(isKittyImageLine);
203
- // Equal total height does not prove row identity. If a stable row crossed
204
- // the viewport boundary, freezing by index would duplicate it across the
205
- // hidden and visible regions; retain pi-tui's canonical full redraw instead.
206
- if (!hiddenHasKittyImage && !crossesViewportBoundary(previousLines, nextLines, hiddenEnd)) {
207
- for (let index = 0; index < hiddenEnd; index += 1) {
208
- const previous = previousLines[index];
209
- if (previous !== undefined) nextLines[index] = previous;
205
+ let hiddenHasKittyImage = false;
206
+ for (let index = 0; index < hiddenEnd; index += 1) {
207
+ if (isKittyImageLine(previousLines[index]) || isKittyImageLine(nextLines[index])) {
208
+ hiddenHasKittyImage = true;
209
+ break;
210
+ }
211
+ }
212
+ if (!hiddenHasKittyImage) {
213
+ for (const span of spans) {
214
+ if (span.end > hiddenEnd) break;
215
+ for (let index = span.start; index < span.end; index += 1) nextLines[index] = previousLines[index]!;
210
216
  }
211
217
  }
212
218
  }
219
+ if (spans) {
220
+ // The native no-change path keeps the old array rather than adopting
221
+ // nextLines. Track the exact baseline it will retain in either case.
222
+ const unchanged = previousLines?.length === nextLines.length
223
+ && nextLines.every((line, index) => line === previousLines[index]);
224
+ frames.set(this, { baseline: unchanged ? previousLines! : nextLines, spans });
225
+ } else frames.delete(this);
213
226
  return nextLines;
214
227
  };
215
228
  let references = 1;