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 +88 -88
- package/src/agents-store.ts +28 -0
- package/src/footer.ts +4 -0
- package/src/fullscreen-controller.ts +6 -11
- package/src/index.ts +23 -11
- package/src/terminal-cleanup.ts +14 -2
- package/src/usage/core.ts +16 -0
- package/src/usage/extension.ts +22 -2
- package/src/viewport-stability.ts +88 -75
package/package.json
CHANGED
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-cockpit",
|
|
3
|
-
"version": "0.24.
|
|
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.
|
|
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
|
+
}
|
package/src/agents-store.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
284
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
345
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
// (
|
|
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) => {
|
package/src/terminal-cleanup.ts
CHANGED
|
@@ -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, {
|
package/src/usage/extension.ts
CHANGED
|
@@ -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
|
|
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" && !
|
|
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
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
function
|
|
57
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (
|
|
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;
|