pi-cockpit 0.24.2 → 0.24.4

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A responsive **agent cockpit** for [Pi](https://pi.dev): wide terminals get a docked Maestro operations sidebar, narrow terminals automatically fall back to the existing Todo and Agent widgets, and every layout keeps the Starship-style footer.
4
4
 
5
- It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.24.2**.
5
+ It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.24.3**, with optional Teammate peer `^2.8.1` and Node.js minimum 22.19.0; Settings-Core 0.2.3 is unchanged. See [RELEASE.md](../../RELEASE.md) for suite dependency changes and verification scope.
6
6
 
7
7
  The dock is a non-capturing top-right overlay. Cockpit reserves its columns by wrapping the active TUI renderer at runtime, so the Pi workspace reflows instead of rendering underneath it. No Pi source files are modified.
8
8
 
@@ -25,7 +25,7 @@ The dock is a non-capturing top-right overlay. Cockpit reserves its columns by w
25
25
  `pi-cockpit` comes automatically with the orchestration layer — installing `pi-maestro-flow` pulls `pi-cockpit` and registers it into `settings.packages` on postinstall, no manual setup required. To use it on its own:
26
26
 
27
27
  ```bash
28
- pi install npm:pi-cockpit@0.24.2 # standalone from npm
28
+ pi install npm:pi-cockpit@0.24.3 # standalone from npm
29
29
  # or, for one run:
30
30
  pi -e ./packages/pi-cockpit
31
31
  ```
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "pi-cockpit",
3
- "version": "0.24.2",
3
+ "version": "0.24.4",
4
4
  "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
6
9
  "keywords": [
7
10
  "pi-package",
8
11
  "pi",
@@ -18,7 +21,7 @@
18
21
  "README.md"
19
22
  ],
20
23
  "scripts": {
21
- "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",
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",
22
25
  "typecheck": "tsc --noEmit",
23
26
  "prepack": "node ../../scripts/bundle-settings-core.mjs",
24
27
  "postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
@@ -43,7 +46,7 @@
43
46
  "@earendil-works/pi-ai": "*",
44
47
  "@earendil-works/pi-coding-agent": "*",
45
48
  "@earendil-works/pi-tui": "*",
46
- "pi-maestro-teammate": "^2.5.0",
49
+ "pi-maestro-teammate": "^2.8.1",
47
50
  "typebox": "*"
48
51
  },
49
52
  "peerDependenciesMeta": {
@@ -72,7 +75,7 @@
72
75
  "@earendil-works/pi-coding-agent": "0.99.0",
73
76
  "@earendil-works/pi-tui": "0.99.0",
74
77
  "@types/node": "^22.0.0",
75
- "pi-maestro-teammate": "2.8.0",
78
+ "pi-maestro-teammate": "2.8.1",
76
79
  "typebox": "1.3.27",
77
80
  "typescript": "^5.5.0"
78
81
  },
@@ -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) => {
@@ -483,6 +483,8 @@ export function toolResultCard(
483
483
  o: {
484
484
  name: string;
485
485
  ok?: boolean;
486
+ /** Pre-colored glyph overriding the ✓/✗ status mark (e.g. "←" for incoming messages). */
487
+ mark?: string;
486
488
  arg?: string;
487
489
  summary?: string;
488
490
  rows?: string[];
@@ -493,9 +495,9 @@ export function toolResultCard(
493
495
  const bold = theme.bold ?? ((text: string) => text);
494
496
  return {
495
497
  render(width: number): string[] {
496
- const mark = o.ok === false
498
+ const mark = o.mark ?? (o.ok === false
497
499
  ? theme.fg("error", quietStatusMark("failure"))
498
- : theme.fg("success", quietStatusMark("success"));
500
+ : theme.fg("success", quietStatusMark("success")));
499
501
  const safeWidth = Math.max(1, width);
500
502
  if (safeWidth <= 1) return [];
501
503
  const label = `${mark} ${theme.fg("toolTitle", bold(o.name))}${o.arg ? ` ${theme.fg("accent", o.arg)}` : ""}${o.summary ? ` ${theme.fg("dim", `· ${o.summary}`)}` : ""}`;
@@ -1,7 +1,8 @@
1
1
  import { stripVTControlCharacters } from "node:util";
2
2
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
3
- import { Box, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
3
+ import type { Component } from "@earendil-works/pi-tui";
4
4
  import { tuiT } from "./tui-i18n.ts";
5
+ import { toolResultCard } from "./quiet-tools.ts";
5
6
 
6
7
  export const TEAMMATE_MESSAGE_CUSTOM_TYPE = "teammate-message";
7
8
 
@@ -93,27 +94,6 @@ function kindLabel(kind: string | undefined): string | undefined {
93
94
  return key ? tuiT(key) : kind;
94
95
  }
95
96
 
96
- const COLLAPSED_BODY_LINES = 2;
97
-
98
- function collapsedBody(value: string, width: number): string {
99
- const text = value.replace(/\s+/g, " ").trim();
100
- if (!text) return "";
101
- const lineWidth = Math.max(1, width);
102
- const lines = wrapTextWithAnsi(text, lineWidth);
103
- if (lines.length <= COLLAPSED_BODY_LINES) return lines.join("\n");
104
-
105
- const preview = lines.slice(0, COLLAPSED_BODY_LINES);
106
- const last = preview.length - 1;
107
- const ellipsisWidth = Math.max(1, visibleWidth("…"));
108
- const prefix = truncateToWidth(
109
- preview[last] ?? "",
110
- Math.max(0, lineWidth - ellipsisWidth),
111
- "",
112
- );
113
- preview[last] = `${prefix}…`;
114
- return preview.join("\n");
115
- }
116
-
117
97
  export function renderIncomingTeammateMessage(
118
98
  message: { content: MessageContent; details?: TeammateMessageDetails },
119
99
  options: { expanded: boolean; outputPad: number },
@@ -121,26 +101,19 @@ export function renderIncomingTeammateMessage(
121
101
  ): Component {
122
102
  const envelope = parseTeammateMessageEnvelope(message.content, message.details);
123
103
  const kind = kindLabel(envelope.kind);
124
- const header = [
125
- `${theme.fg("accent", "←")} ${theme.bold(tuiT("message.receivedFrom", { sender: envelope.sender }))}`,
126
- kind ? theme.fg("muted", `· ${kind}`) : "",
127
- ].filter(Boolean).join(" ");
128
- const box = new Box(options.outputPad, 1, (text) => theme.bg("customMessageBg", text));
129
- box.addChild({
130
- render(width: number): string[] {
131
- const sections = [header];
132
- if (options.expanded && envelope.guidance) sections.push(theme.fg("dim", envelope.guidance));
133
- if (envelope.body) {
134
- const body = options.expanded ? envelope.body : collapsedBody(envelope.body, width);
135
- sections.push(theme.fg("text", body));
136
- } else if (!options.expanded && envelope.guidance) {
137
- sections.push(theme.fg("text", collapsedBody(envelope.guidance, width)));
138
- }
139
- return new Text(sections.join("\n"), 0, 0).render(width);
140
- },
141
- invalidate(): void {},
104
+ const groups: string[][] = [];
105
+ if (options.expanded && envelope.guidance) {
106
+ groups.push(envelope.guidance.split("\n").map((line) => theme.fg("dim", line)));
107
+ }
108
+ const bodySource = envelope.body || (!options.expanded ? envelope.guidance : "");
109
+ if (bodySource) groups.push(bodySource.split("\n"));
110
+ return toolResultCard(theme, {
111
+ name: tuiT("message.receivedFrom", { sender: envelope.sender }),
112
+ mark: theme.fg("accent", "←"),
113
+ ...(kind ? { summary: kind } : {}),
114
+ groups,
115
+ maxBodyRows: options.expanded ? undefined : 8,
142
116
  });
143
- return box;
144
117
  }
145
118
 
146
119
  export function registerTeammateMessageRenderer(
@@ -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);
@@ -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;