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 +12 -5
- package/package.json +70 -70
- package/src/agents-store.ts +8 -0
- package/src/ambient.ts +3 -1
- package/src/bash-bg-widget.ts +2 -22
- package/src/footer.ts +15 -13
- package/src/index.ts +55 -42
- package/src/render.ts +40 -47
- package/src/types.ts +2 -0
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
|
|
23
|
-
|
|
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
|
|
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`
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/agents-store.ts
CHANGED
|
@@ -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
|
/**
|
package/src/bash-bg-widget.ts
CHANGED
|
@@ -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("
|
|
51
|
-
{ text: theme.fg("dim", `${options.hint ?? DEFAULT_BASH_BG_HINT} details`), priority:
|
|
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 === "
|
|
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
|
-
|
|
320
|
-
|
|
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)
|
|
524
|
-
|
|
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.
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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,
|