pi-cockpit 0.1.1 → 0.2.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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A list-mode **agent cockpit** for [Pi](https://pi.dev): a status stack pinned **above the editor** showing live teammates and the current todo plan, plus a Starship-style footer. Everything is drawn through Pi's public extension APIs only (`setWidget` / `setFooter`) — no core patches.
4
4
 
5
+ It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). Since `pi-maestro-flow@0.6.1` it is an exact-pinned dependency of `pi-maestro-flow` — installed together and auto-registered into Pi's `settings.packages` on postinstall — but it also installs and runs standalone.
6
+
5
7
  ```
6
8
  ┌─ AGENTS · 2 running ─────────────────────────────┐ ← setWidget(aboveEditor)
7
9
  │ ⠋ explorer #a1f3c2 map auth read routes.ts │
@@ -18,16 +20,19 @@ A list-mode **agent cockpit** for [Pi](https://pi.dev): a status stack pinned **
18
20
 
19
21
  ## Install
20
22
 
23
+ `pi-cockpit` comes automatically with the orchestration layer — installing `pi-maestro-flow@0.6.1` pulls `pi-cockpit@0.1.1` and registers it into `settings.packages` on postinstall, no manual setup required. To use it on its own:
24
+
21
25
  ```bash
22
- pi install <path-or-spec> # or, for one run:
23
- pi -e ./extensions/pi-cockpit
26
+ pi install npm:pi-cockpit # standalone from npm
27
+ # or, for one run:
28
+ pi -e ./packages/pi-cockpit
24
29
  ```
25
30
 
26
31
  ## What it shows
27
32
 
28
33
  - **AGENTS** — every running teammate as a table row (status spinner · role · id · label · live tail), sorted running-first. Collapses to a one-line `N agents running` summary in compact mode.
29
34
  - **TODO** — the active plan as numbered rows with four states (done ✓ / in-progress spinner / blocked ! / pending ·). Collapses to a segmented progress bar + current step + percent.
30
- - **Footer** — `provider/model · context gauge · ↑in ↓out · $cost · elapsed · git branch`.
35
+ - **Footer** — `provider/model · context gauge · ↑in ↓out · $cost · elapsed · git branch`. Since v0.1.1, `bash_bg` background-job state lives on a **dedicated second footer row** so it no longer competes with the primary line.
31
36
 
32
37
  Toggle each block between list and compact with `/cockpit`.
33
38
 
@@ -41,6 +46,8 @@ Toggle each block between list and compact with `/cockpit`.
41
46
 
42
47
  So on a stock Pi (no teammate / no todo tool) the extension loads without error, the status stack renders nothing, and only the footer appears. The roster is **self-accumulated from event deltas** — teammates already running before the extension loads are not back-filled (the teammate extension broadcasts deltas, never a full roster). The todo list **is** back-filled on `session_start` from the persisted snapshot.
43
48
 
49
+ This is the whole coupling story: cockpit has **no package dependency on `pi-maestro-flow`** and only a peer dependency on `pi-maestro-teammate`. It observes the other two plugins through public channels alone — teammate's event broadcasts for AGENTS, and the `todo-state` snapshot flow's `todo` tool persists for TODO. Remove either source and the matching block simply hides; the footer always remains.
50
+
44
51
  ## Configuration
45
52
 
46
53
  `~/.pi/agent/cockpit.json` (created on first run):
@@ -75,12 +82,12 @@ The original mockup was a browser page; a terminal is an ANSI stream with no DOM
75
82
  ## Local development
76
83
 
77
84
  ```bash
78
- cd extensions/pi-cockpit
85
+ cd packages/pi-cockpit
79
86
  node --test --experimental-transform-types tests/agents-store.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts
80
87
  ../../node_modules/.bin/tsc -p tsconfig.json # type-check (uses the monorepo root's tsc + types)
81
88
  ```
82
89
 
83
- The package lives inside the `pi-maestro-flow` tree so it resolves `@earendil-works/*` types from the root `node_modules`, but it is **not** part of the `packages/*` workspace — it neither pollutes the lockfile nor gets swept by monorepo builds.
90
+ The package lives inside the `pi-maestro-flow` monorepo under `packages/` so it resolves `@earendil-works/*` types from the root `node_modules`. Since v0.6.1 it is also an exact-pinned dependency of `pi-maestro-flow` and ships as its own npm package (`pi-cockpit`).
84
91
 
85
92
  ## License
86
93
 
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.1.1",
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
- "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/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
22
- "typecheck": "tsc --noEmit"
23
- },
24
- "pi": {
25
- "extensions": [
26
- "./src/extension/index.ts"
27
- ],
28
- "themes": [
29
- "./themes"
30
- ]
31
- },
32
- "peerDependencies": {
33
- "@earendil-works/pi-agent-core": "*",
34
- "@earendil-works/pi-ai": "*",
35
- "@earendil-works/pi-coding-agent": "*",
36
- "@earendil-works/pi-tui": "*",
37
- "pi-maestro-teammate": "^0.6.0"
38
- },
39
- "peerDependenciesMeta": {
40
- "@earendil-works/pi-agent-core": {
41
- "optional": true
42
- },
43
- "@earendil-works/pi-ai": {
44
- "optional": true
45
- },
46
- "@earendil-works/pi-coding-agent": {
47
- "optional": true
48
- },
49
- "@earendil-works/pi-tui": {
50
- "optional": true
51
- },
52
- "pi-maestro-teammate": {
53
- "optional": true
54
- }
55
- },
56
- "devDependencies": {
57
- "@earendil-works/pi-agent-core": "0.82.1",
58
- "@earendil-works/pi-ai": "0.82.1",
59
- "@earendil-works/pi-coding-agent": "0.82.1",
60
- "@earendil-works/pi-tui": "0.82.1",
61
- "@types/node": "^22.0.0",
62
- "pi-maestro-teammate": "0.6.0",
63
- "typescript": "^5.5.0"
64
- },
65
- "main": "./src/index.ts",
66
- "exports": {
67
- ".": "./src/index.ts",
68
- "./src/*": "./src/*"
69
- }
70
- }
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.2.0",
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
+ "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/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/extension/index.ts"
27
+ ],
28
+ "themes": [
29
+ "./themes"
30
+ ]
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-agent-core": "*",
34
+ "@earendil-works/pi-ai": "*",
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "@earendil-works/pi-tui": "*",
37
+ "pi-maestro-teammate": "^1.0.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@earendil-works/pi-agent-core": {
41
+ "optional": true
42
+ },
43
+ "@earendil-works/pi-ai": {
44
+ "optional": true
45
+ },
46
+ "@earendil-works/pi-coding-agent": {
47
+ "optional": true
48
+ },
49
+ "@earendil-works/pi-tui": {
50
+ "optional": true
51
+ },
52
+ "pi-maestro-teammate": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@earendil-works/pi-agent-core": "0.82.1",
58
+ "@earendil-works/pi-ai": "0.82.1",
59
+ "@earendil-works/pi-coding-agent": "0.82.1",
60
+ "@earendil-works/pi-tui": "0.82.1",
61
+ "@types/node": "^22.0.0",
62
+ "pi-maestro-teammate": "1.0.0",
63
+ "typescript": "^5.5.0"
64
+ },
65
+ "main": "./src/index.ts",
66
+ "exports": {
67
+ ".": "./src/index.ts",
68
+ "./src/*": "./src/*"
69
+ }
70
+ }
@@ -41,6 +41,8 @@ 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 {
@@ -53,6 +55,8 @@ export interface MessagePayload {
53
55
  recentTools?: Array<string | { name?: string; status?: string }>;
54
56
  toolCount?: number;
55
57
  tokens?: number;
58
+ inputTokens?: number;
59
+ outputTokens?: number;
56
60
  status?: string;
57
61
  lastActivityAt?: number | string;
58
62
  progress?: ProgressPayload[];
@@ -159,6 +163,8 @@ export class AgentsStore {
159
163
  }
160
164
  if (typeof p.toolCount === "number") row.toolCount = p.toolCount;
161
165
  if (typeof p.tokens === "number") row.tokens = p.tokens;
166
+ if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
167
+ if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
162
168
  if (typeof p.status === "string") {
163
169
  row.taskStatus = p.status;
164
170
  row.status = mapAgentStatus(p.status);
@@ -241,6 +247,8 @@ export class AgentsStore {
241
247
  }
242
248
  if (typeof p.toolCount === "number") row.toolCount = p.toolCount;
243
249
  if (typeof p.tokens === "number") row.tokens = p.tokens;
250
+ if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
251
+ if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
244
252
  if (typeof p.lastMessage === "string" && p.lastMessage.length > 0) {
245
253
  row.tail = truncateTail(p.lastMessage);
246
254
  }
package/src/ambient.ts CHANGED
@@ -37,7 +37,9 @@ function failedJobs(jobs: readonly BashBgJob[]): BashBgJob[] {
37
37
  * Only replace the label while a foreground tool is actively executing.
38
38
  */
39
39
  export function workingMessage(state: AmbientState): string | undefined {
40
- return state.activeTool;
40
+ if (state.activeTool) return state.activeTool;
41
+ if (state.running) return "working";
42
+ return undefined;
41
43
  }
42
44
 
43
45
  /**
@@ -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) {
@@ -246,22 +261,18 @@ export function renderAgents(
246
261
  }
247
262
 
248
263
  const TODO_MAX_VISIBLE = 8;
249
- const RECENT_COMPLETED_MS = 30_000;
250
264
 
251
- function todoDisplayRank(item: TodoItem, now: number): number {
252
- if (item.status === "completed") {
253
- const recent = item.updatedAt !== undefined && now - item.updatedAt < RECENT_COMPLETED_MS;
254
- return recent ? 0 : 4;
255
- }
256
- if (item.status === "in_progress") return 1;
257
- if (item.status === "blocked") return 2;
258
- return 3;
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;
259
269
  }
260
270
 
261
- function findNextTodo(items: readonly TodoItem[]): TodoItem | undefined {
262
- return items.find((i) => i.status === "in_progress")
263
- ?? items.find((i) => i.status === "pending" && i.blockedBy.length === 0)
264
- ?? items.find((i) => i.status === "blocked" || i.status === "pending");
271
+ // Tasks keep their creation order (the numeric id assigned at allocateTaskId).
272
+ // Non-numeric ids (workflow mirrors) sort after numeric ones, then lexicographically.
273
+ function todoIdOrder(id: string): number {
274
+ const n = Number(id);
275
+ return Number.isInteger(n) ? n : Number.POSITIVE_INFINITY;
265
276
  }
266
277
 
267
278
  function todoActorLabel(actor: NonNullable<TodoItem["assignee"]>, items: readonly TodoItem[]): string {
@@ -318,22 +329,6 @@ function todoSubject(paint: TodoPaint, subject: string, theme: PaintTheme): stri
318
329
  return paint.bold ? bold(theme, text) : text;
319
330
  }
320
331
 
321
- function todoNextLabel(
322
- item: TodoItem,
323
- items: readonly TodoItem[],
324
- glyphs: IconGlyphs,
325
- spin: string,
326
- theme: PaintTheme,
327
- ): string {
328
- const actor = todoActor(item, items);
329
- const paint = todoPaint(item.status, glyphs, spin);
330
- return [
331
- theme.fg(paint.glyphColor, paint.glyph),
332
- actor ? theme.fg("mdLink", actor) : "",
333
- todoSubject(paint, item.subject, theme),
334
- ].filter(Boolean).join(" ");
335
- }
336
-
337
332
  export function renderTodos(
338
333
  items: readonly TodoItem[],
339
334
  mode: ViewMode,
@@ -346,7 +341,6 @@ export function renderTodos(
346
341
  const g = opts.glyphs;
347
342
  const ell = theme.fg("dim", g.ellipsis);
348
343
  const spin = opts.spin ?? "~";
349
- const now = opts.now ?? Date.now();
350
344
  const expanded = opts.expanded !== false;
351
345
  const toggleHint = opts.toggleHint ?? DEFAULT_TOGGLE_HINT;
352
346
 
@@ -355,17 +349,11 @@ export function renderTodos(
355
349
  const done = items.filter((i) => i.status === "completed").length;
356
350
  const blocked = items.filter((i) => i.status === "blocked").length;
357
351
  const pct = total ? Math.round((done / total) * 100) : 0;
358
- const nxt = findNextTodo(items);
359
- const label = nxt
360
- ? `${theme.fg("dim", g.arrow)} ${todoNextLabel(nxt, items, g, spin, theme)}`
361
- : done === total ? theme.fg("success", `${g.check} all done`) : "";
362
352
  const segs: PrioritizedSegment[] = [
363
353
  { text: renderTodoBar(items, width, theme, g), priority: 60, clippable: false },
364
354
  { text: theme.fg("muted", `${pct}%`), priority: 90, clippable: false },
365
355
  ];
366
- // A glyph run is opaque to a screen reader, so the blocked count is spelled out.
367
356
  if (blocked > 0) segs.push({ text: theme.fg("error", `${blocked} blocked`), priority: 95, clippable: false });
368
- if (label) segs.push({ text: label, priority: 100, minWidth: 8 });
369
357
  segs.push({ text: theme.fg("dim", `(${toggleHint} expand)`), priority: 10, clippable: false });
370
358
  return [fitLineByPriority(segs, width, utils, " ", g.ellipsis)];
371
359
  }
@@ -377,17 +365,10 @@ export function renderTodos(
377
365
  const blocked = items.filter((i) => i.status === "blocked").length;
378
366
  const members = new Set(items.map((item) => item.assignee?.id).filter(Boolean)).size;
379
367
  const sep = theme.fg("dim", g.separator.trim());
380
- const nxt = findNextTodo(items);
381
- const nextText = nxt
382
- ? `${theme.fg(nxt.status === "blocked" ? "error" : "dim", g.arrow)} ${todoNextLabel(nxt, items, g, spin, theme)}`
383
- : theme.fg("success", `${g.check} all done`);
384
368
 
385
- // The next actionable task outranks the counts: it is the one token that tells
386
- // the user what to do, so it must survive when the terminal is narrow.
387
369
  const summarySegs: PrioritizedSegment[] = [
388
370
  { text: theme.fg("syntaxFunction", "Todo"), priority: 40, clippable: false },
389
371
  { text: theme.fg("dim", `${done}/${total}`), priority: 80, clippable: false },
390
- { text: nextText, priority: 100, minWidth: 8 },
391
372
  ];
392
373
  if (blocked > 0) {
393
374
  summarySegs.push({ text: theme.fg("error", `${blocked} blocked`), priority: 90, clippable: false });
@@ -398,6 +379,21 @@ export function renderTodos(
398
379
  if (members > 0) {
399
380
  summarySegs.push({ text: theme.fg("dim", `${members} members`), priority: 30, clippable: false });
400
381
  }
382
+ // Creation order drives both the next-task pointer and the expanded rows, so
383
+ // compute it once up front. The pointer is the first task the user can act on:
384
+ // not done, not blocked, and free of unresolved dependencies.
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
+ );
390
+ const next = ordered.find((it) => it.status !== "completed" && it.status !== "blocked" && it.blockedBy.length === 0);
391
+ if (next) {
392
+ const np = todoPaint(next.status, g, spin);
393
+ const nactor = todoActor(next, items);
394
+ const ntext = `${g.arrow} ${theme.fg(np.glyphColor, np.glyph)}${nactor ? ` ${theme.fg("mdLink", nactor)}` : ""} ${todoSubject(np, next.subject, theme)}`;
395
+ summarySegs.push({ text: ntext, priority: 20, clippable: false });
396
+ }
401
397
  summarySegs.push({
402
398
  text: theme.fg("dim", `(${toggleHint} ${expanded ? "collapse" : "expand"})`),
403
399
  priority: 10,
@@ -406,9 +402,6 @@ export function renderTodos(
406
402
  const summaryLine = fitLineByPriority(summarySegs, width, utils, ` ${sep} `, g.ellipsis);
407
403
 
408
404
  if (!expanded) return [summaryLine];
409
-
410
- // sorted + capped task rows
411
- const ordered = [...items].sort((a, b) => todoDisplayRank(a, now) - todoDisplayRank(b, now));
412
405
  // The summary line is already spent, so the task rows compete for what is left.
413
406
  const budget = Math.min(
414
407
  TODO_MAX_VISIBLE + 1,
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;