pi-cockpit 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cockpit",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Agent cockpit for Pi: a list-mode status stack (live teammates + todo plan) pinned above the editor, plus a Starship-style footer. Renders only via public extension APIs (setWidget/setFooter).",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -34,7 +34,7 @@
34
34
  "@earendil-works/pi-ai": "*",
35
35
  "@earendil-works/pi-coding-agent": "*",
36
36
  "@earendil-works/pi-tui": "*",
37
- "pi-maestro-teammate": "^0.6.0"
37
+ "pi-maestro-teammate": "^1.0.0"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@earendil-works/pi-agent-core": {
@@ -59,7 +59,7 @@
59
59
  "@earendil-works/pi-coding-agent": "0.82.1",
60
60
  "@earendil-works/pi-tui": "0.82.1",
61
61
  "@types/node": "^22.0.0",
62
- "pi-maestro-teammate": "0.6.0",
62
+ "pi-maestro-teammate": "1.0.0",
63
63
  "typescript": "^5.5.0"
64
64
  },
65
65
  "main": "./src/index.ts",
@@ -41,11 +41,17 @@ export interface ProgressPayload {
41
41
  recentTools?: Array<string | { name?: string; status?: string }>;
42
42
  toolCount?: number;
43
43
  tokens?: number;
44
+ inputTokens?: number;
45
+ outputTokens?: number;
44
46
  lastMessage?: string;
45
47
  }
46
48
  export interface MessagePayload {
47
49
  correlationId: string;
48
50
  taskCorrelationId?: string;
51
+ // Present on progress/interaction deltas (teammate/.../index.ts publishProgress);
52
+ // absent on send deltas. Used to self-heal a row when `started` was missed.
53
+ agent?: string;
54
+ name?: string;
49
55
  taskIndex?: number;
50
56
  dependencies?: number[];
51
57
  message?: string;
@@ -53,6 +59,8 @@ export interface MessagePayload {
53
59
  recentTools?: Array<string | { name?: string; status?: string }>;
54
60
  toolCount?: number;
55
61
  tokens?: number;
62
+ inputTokens?: number;
63
+ outputTokens?: number;
56
64
  status?: string;
57
65
  lastActivityAt?: number | string;
58
66
  progress?: ProgressPayload[];
@@ -116,6 +124,9 @@ function latestTool(tools: MessagePayload["recentTools"]): string | undefined {
116
124
  // Self-accumulating roster. The teammate extension only broadcasts deltas
117
125
  // (started/message/complete), never a full snapshot, so we rebuild the list here.
118
126
  // Cold start is empty by design — we only reflect activity observed after load.
127
+ // The roster is self-healing: message/progress deltas for an agent whose
128
+ // `started` delta was missed still materialize a row, so a running agent is
129
+ // always represented as long as it keeps emitting activity.
119
130
  export class AgentsStore {
120
131
  private readonly roster = new Map<string, AgentRow>();
121
132
 
@@ -146,6 +157,22 @@ export class AgentsStore {
146
157
  for (const progress of p.progress) this.applyProgress(p.correlationId, progress, now);
147
158
  }
148
159
  const targetId = p.taskCorrelationId ?? p.correlationId;
160
+ if (!this.roster.has(targetId)) {
161
+ // Self-heal the roster: a running agent must stay visible even when its
162
+ // `started` delta was missed (cold start, event reorder, or a foreground
163
+ // dispatch that detached to background). Materialize the row from this
164
+ // event instead of dropping it; later deltas refine the placeholder.
165
+ this.applyStarted({
166
+ correlationId: targetId,
167
+ agent: p.agent ?? "",
168
+ name: p.name,
169
+ status: p.status,
170
+ lastActivityAt: p.lastActivityAt,
171
+ ...(p.taskCorrelationId && p.taskCorrelationId !== p.correlationId
172
+ ? { spawnedBy: p.correlationId }
173
+ : {}),
174
+ }, now);
175
+ }
149
176
  const row = this.roster.get(targetId);
150
177
  if (!row) return;
151
178
  const progressActivity = p.progress?.find((progress) => progress.correlationId === targetId)?.lastActivityAt;
@@ -159,6 +186,8 @@ export class AgentsStore {
159
186
  }
160
187
  if (typeof p.toolCount === "number") row.toolCount = p.toolCount;
161
188
  if (typeof p.tokens === "number") row.tokens = p.tokens;
189
+ if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
190
+ if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
162
191
  if (typeof p.status === "string") {
163
192
  row.taskStatus = p.status;
164
193
  row.status = mapAgentStatus(p.status);
@@ -217,6 +246,21 @@ export class AgentsStore {
217
246
  }
218
247
 
219
248
  private applyProgress(parentCorrelationId: string, p: ProgressPayload, now: number): void {
249
+ if (!this.roster.has(p.correlationId)) {
250
+ // Self-heal: see applyMessage. A graph child whose `started` delta was
251
+ // missed still materializes from its first progress event.
252
+ this.applyStarted({
253
+ correlationId: p.correlationId,
254
+ agent: p.agent,
255
+ name: p.name,
256
+ status: p.status,
257
+ startedAt: p.startedAt,
258
+ lastActivityAt: p.lastActivityAt,
259
+ ...(parentCorrelationId !== p.correlationId
260
+ ? { spawnedBy: parentCorrelationId }
261
+ : {}),
262
+ }, now);
263
+ }
220
264
  const row = this.roster.get(p.correlationId);
221
265
  if (!row) return;
222
266
  row.parentCorrelationId = parentCorrelationId === p.correlationId
@@ -241,6 +285,8 @@ export class AgentsStore {
241
285
  }
242
286
  if (typeof p.toolCount === "number") row.toolCount = p.toolCount;
243
287
  if (typeof p.tokens === "number") row.tokens = p.tokens;
288
+ if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
289
+ if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
244
290
  if (typeof p.lastMessage === "string" && p.lastMessage.length > 0) {
245
291
  row.tail = truncateTail(p.lastMessage);
246
292
  }
@@ -1,6 +1,5 @@
1
1
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import type { IconGlyphs } from "./icons.ts";
3
- import { sanitizeExtensionStatusText } from "./extension-status.ts";
4
3
  import { fitLineByPriority, type PrioritizedSegment } from "./layout.ts";
5
4
  import { formatDuration, type PaintTheme, type WidthUtils } from "./render.ts";
6
5
  import type { BashBgJob } from "./types.ts";
@@ -35,10 +34,7 @@ export function renderBashBgSummary(
35
34
  ].filter(Boolean).join(" · ");
36
35
  const statusColor: ThemeColor = stopping > 0 ? "warning" : "accent";
37
36
  const elapsed = formatDuration(options.now - current.startedAt);
38
- const preview = lastOutputLine(current.outputTail);
39
37
  const g = options.glyphs;
40
- // The Alt+J hint is the only advertised route into the job detail overlay, so
41
- // it outranks the command preview instead of being the first thing clipped.
42
38
  const segments: PrioritizedSegment[] = [
43
39
  {
44
40
  text: theme.fg(statusColor, current.status === "stopping" ? g.blocked : options.spin),
@@ -47,24 +43,8 @@ export function renderBashBgSummary(
47
43
  },
48
44
  { text: theme.fg("muted", "BG"), priority: 95, clippable: false },
49
45
  { text: theme.fg(statusColor, counts), priority: 90, clippable: false },
50
- { text: theme.fg("text", oneLine(current.command)), priority: 70, minWidth: 8 },
51
- { text: theme.fg("dim", `${options.hint ?? DEFAULT_BASH_BG_HINT} details`), priority: 60, clippable: false },
52
- { text: theme.fg("dim", elapsed), priority: 50, clippable: false },
53
- { text: theme.fg("mdLink", current.id), priority: 30, clippable: false },
46
+ { text: theme.fg("dim", elapsed), priority: 80, clippable: false },
47
+ { text: theme.fg("dim", `${options.hint ?? DEFAULT_BASH_BG_HINT} details`), priority: 70, clippable: false },
54
48
  ];
55
- if (preview) segments.push({ text: theme.fg("dim", `${g.arrow} ${preview}`), priority: 20, minWidth: 8 });
56
49
  return [fitLineByPriority(segments, width, utils, theme.fg("dim", g.separator), g.ellipsis)];
57
50
  }
58
-
59
- function lastOutputLine(output: string): string {
60
- const lines = output.replace(/\r/g, "").split("\n");
61
- for (let index = lines.length - 1; index >= 0; index--) {
62
- const line = lines[index]?.trim();
63
- if (line) return oneLine(line);
64
- }
65
- return "";
66
- }
67
-
68
- function oneLine(value: string): string {
69
- return sanitizeExtensionStatusText(value);
70
- }
package/src/footer.ts CHANGED
@@ -27,10 +27,10 @@ export interface FooterParts {
27
27
  ctxTokens: number;
28
28
  ctxWindow: number;
29
29
  totals: UsageTotals;
30
- git?: string;
31
- agentSummary?: string;
32
- bashBgStatus?: string;
33
- workflowStatus?: string;
30
+ git?: string;
31
+ agentSummary?: string;
32
+ bashBgStatus?: string;
33
+ workflowStatus?: string;
34
34
  extensionStatuses?: readonly ExtensionStatusSegment[];
35
35
  width: number;
36
36
  glyphs: IconGlyphs;
@@ -207,7 +207,9 @@ export function renderFooter(p: FooterParts): string[] {
207
207
  (status) => isVisibleExtensionStatus(status, p.thinking),
208
208
  );
209
209
  const controlStatuses = visibleStatuses.filter(
210
- (status) => status.key === "approval-mode" || status.key === "maestro-auto-compact-mode",
210
+ (status) => status.key === "mode"
211
+ || status.key === "approval-mode"
212
+ || status.key === "maestro-auto-compact-mode",
211
213
  );
212
214
 
213
215
  // line 1: prioritized identity and control segments
@@ -323,14 +325,14 @@ export function renderFooter(p: FooterParts): string[] {
323
325
  }
324
326
  if (!selected) fittedLeft = fitLeftBeside("");
325
327
 
326
- const line1 = alignRight(fittedLeft.join(identitySeparator), right1, width, utils.measure);
327
- const lines = [utils.clip(line1, width, ell)];
328
- if (p.bashBgStatus) {
329
- lines.push(utils.clip(p.bashBgStatus, width, ell));
330
- }
331
- if (p.workflowStatus) {
332
- lines.push(utils.clip(theme.fg("muted", p.workflowStatus), width, ell));
333
- }
328
+ const line1 = alignRight(fittedLeft.join(identitySeparator), right1, width, utils.measure);
329
+ const lines = [utils.clip(line1, width, ell)];
330
+ if (p.bashBgStatus) {
331
+ lines.push(utils.clip(p.bashBgStatus, width, ell));
332
+ }
333
+ if (p.workflowStatus) {
334
+ lines.push(utils.clip(theme.fg("muted", p.workflowStatus), width, ell));
335
+ }
334
336
  const statuses = visibleStatuses.filter((status) => !controlStatuses.includes(status));
335
337
  if (statuses.length > 0) {
336
338
  const statusSeparator = ` ${sep} `;
package/src/index.ts CHANGED
@@ -2,17 +2,17 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
3
  import type { TUI } from "@earendil-works/pi-tui";
4
4
  import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
- import { AgentsStore, type CompletePayload, type MessagePayload, type StartedPayload } from "./agents-store.ts";
6
- import { statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
7
- import { BashBgStore } from "./bash-bg-store.ts";
8
- import { BashBgOverlay } from "./bash-bg-overlay.ts";
9
- import { renderBashBgSummary } from "./bash-bg-widget.ts";
10
- import { TodoStore } from "./todo-store.ts";
5
+ import { AgentsStore, type CompletePayload, type MessagePayload, type StartedPayload } from "./agents-store.ts";
6
+ import { statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
7
+ import { BashBgStore } from "./bash-bg-store.ts";
8
+ import { BashBgOverlay } from "./bash-bg-overlay.ts";
9
+ import { renderBashBgSummary } from "./bash-bg-widget.ts";
10
+ import { TodoStore } from "./todo-store.ts";
11
11
  import { makeTodoWidget, makeAgentWidget, terminalRows } from "./stack-widget.ts";
12
12
  import { activeThemeName, ThemePicker } from "./theme-picker.ts";
13
13
  import { getUsageTotals, invalidateUsageCache, renderFooter, type PaintTheme, type WidthUtils } from "./footer.ts";
14
14
  import { collectExtensionStatuses } from "./extension-status.ts";
15
- import { ANIMATION_PERIOD_MS, resolveGlyphs, spinFrame } from "./icons.ts";
15
+ import { ANIMATION_PERIOD_MS, resolveGlyphs, spinFrame } from "./icons.ts";
16
16
  import { ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
17
17
  import { applyRow, buildRows, rowKeyForAccel, type SaveState } from "./settings-view.ts";
18
18
  import {
@@ -133,6 +133,7 @@ export default function (pi: ExtensionAPI): void {
133
133
  pi.events.emit(COCKPIT_UI_OWNERSHIP_EVENT, {
134
134
  todo: config.enabled,
135
135
  agents: config.enabled && config.hideNativeAgents,
136
+ footer: config.enabled,
136
137
  todoExpanded: config.todoExpanded,
137
138
  });
138
139
  };
@@ -181,12 +182,12 @@ export default function (pi: ExtensionAPI): void {
181
182
  ctx.ui.setWidget(
182
183
  AGENT_WIDGET_KEY,
183
184
  (tui, theme) => {
184
- capturedTui = tui;
185
- return makeAgentWidget({
186
- getAgents: () => agents.snapshot(),
187
- getConfig: () => config,
188
- isRunning: () => running,
189
- isAnimating,
185
+ capturedTui = tui;
186
+ return makeAgentWidget({
187
+ getAgents: () => agents.snapshot(),
188
+ getConfig: () => config,
189
+ isRunning: () => running,
190
+ isAnimating,
190
191
  })(tui, theme);
191
192
  },
192
193
  { placement: "belowEditor" },
@@ -210,23 +211,23 @@ export default function (pi: ExtensionAPI): void {
210
211
  const component = {
211
212
  render(width: number): string[] {
212
213
  if (Number.isFinite(width) && width > 0) observedWidth = width;
213
- const cu = ctx.getContextUsage();
214
- const branch = footerData.getGitBranch();
215
- const extensionStatuses = collectExtensionStatuses(footerData.getExtensionStatuses());
216
- const glyphs = resolveGlyphs(config.icons.mode);
217
- const now = Date.now();
218
- const bashBgStatus = renderBashBgSummary(
219
- bashBg.snapshot(),
220
- width,
221
- theme,
222
- FOOTER_UTILS,
223
- {
224
- glyphs,
225
- spin: spinFrame(glyphs, now, isAnimating()),
226
- now,
227
- },
228
- )[0];
229
- return renderFooter({
214
+ const cu = ctx.getContextUsage();
215
+ const branch = footerData.getGitBranch();
216
+ const extensionStatuses = collectExtensionStatuses(footerData.getExtensionStatuses());
217
+ const glyphs = resolveGlyphs(config.icons.mode);
218
+ const now = Date.now();
219
+ const bashBgStatus = renderBashBgSummary(
220
+ bashBg.snapshot(),
221
+ width,
222
+ theme,
223
+ FOOTER_UTILS,
224
+ {
225
+ glyphs,
226
+ spin: spinFrame(glyphs, now, isAnimating()),
227
+ now,
228
+ },
229
+ )[0];
230
+ return renderFooter({
230
231
  width,
231
232
  model: ctx.model?.id ?? "no-model",
232
233
  provider: ctx.model?.provider,
@@ -234,13 +235,13 @@ export default function (pi: ExtensionAPI): void {
234
235
  cwd: formatCwd(ctx.sessionManager.getCwd()),
235
236
  ctxPct: cu?.percent ?? 0,
236
237
  ctxTokens: cu?.tokens ?? 0,
237
- ctxWindow: cu?.contextWindow ?? ctx.model?.contextWindow ?? 0,
238
- totals: getUsageTotals(ctx.sessionManager.getEntries()),
239
- git: branch ?? undefined,
240
- bashBgStatus,
241
- workflowStatus: extensionStatuses.find((status) => status.key === WORKFLOW_STATUS_KEY)?.text,
242
- extensionStatuses: extensionStatuses.filter((status) => status.key !== WORKFLOW_STATUS_KEY),
243
- glyphs,
238
+ ctxWindow: cu?.contextWindow ?? ctx.model?.contextWindow ?? 0,
239
+ totals: getUsageTotals(ctx.sessionManager.getEntries()),
240
+ git: branch ?? undefined,
241
+ bashBgStatus,
242
+ workflowStatus: extensionStatuses.find((status) => status.key === WORKFLOW_STATUS_KEY)?.text,
243
+ extensionStatuses: extensionStatuses.filter((status) => status.key !== WORKFLOW_STATUS_KEY),
244
+ glyphs,
244
245
  theme,
245
246
  utils: FOOTER_UTILS,
246
247
  });
@@ -316,8 +317,13 @@ export default function (pi: ExtensionAPI): void {
316
317
  // through /settings since — including an automatic "light/dark" pair, which
317
318
  // cockpit cannot represent and would silently flatten to a single theme.
318
319
  todos.hydrateFromEntries(ctx.sessionManager.getEntries());
319
- applyUi(ctx);
320
- publishUiOwnership();
320
+ if (config.enabled) {
321
+ publishUiOwnership();
322
+ applyUi(ctx);
323
+ } else {
324
+ applyUi(ctx);
325
+ publishUiOwnership();
326
+ }
321
327
  pi.events.emit(BASH_BG_QUERY_EVENT, undefined);
322
328
  req();
323
329
  });
@@ -327,6 +333,7 @@ export default function (pi: ExtensionAPI): void {
327
333
  pi.events.emit(COCKPIT_UI_OWNERSHIP_EVENT, {
328
334
  todo: false,
329
335
  agents: false,
336
+ footer: false,
330
337
  todoExpanded: config.todoExpanded,
331
338
  });
332
339
  lastCtx = undefined;
@@ -520,10 +527,16 @@ export default function (pi: ExtensionAPI): void {
520
527
  ? { kind: "saved" }
521
528
  : { kind: "failed", message: result.error ?? "unknown error" };
522
529
  if (wasEnabled !== config.enabled) {
523
- if (config.enabled) applyUi(ctx);
524
- else uninstallUi(ctx);
530
+ if (config.enabled) {
531
+ publishUiOwnership();
532
+ applyUi(ctx);
533
+ } else {
534
+ uninstallUi(ctx);
535
+ publishUiOwnership();
536
+ }
537
+ } else {
538
+ publishUiOwnership();
525
539
  }
526
- publishUiOwnership();
527
540
  req();
528
541
  };
529
542
  const ui = {
package/src/render.ts CHANGED
@@ -106,6 +106,16 @@ export function formatDuration(ms: number): string {
106
106
  return `${h}h ${m}m ${s}s`;
107
107
  }
108
108
 
109
+ export function formatAgentMetric(value: number): string {
110
+ if (value < 100) return String(value);
111
+ if (value < 999_950) return `${trimMetric(value / 1_000)}k`;
112
+ return `${trimMetric(value / 1_000_000)}m`;
113
+ }
114
+
115
+ function trimMetric(value: number): string {
116
+ return value.toFixed(1).replace(/\.0$/, "");
117
+ }
118
+
109
119
  // Role is identity, status is state — they must not share a palette. Reusing
110
120
  // success/warning/error for roles meant a green row could mean "executor" or
111
121
  // "done", and every theme inherits that ambiguity. Roles therefore draw from the
@@ -236,7 +246,12 @@ export function renderAgents(
236
246
  if (r.activeTool) segs.push({ text: theme.fg("accent", `tool ${r.activeTool}`), priority: 50, minWidth: 8 });
237
247
  if (r.tail) segs.push({ text: theme.fg("dim", r.tail), priority: 40, minWidth: 8 });
238
248
  if (r.toolCount !== undefined) segs.push({ text: theme.fg("muted", `${r.toolCount} tools`), priority: 20, clippable: false });
239
- if (r.tokens !== undefined) segs.push({ text: theme.fg("muted", `${r.tokens} tok`), priority: 10, clippable: false });
249
+ if (r.inputTokens !== undefined || r.outputTokens !== undefined) {
250
+ const usage = `in ${formatAgentMetric(r.inputTokens ?? 0)} · out ${formatAgentMetric(r.outputTokens ?? 0)}`;
251
+ segs.push({ text: theme.fg("muted", usage), priority: 10, clippable: false });
252
+ } else if (r.tokens !== undefined) {
253
+ segs.push({ text: theme.fg("muted", `${formatAgentMetric(r.tokens)} tok`), priority: 10, clippable: false });
254
+ }
240
255
  return fitLineByPriority(segs, width, utils, " ", g.ellipsis);
241
256
  });
242
257
  if (hidden > 0) {
@@ -247,6 +262,12 @@ export function renderAgents(
247
262
 
248
263
  const TODO_MAX_VISIBLE = 8;
249
264
 
265
+ const TODO_STATUS_RANK: Record<string, number> = { in_progress: 0, blocked: 1, pending: 2, completed: 3 };
266
+
267
+ function todoStatusRank(status: string): number {
268
+ return TODO_STATUS_RANK[status] ?? 4;
269
+ }
270
+
250
271
  // Tasks keep their creation order (the numeric id assigned at allocateTaskId).
251
272
  // Non-numeric ids (workflow mirrors) sort after numeric ones, then lexicographically.
252
273
  function todoIdOrder(id: string): number {
@@ -361,7 +382,11 @@ export function renderTodos(
361
382
  // Creation order drives both the next-task pointer and the expanded rows, so
362
383
  // compute it once up front. The pointer is the first task the user can act on:
363
384
  // not done, not blocked, and free of unresolved dependencies.
364
- const ordered = [...items].sort((a, b) => todoIdOrder(a.id) - todoIdOrder(b.id) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
385
+ const ordered = [...items].sort((a, b) =>
386
+ todoStatusRank(a.status) - todoStatusRank(b.status)
387
+ || todoIdOrder(a.id) - todoIdOrder(b.id)
388
+ || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
389
+ );
365
390
  const next = ordered.find((it) => it.status !== "completed" && it.status !== "blocked" && it.blockedBy.length === 0);
366
391
  if (next) {
367
392
  const np = todoPaint(next.status, g, spin);
package/src/types.ts CHANGED
@@ -35,6 +35,8 @@ export interface AgentRow {
35
35
  lastActivityAt: number;
36
36
  toolCount?: number;
37
37
  tokens?: number;
38
+ inputTokens?: number;
39
+ outputTokens?: number;
38
40
  taskStatus?: string;
39
41
  activeTool?: string;
40
42
  taskIndex?: number;