pi-cockpit 0.24.0 → 0.24.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-maestro-settings-core",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Versioned Settings and i18n contracts shared by Pi Maestro plugins.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,22 @@
1
+ /** Classify only the supplied host version; prereleases and unsupported hosts are unknown. */
2
+ export function getPiHostMode(version: unknown): "native" | "legacy" | "unknown" {
3
+ if (typeof version !== "string") return "unknown";
4
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
5
+ if (!match || match[0] !== version) return "unknown";
6
+
7
+ const [major, minor, patch] = match.slice(1, 4).map(Number);
8
+ if (![major, minor, patch].every(Number.isSafeInteger)) return "unknown";
9
+ if (major > 0 || minor >= 99) return "native";
10
+ if (minor >= 87) return "legacy";
11
+ return "unknown";
12
+ }
13
+
14
+ /** Missing native APIs never authorize legacy fallback on a native or unknown host. */
15
+ export function getPiFeatureOwner(
16
+ version: unknown,
17
+ apiAvailable: boolean,
18
+ ): "native" | "legacy" | "unavailable" {
19
+ const mode = getPiHostMode(version);
20
+ if (mode === "legacy") return "legacy";
21
+ return mode === "native" && apiAvailable ? "native" : "unavailable";
22
+ }
@@ -1,6 +1,7 @@
1
1
  export * from "./events.ts";
2
2
  export * from "./provider.ts";
3
3
  export * from "./schema.ts";
4
+ export { getPiHostMode, getPiFeatureOwner } from "../../pi-host.ts";
4
5
 
5
6
  // The keyboard-shortcut helpers live in the shared UI primitives module so
6
7
  // Cockpit, Flow, and Teammate all import the same implementation. Keep the
package/package.json CHANGED
@@ -1,85 +1,85 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.24.0",
4
- "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
- "type": "module",
6
- "keywords": [
7
- "pi-package",
8
- "pi",
9
- "pi-extension",
10
- "tui",
11
- "teammate",
12
- "todo"
13
- ],
14
- "license": "MIT",
15
- "files": [
16
- "src/",
17
- "themes/",
18
- "README.md"
19
- ],
20
- "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/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
- "typecheck": "tsc --noEmit",
23
- "prepack": "node ../../scripts/bundle-settings-core.mjs",
24
- "postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
25
- },
26
- "pi": {
27
- "extensions": [
28
- "./src/extension/index.ts"
29
- ],
30
- "themes": [
31
- "./themes"
32
- ]
33
- },
34
- "dependencies": {
35
- "pi-maestro-settings-core": "0.2.2",
36
- "proper-lockfile": "4.1.2"
37
- },
38
- "bundledDependencies": [
39
- "pi-maestro-settings-core"
40
- ],
41
- "peerDependencies": {
42
- "@earendil-works/pi-agent-core": "*",
43
- "@earendil-works/pi-ai": "*",
44
- "@earendil-works/pi-coding-agent": "*",
45
- "@earendil-works/pi-tui": "*",
46
- "pi-maestro-teammate": "^2.5.0",
47
- "typebox": "*"
48
- },
49
- "peerDependenciesMeta": {
50
- "@earendil-works/pi-agent-core": {
51
- "optional": true
52
- },
53
- "@earendil-works/pi-ai": {
54
- "optional": true
55
- },
56
- "@earendil-works/pi-coding-agent": {
57
- "optional": true
58
- },
59
- "@earendil-works/pi-tui": {
60
- "optional": true
61
- },
62
- "pi-maestro-teammate": {
63
- "optional": true
64
- },
65
- "typebox": {
66
- "optional": true
67
- }
68
- },
69
- "devDependencies": {
70
- "@earendil-works/pi-agent-core": "0.84.4",
71
- "@earendil-works/pi-ai": "0.84.4",
72
- "@earendil-works/pi-coding-agent": "0.84.4",
73
- "@earendil-works/pi-tui": "0.84.4",
74
- "@types/node": "^22.0.0",
75
- "pi-maestro-teammate": "2.7.0",
76
- "typebox": "1.3.7",
77
- "typescript": "^5.5.0"
78
- },
79
- "main": "./src/index.ts",
80
- "exports": {
81
- ".": "./src/index.ts",
82
- "./v1/events": "./src/public/v1/events.ts",
83
- "./src/*": "./src/*"
84
- }
85
- }
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.24.1",
4
+ "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "tui",
11
+ "teammate",
12
+ "todo"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "src/",
17
+ "themes/",
18
+ "README.md"
19
+ ],
20
+ "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",
22
+ "typecheck": "tsc --noEmit",
23
+ "prepack": "node ../../scripts/bundle-settings-core.mjs",
24
+ "postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
25
+ },
26
+ "pi": {
27
+ "extensions": [
28
+ "./src/extension/index.ts"
29
+ ],
30
+ "themes": [
31
+ "./themes"
32
+ ]
33
+ },
34
+ "dependencies": {
35
+ "pi-maestro-settings-core": "0.2.3",
36
+ "proper-lockfile": "4.1.2"
37
+ },
38
+ "bundledDependencies": [
39
+ "pi-maestro-settings-core"
40
+ ],
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-agent-core": "*",
43
+ "@earendil-works/pi-ai": "*",
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*",
46
+ "pi-maestro-teammate": "^2.5.0",
47
+ "typebox": "*"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@earendil-works/pi-agent-core": {
51
+ "optional": true
52
+ },
53
+ "@earendil-works/pi-ai": {
54
+ "optional": true
55
+ },
56
+ "@earendil-works/pi-coding-agent": {
57
+ "optional": true
58
+ },
59
+ "@earendil-works/pi-tui": {
60
+ "optional": true
61
+ },
62
+ "pi-maestro-teammate": {
63
+ "optional": true
64
+ },
65
+ "typebox": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "devDependencies": {
70
+ "@earendil-works/pi-agent-core": "0.99.0",
71
+ "@earendil-works/pi-ai": "0.99.0",
72
+ "@earendil-works/pi-coding-agent": "0.99.0",
73
+ "@earendil-works/pi-tui": "0.99.0",
74
+ "@types/node": "^22.0.0",
75
+ "pi-maestro-teammate": "2.7.2",
76
+ "typebox": "1.3.27",
77
+ "typescript": "^5.5.0"
78
+ },
79
+ "main": "./src/index.ts",
80
+ "exports": {
81
+ ".": "./src/index.ts",
82
+ "./v1/events": "./src/public/v1/events.ts",
83
+ "./src/*": "./src/*"
84
+ }
85
+ }
package/src/agent-bar.ts CHANGED
@@ -8,13 +8,12 @@
8
8
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
9
9
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
10
10
  import type { CockpitEndpoint } from "./endpoint-store.ts";
11
- import { formatAgentMetric } from "./render.ts";
12
11
  import type { SessionUiState } from "./session-ui-state.ts";
13
12
  import { formatUnreadCount, type SessionTab } from "./session-tabs.ts";
14
13
  import type { AgentRow } from "./types.ts";
15
14
  import { effectiveAgentStatus, isCliAgent, type AgentDisplayStatus } from "./agents-store.ts";
16
15
  import { visibleAgentRows } from "./stack-widget.ts";
17
- import { tuiT } from "./tui-i18n.ts";
16
+ import { tuiStatus, tuiT } from "./tui-i18n.ts";
18
17
  import { ownerDisplayToken } from "./window-autocomplete.ts";
19
18
 
20
19
  export const SESSION_BAR_WIDGET_KEY = "cockpit-session-bar";
@@ -98,46 +97,23 @@ export function statusColor(status: AgentDisplayStatus): ThemeColor {
98
97
  }
99
98
  }
100
99
 
101
- function chip(
102
- label: string,
103
- active: boolean,
104
- color: ThemeColor,
105
- theme: Theme,
106
- unread = 0,
107
- activity?: string,
108
- attention = false,
109
- outcome?: { status: "completed" | "failed" | "terminated"; message?: string },
110
- cli = false,
111
- ): string {
100
+ /** Original chip shape retained exclusively for the legacy Session Bar facade. */
101
+ function legacyChip(label: string, active: boolean, color: ThemeColor, theme: Theme): string {
112
102
  const text = `@${label}`;
113
- const cliBadge = cli ? theme.fg("accent", ` ${tuiT("widget.agent.cli")}`) : "";
114
- const badge = unread > 0 ? theme.fg("warning", ` •${formatUnreadCount(unread)}`) : "";
115
- const activitySuffix = activity ? theme.fg("dim", ` · ${activity}`) : "";
116
- const attentionPrefix = attention ? theme.fg("error", "!") : "";
117
- const outcomeSuffix = outcome
118
- ? outcome.status === "failed"
119
- ? theme.fg("error", ` ✗${outcome.message ? ` ${outcome.message}` : ""}`)
120
- : outcome.status === "terminated"
121
- ? theme.fg("warning", " ✗")
122
- : theme.fg("success", " ✓")
123
- : "";
124
103
  return active
125
- ? `${attentionPrefix}${theme.fg(color, "▸")} ${theme.fg(color, theme.bold(text))}${cliBadge}${outcomeSuffix}${activitySuffix}${badge}`
126
- : `${attentionPrefix}${theme.fg(color, text)}${cliBadge}${outcomeSuffix}${activitySuffix}${badge}`;
104
+ ? `${theme.fg(color, "▸")} ${theme.fg(color, theme.bold(text))}`
105
+ : theme.fg(color, text);
127
106
  }
128
107
 
129
108
  export type AgentBarStatus = AgentDisplayStatus | "idle";
130
109
 
131
110
  interface AgentBarTab extends SessionTab {
132
- endpoint: CockpitEndpoint;
133
111
  status: AgentBarStatus;
134
112
  color: ThemeColor;
135
- /** Current tool name while the agent is live; shown after the chip label. */
136
- activity?: string;
137
- /** Stalled state beyond the color channel: a leading error `!`. */
138
- attention?: boolean;
139
- /** Terminal outcome badge: ✓ completed, ✗ failed (with reason) or terminated. */
140
- outcome?: { status: "completed" | "failed" | "terminated"; message?: string };
113
+ /** The rail has one suffix slot: live tool name when present, otherwise localized state. */
114
+ suffix: string;
115
+ /** Last terminal outcome selects the status glyph, but never exposes its error message. */
116
+ outcome?: "completed" | "failed" | "terminated";
141
117
  /** External CLI backend (ACP `cli/<tool>` route): shown as an accent badge after the label. */
142
118
  cli?: boolean;
143
119
  }
@@ -151,8 +127,7 @@ function endpointStatus(endpoint: CockpitEndpoint, now: number, mainRunning: boo
151
127
  return "running";
152
128
  }
153
129
 
154
- function endpointColor(endpoint: CockpitEndpoint, status: AgentBarStatus, now: number, selected: boolean): ThemeColor {
155
- if (selected) return "accent";
130
+ function endpointColor(endpoint: CockpitEndpoint, status: AgentBarStatus, now: number): ThemeColor {
156
131
  if (endpoint.kind === "root") return status === "running" ? "warning" : "muted";
157
132
  if (endpoint.agentRow) return agentSessionColor(endpoint.agentRow, now, status === "idle" ? undefined : status);
158
133
  if (status === "done" || status === "sleeping" || status === "terminated") return "muted";
@@ -160,6 +135,35 @@ function endpointColor(endpoint: CockpitEndpoint, status: AgentBarStatus, now: n
160
135
  return assignedAgentColor(endpoint.correlationId ?? endpoint.id);
161
136
  }
162
137
 
138
+ function statusVisual(
139
+ status: AgentBarStatus,
140
+ outcome?: AgentBarTab["outcome"],
141
+ ): { glyph: string; color: ThemeColor } {
142
+ if (outcome === "completed") return { glyph: "✓", color: "success" };
143
+ if (outcome === "failed") return { glyph: "✗", color: "error" };
144
+ if (outcome === "terminated") return { glyph: "✗", color: "warning" };
145
+ if (status === "failed") return { glyph: "✗", color: "error" };
146
+ if (status === "stalled") return { glyph: "!", color: "error" };
147
+ if (status === "terminated") return { glyph: "✗", color: "warning" };
148
+ if (status === "done" || status === "result-ready") return { glyph: "✓", color: "success" };
149
+ if (status === "retrying") return { glyph: "↻", color: "warning" };
150
+ if (status === "running") return { glyph: "●", color: "warning" };
151
+ if (status === "pending") return { glyph: "○", color: "dim" };
152
+ return { glyph: "○", color: "muted" };
153
+ }
154
+
155
+ function railChip(tab: AgentBarTab, active: boolean, theme: Theme): string {
156
+ const visual = statusVisual(tab.status, tab.outcome);
157
+ const selected = active ? `${theme.fg("accent", "▸")} ` : "";
158
+ const status = theme.fg(visual.color, visual.glyph);
159
+ const identity = theme.fg(tab.color, active ? theme.bold(`@${tab.label}`) : `@${tab.label}`);
160
+ const cli = tab.cli ? theme.fg("accent", ` ${tuiT("widget.agent.cli")}`) : "";
161
+ const unreadCount = tab.unread ?? 0;
162
+ const unread = unreadCount > 0 ? theme.fg("warning", ` •${formatUnreadCount(unreadCount)}`) : "";
163
+ const suffix = tab.suffix ? theme.fg("dim", ` · ${tab.suffix}`) : "";
164
+ return `${selected}${status} ${identity}${cli}${unread}${suffix}`;
165
+ }
166
+
163
167
  export interface AgentBarRenderOptions {
164
168
  mainRunning?: boolean;
165
169
  now?: number;
@@ -177,21 +181,10 @@ const CHIP_SEPARATOR = " ";
177
181
  * and its context visible no matter how many agents exist.
178
182
  */
179
183
  function renderAgentChipLine(tabs: AgentBarTab[], selectedId: string, width: number, theme: Theme): string {
180
- const w = Math.max(1, width);
184
+ if (width <= 0 || tabs.length === 0) return "";
185
+ const w = width;
181
186
  const selectedIndex = Math.max(0, tabs.findIndex((tab) => tab.id === selectedId));
182
- const chips = tabs.map((tab) => chip(
183
- tab.label,
184
- tab.id === selectedId,
185
- tab.color,
186
- theme,
187
- tab.unread,
188
- tab.activity,
189
- tab.attention,
190
- tab.outcome
191
- ? { status: tab.outcome.status, message: tab.outcome.message ? truncateToWidth(tab.outcome.message, 24, "…") : undefined }
192
- : undefined,
193
- tab.cli,
194
- ));
187
+ const chips = tabs.map((tab) => railChip(tab, tab.id === selectedId, theme));
195
188
  const chipWidths = chips.map((text) => visibleWidth(text));
196
189
  const sepWidth = visibleWidth(CHIP_SEPARATOR);
197
190
  const totalWidth = chipWidths.reduce((sum, chipWidth) => sum + chipWidth + sepWidth, 0) - sepWidth;
@@ -244,7 +237,8 @@ export function renderSessionBarLine(
244
237
  ): string {
245
238
  // A live main-screen row must not write the terminal's final column: doing so
246
239
  // arms auto-wrap, while pi-tui still tracks the cursor on the original row.
247
- const w = Math.max(1, width - 1);
240
+ const w = Math.max(0, width - 1);
241
+ if (w === 0) return "";
248
242
  if (!shortcutHint) return renderContent(w);
249
243
  const hintSpec = typeof shortcutHint === "string" ? { text: shortcutHint, color: "dim" as const } : shortcutHint;
250
244
  const hint = theme.fg(hintSpec.color ?? "dim", hintSpec.text);
@@ -265,7 +259,24 @@ export function renderAgentBar(
265
259
  theme: Theme,
266
260
  options: AgentBarRenderOptions = {},
267
261
  ): string[] {
268
- if (endpoints.length === 0) return [truncateToWidth(chip(MAIN_SESSION_LABEL, true, "accent", theme), Math.max(1, width - 1), "…")];
262
+ if (endpoints.length === 0) {
263
+ const status: AgentBarStatus = options.mainRunning ? "running" : "idle";
264
+ const fallback: AgentBarTab = {
265
+ id: "root",
266
+ label: MAIN_SESSION_LABEL,
267
+ ordinal: 0,
268
+ unread: 0,
269
+ status,
270
+ color: status === "running" ? "warning" : "muted",
271
+ suffix: tuiStatus(status),
272
+ };
273
+ return [renderSessionBarLine(
274
+ (availableWidth) => truncateToWidth(railChip(fallback, true, theme), availableWidth, "…"),
275
+ width,
276
+ theme,
277
+ options.shortcutHint,
278
+ )];
279
+ }
269
280
  const now = options.now ?? 0;
270
281
  // A stale selection (id no longer in the endpoint list) must not leave the
271
282
  // bar without a highlighted chip: fall back to root / first endpoint.
@@ -275,11 +286,11 @@ export function renderAgentBar(
275
286
  ? requestedId
276
287
  : fallbackId;
277
288
  const tabs: AgentBarTab[] = endpoints.map((endpoint) => {
278
- const selected = endpoint.id === selectedId;
279
289
  const status = endpointStatus(endpoint, now, options.mainRunning === true);
280
- const live = status === "running" || status === "retrying";
290
+ const liveAgent = endpoint.kind === "agent" && (status === "running" || status === "retrying");
291
+ const activeTool = liveAgent ? endpoint.agentRow?.activeTool ?? endpoint.externalAgent?.activeTool : undefined;
281
292
  const outcome = status === "done" || status === "failed" || status === "terminated" || status === "sleeping"
282
- ? endpoint.agentRow?.lastOutcome
293
+ ? endpoint.agentRow?.lastOutcome?.status
283
294
  : undefined;
284
295
  const ownerId = endpoint.kind === "root" ? endpoint.registryEndpoint?.ownerId : undefined;
285
296
  return {
@@ -287,39 +298,17 @@ export function renderAgentBar(
287
298
  label: ownerId ? ownerDisplayToken(endpoint.label, ownerId) : endpoint.label,
288
299
  ordinal: endpoint.ordinal,
289
300
  unread: state.endpoint(endpoint.id).unread,
290
- endpoint,
291
301
  status,
292
- color: endpointColor(endpoint, status, now, selected),
293
- ...(live && (endpoint.agentRow?.activeTool || endpoint.externalAgent?.activeTool)
294
- ? {
295
- activity: endpoint.agentRow?.activeTool
296
- ? endpoint.agentRow.activeToolArgs
297
- ? `${endpoint.agentRow.activeTool} ${endpoint.agentRow.activeToolArgs}`
298
- : endpoint.agentRow.activeTool
299
- : endpoint.externalAgent!.activeToolArgs
300
- ? `${endpoint.externalAgent!.activeTool} ${endpoint.externalAgent!.activeToolArgs}`
301
- : endpoint.externalAgent!.activeTool,
302
- }
303
- : {}),
304
- ...(status === "stalled" ? { attention: true } : {}),
302
+ color: endpointColor(endpoint, status, now),
303
+ suffix: liveAgent
304
+ ? activeTool ? truncateToWidth(activeTool, 18, "…") : ""
305
+ : tuiStatus(status),
305
306
  ...(outcome ? { outcome } : {}),
306
307
  ...(endpoint.agentRow && isCliAgent(endpoint.agentRow) ? { cli: true } : {}),
307
308
  };
308
309
  });
309
- // Selected-session metrics summary, appended only when the chip line leaves
310
- // room for it: telemetry must never squeeze the chips that carry identity.
311
- const selectedEndpoint = tabs.find((tab) => tab.id === selectedId)?.endpoint;
312
- const selectedMetrics = selectedEndpoint?.agentRow ?? selectedEndpoint?.externalAgent?.metrics;
313
- const metricParts: string[] = [];
314
- if (selectedMetrics?.toolCount !== undefined) metricParts.push(tuiT("common.tools", { count: selectedMetrics.toolCount }));
315
- if (selectedMetrics?.tokens !== undefined) metricParts.push(tuiT("widget.agent.tokens", { count: formatAgentMetric(selectedMetrics.tokens) }));
316
- const metrics = metricParts.length > 0 ? theme.fg("muted", ` · ${metricParts.join(" · ")}`) : "";
317
310
  return [renderSessionBarLine(
318
- (availableWidth) => {
319
- const chipsLine = renderAgentChipLine(tabs, selectedId, availableWidth, theme);
320
- if (!metrics || visibleWidth(chipsLine) + visibleWidth(metrics) > availableWidth) return chipsLine;
321
- return chipsLine + metrics;
322
- },
311
+ (availableWidth) => renderAgentChipLine(tabs, selectedId, availableWidth, theme),
323
312
  width,
324
313
  theme,
325
314
  options.shortcutHint,
@@ -364,7 +353,7 @@ export function renderSessionBar(
364
353
  }
365
354
  }
366
355
  const chips = [
367
- chip(
356
+ legacyChip(
368
357
  MAIN_SESSION_LABEL,
369
358
  viewingId === undefined,
370
359
  viewingId === undefined ? "accent" : opts.mainRunning ? "warning" : "muted",
@@ -377,7 +366,7 @@ export function renderSessionBar(
377
366
  const stableStatus = previousStatus === nextStatus ? previousStatus : nextStatus;
378
367
  opts.stableStatuses?.set(row.correlationId, stableStatus);
379
368
  const color = agentSessionColor(row, now, stableStatus);
380
- return chip(label, row.correlationId === viewingId, color, theme);
369
+ return legacyChip(label, row.correlationId === viewingId, color, theme);
381
370
  }),
382
371
  ];
383
372
  const w = Math.max(1, width);
package/src/ambient.ts CHANGED
@@ -23,6 +23,10 @@ export interface AmbientState {
23
23
  hideLiveDuration?: boolean;
24
24
  /** Separator glyph between label and elapsed time (e.g. " · "). */
25
25
  separator?: string;
26
+ /** Parenthesized suffix appended after the elapsed (e.g. an interrupt hint). */
27
+ hint?: string;
28
+ /** Live output-token rate label (e.g. "45 tok/s"), shown while streaming. */
29
+ tokenRate?: string;
26
30
  /** Session summary — the session_info name, else a short session id. */
27
31
  session?: string;
28
32
  /** Active model short id. */
@@ -59,20 +63,11 @@ export function nextUiPromptDepth(depth: number, event: "start" | "end"): number
59
63
  return event === "start" ? depth + 1 : Math.max(0, depth - 1);
60
64
  }
61
65
 
62
- export function shouldHideWorkingDuration(
63
- mode: "regular" | "fullscreen" | undefined,
64
- staticMode: boolean,
65
- ): boolean {
66
- // Pi's regular renderer shares scrollback between the transcript and status
67
- // row, so a moving live timer can be preserved as transcript height changes.
68
- return staticMode || mode !== "fullscreen";
69
- }
70
-
71
66
  /**
72
67
  * The streaming working line.
73
68
  *
74
- * Cockpit hides the host indicator and renders the active state. Fullscreen may
75
- * add a live elapsed value; the regular main screen keeps this row stable.
69
+ * Cockpit hides the host indicator and renders the active state with a live
70
+ * elapsed value. Static mode keeps the row stable by omitting the timer.
76
71
  */
77
72
  export function workingMessage(state: AmbientState, now = Date.now()): string | undefined {
78
73
  const label = state.waitingForInput
@@ -80,10 +75,14 @@ export function workingMessage(state: AmbientState, now = Date.now()): string |
80
75
  : state.activeTool ?? (state.running ? tuiT("ambient.working") : undefined);
81
76
  if (!label) return undefined;
82
77
  const sep = state.separator ?? " ";
83
- const text = (state.waitingForInput || state.hideLiveDuration || state.workingStartedAt === undefined)
84
- ? label
85
- : `${label}${sep}${formatThinkingDuration(now - state.workingStartedAt)}`;
86
- return `\x1b[3m${text}\x1b[23m`;
78
+ const parts = [label];
79
+ if (!state.waitingForInput && !state.hideLiveDuration && state.workingStartedAt !== undefined) {
80
+ parts.push(formatThinkingDuration(now - state.workingStartedAt));
81
+ if (state.tokenRate) parts.push(state.tokenRate);
82
+ }
83
+ let text = parts.join(sep);
84
+ if (state.hint) text += ` (${state.hint})`;
85
+ return text;
87
86
  }
88
87
 
89
88
  /** Composition options for titleFor. */
@@ -1,6 +1,7 @@
1
1
  import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
2
  import { isKeyRelease, isKeyRepeat, matchesKey, getKeybindings, truncateToWidth, visibleWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
3
3
  import { readAutocompleteState } from "./autocomplete-probe.ts";
4
+ import { stripAnsi } from "./transcript-selection.ts";
4
5
  import { tuiT } from "./tui-i18n.ts";
5
6
 
6
7
  /** Invisible editor start marker consumed by the fullscreen controller (fullscreenInput only). */
@@ -22,8 +23,8 @@ export interface CockpitEditorRouteTarget {
22
23
  export interface CockpitClaudeEditorOptions {
23
24
  /** Enable the double bare-Escape clear-input state machine. */
24
25
  doubleEscapeClearInput: boolean;
25
- /** Emit editor start/end markers in render() for the fullscreen controller. */
26
- emitEditorMarkers: boolean;
26
+ /** Emit editor start/end markers while the fullscreen controller is active. */
27
+ emitEditorMarkers: boolean | (() => boolean);
27
28
  /** Guard: true while pi is streaming or a capturing overlay is open (never arm). */
28
29
  isBusy?: () => boolean;
29
30
  /** Called after a double-Escape clears a non-empty draft. */
@@ -40,6 +41,12 @@ export interface CockpitClaudeEditorOptions {
40
41
  getRouteTarget?: () => CockpitEditorRouteTarget | undefined;
41
42
  /** Called with the constructed editor so the host can push route updates. */
42
43
  onEditor?: (editor: CockpitClaudeEditor) => void;
44
+ /**
45
+ * Repaints the embedded working-status row with a fixed color. The host
46
+ * derives that row's color from the thinking-level border tint; Cockpit
47
+ * keeps it neutral instead.
48
+ */
49
+ workingStatusPaint?: (text: string) => string;
43
50
  }
44
51
 
45
52
  export interface DoubleEscapeDecision {
@@ -147,7 +154,10 @@ export class CockpitClaudeEditor extends CustomEditor {
147
154
  const banner = this.browsing() && total > 0
148
155
  ? historyBanner(this.index + 1, total, width, this.getPaddingX(), this.borderColor)
149
156
  : undefined;
150
- if (!this.editorOptions.emitEditorMarkers) {
157
+ const emitEditorMarkers = typeof this.editorOptions.emitEditorMarkers === "function"
158
+ ? this.editorOptions.emitEditorMarkers()
159
+ : this.editorOptions.emitEditorMarkers;
160
+ if (!emitEditorMarkers) {
151
161
  return banner ? [...lines, banner] : lines;
152
162
  }
153
163
  // The fullscreen controller treats everything between the sentinels as the
@@ -204,6 +214,21 @@ export class CockpitClaudeEditor extends CustomEditor {
204
214
  this.tui.requestRender();
205
215
  }
206
216
 
217
+ /** Status row embedded in the top border keeps a constant color, never the thinking tint. */
218
+ override setWorkingStatusIndicator(indicator: {
219
+ renderInBorder(width: number): string;
220
+ renderSpinnerInBorder(width: number): string;
221
+ } | undefined): void {
222
+ const paint = this.editorOptions.workingStatusPaint;
223
+ if (indicator && paint) {
224
+ const render = indicator.renderInBorder.bind(indicator);
225
+ const renderSpinner = indicator.renderSpinnerInBorder.bind(indicator);
226
+ indicator.renderInBorder = (width: number) => paint(stripAnsi(render(width)));
227
+ indicator.renderSpinnerInBorder = (width: number) => paint(stripAnsi(renderSpinner(width)));
228
+ }
229
+ super.setWorkingStatusIndicator(indicator as Parameters<CustomEditor["setWorkingStatusIndicator"]>[0]);
230
+ }
231
+
207
232
  private isBareEscape(data: string): boolean {
208
233
  return matchesKey(data, "escape") && !isKeyRelease(data) && !isKeyRepeat(data);
209
234
  }