pi-cockpit 0.7.0 → 0.8.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 +22 -1
- package/package.json +75 -71
- package/src/agents-store.ts +55 -12
- package/src/ambient.ts +57 -9
- package/src/config.ts +57 -1
- package/src/editor-bottom.ts +128 -0
- package/src/index.ts +349 -9
- package/src/render.ts +18 -2
- package/src/settings/cockpit-provider.ts +682 -0
- package/src/settings/coordinator.ts +323 -0
- package/src/settings/i18n.ts +150 -0
- package/src/settings/locale-state.ts +138 -0
- package/src/settings/registry.ts +160 -0
- package/src/settings/settings-shell.ts +1147 -0
- package/src/settings-view.ts +75 -0
- package/src/sidebar-controller.ts +2 -0
- package/src/sidebar-render.ts +7 -3
- package/src/title-gen.ts +60 -0
- package/src/title-llm.ts +115 -0
- package/src/types.ts +58 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A responsive **agent cockpit** for [Pi](https://pi.dev): wide terminals get a docked Maestro operations sidebar, narrow terminals automatically fall back to the existing Todo and Agent widgets, and every layout keeps the Starship-style footer.
|
|
4
4
|
|
|
5
|
-
It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.
|
|
5
|
+
It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.8.0**.
|
|
6
6
|
|
|
7
7
|
The dock is a non-capturing top-right overlay. Cockpit reserves its columns by wrapping the active TUI renderer at runtime, so the Pi workspace reflows instead of rendering underneath it. No Pi source files are modified.
|
|
8
8
|
|
|
@@ -73,6 +73,16 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
|
|
|
73
73
|
"width": 40,
|
|
74
74
|
"density": "comfortable"
|
|
75
75
|
},
|
|
76
|
+
"title": {
|
|
77
|
+
"enabled": true,
|
|
78
|
+
"showSession": true,
|
|
79
|
+
"showCwd": false,
|
|
80
|
+
"showModel": false,
|
|
81
|
+
"showThinking": false,
|
|
82
|
+
"showGit": false,
|
|
83
|
+
"showMaestro": false,
|
|
84
|
+
"maxLength": 80
|
|
85
|
+
},
|
|
76
86
|
"theme": ""
|
|
77
87
|
}
|
|
78
88
|
```
|
|
@@ -86,6 +96,17 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
|
|
|
86
96
|
- `sidebar.width`: persisted dock width, rounded and clamped to `32..56`; default `40`.
|
|
87
97
|
- `sidebar.density`: `"comfortable"` or `"compact"`.
|
|
88
98
|
- `icons.mode`: `"auto"` (detect Nerd Font), `"nerd"`, or `"ascii"`.
|
|
99
|
+
- `title.enabled`: master switch for the terminal tab title (session summary + working state + opt-in tags). On by default.
|
|
100
|
+
- `title.showSession`: include the session summary (the `session_info` name, else a short session id) right after `pi`. On by default.
|
|
101
|
+
- `title.showCwd`: include the working directory after the session. Off by default — the title stays short, since the tab strip has no room for a wall of tags.
|
|
102
|
+
- `title.showModel`: include the active model tag (`m:gpt-5.6-sol`). Off by default.
|
|
103
|
+
- `title.showThinking`: include the thinking level tag (`t:high`); skipped while off. Off by default.
|
|
104
|
+
- `title.showGit`: include the git branch tag (`git:main`, `git:detached`); read synchronously from `.git/HEAD`, so it costs no process spawn. Off by default.
|
|
105
|
+
- `title.showMaestro`: include the Maestro workflow status tag (`wf:running`, `wf:done`). Off by default.
|
|
106
|
+
- `title.maxLength`: hard cap on the composed title; the middle is ellided to keep the head and the working-state tail. Clamped to `20..200`; default `80`.
|
|
107
|
+
- `title.generationModel`: `"provider/model"` (e.g. `"maestro-qwen/qwen3.8-max-preview"`) of a model registered with the `/api-manager` command (or any other provider pi resolves). When set, the session title is generated by that model over the first completed turn (user prompt + assistant reply) through its OpenAI-compatible endpoint, with a 10s timeout. Empty (default) uses the offline rule-based extractor instead. Generation is best-effort — on any failure the title silently falls back to the rule-based one.
|
|
108
|
+
|
|
109
|
+
The tab title is `frame + pi - <session> - <working state>`. The session part follows Claude Code's chain (`sessionTitle ?? agentTitle ?? haikuTitle ?? default`): the `/session name` title wins, otherwise the generated title, otherwise a short session id. The frame is Claude Code's title chrome: `⠂`/`⠐` braille spinner while a turn runs, static `✳` when idle; failure replaces it with `✗`. On quit, Cockpit clears the title so no stale tab label lingers (Claude Code's `CLEAR_TERMINAL_TITLE`).
|
|
89
110
|
- `theme`: named theme override; empty string follows the Pi session theme.
|
|
90
111
|
|
|
91
112
|
## Commands
|
package/package.json
CHANGED
|
@@ -1,71 +1,75 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-cockpit",
|
|
3
|
-
"version": "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/maestro-store.test.ts tests/split-pane.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts 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/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",
|
|
22
|
-
"typecheck": "tsc --noEmit"
|
|
23
|
-
},
|
|
24
|
-
"pi": {
|
|
25
|
-
"extensions": [
|
|
26
|
-
"./src/extension/index.ts"
|
|
27
|
-
],
|
|
28
|
-
"themes": [
|
|
29
|
-
"./themes"
|
|
30
|
-
]
|
|
31
|
-
},
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"pi-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"@earendil-works/pi-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"@
|
|
62
|
-
"pi-
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-cockpit",
|
|
3
|
+
"version": "0.8.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/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/quiet-tools.test.ts 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/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/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/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./src/extension/index.ts"
|
|
27
|
+
],
|
|
28
|
+
"themes": [
|
|
29
|
+
"./themes"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"pi-maestro-settings-core": "0.1.0",
|
|
34
|
+
"proper-lockfile": "4.1.2"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-agent-core": "*",
|
|
38
|
+
"@earendil-works/pi-ai": "*",
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@earendil-works/pi-tui": "*",
|
|
41
|
+
"pi-maestro-teammate": "^1.6.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"@earendil-works/pi-agent-core": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@earendil-works/pi-ai": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"@earendil-works/pi-coding-agent": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"@earendil-works/pi-tui": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"pi-maestro-teammate": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@earendil-works/pi-agent-core": "0.83.0",
|
|
62
|
+
"@earendil-works/pi-ai": "0.83.0",
|
|
63
|
+
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
64
|
+
"@earendil-works/pi-tui": "0.83.0",
|
|
65
|
+
"@types/node": "^22.0.0",
|
|
66
|
+
"pi-maestro-teammate": "1.6.0",
|
|
67
|
+
"typescript": "^5.5.0"
|
|
68
|
+
},
|
|
69
|
+
"main": "./src/index.ts",
|
|
70
|
+
"exports": {
|
|
71
|
+
".": "./src/index.ts",
|
|
72
|
+
"./v1/events": "./src/public/v1/events.ts",
|
|
73
|
+
"./src/*": "./src/*"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/agents-store.ts
CHANGED
|
@@ -114,6 +114,13 @@ export function mapAgentStatus(status: unknown): AgentStatus {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
function activityFromProgressStatus(status: unknown): "running" | "sleeping" | undefined {
|
|
118
|
+
if (typeof status !== "string") return undefined;
|
|
119
|
+
return status === "sleeping" || status === "completed" || status === "failed" || status === "terminated"
|
|
120
|
+
? "sleeping"
|
|
121
|
+
: "running";
|
|
122
|
+
}
|
|
123
|
+
|
|
117
124
|
export const AGENT_STALL_TIMEOUT_MS = 30_000;
|
|
118
125
|
export type AgentDisplayStatus = AgentStatus | "result-ready" | "stalled";
|
|
119
126
|
|
|
@@ -138,9 +145,10 @@ function terminalTime(
|
|
|
138
145
|
now: number,
|
|
139
146
|
): number {
|
|
140
147
|
const completedAt = normalizeStartedAt(payload.completedAt, Number.NaN);
|
|
141
|
-
|
|
148
|
+
const turnStartedAt = row.turnStartedAt ?? row.startedAt;
|
|
149
|
+
if (Number.isFinite(completedAt)) return Math.max(turnStartedAt, completedAt);
|
|
142
150
|
if (typeof payload.durationMs === "number" && Number.isFinite(payload.durationMs)) {
|
|
143
|
-
return
|
|
151
|
+
return turnStartedAt + Math.max(0, payload.durationMs);
|
|
144
152
|
}
|
|
145
153
|
return row.finishedAt ?? now;
|
|
146
154
|
}
|
|
@@ -198,6 +206,14 @@ export class AgentsStore {
|
|
|
198
206
|
role: deriveRole(p.agent, p.name),
|
|
199
207
|
task: prev?.task ?? clean(p.name),
|
|
200
208
|
status: p.status === undefined ? prev?.status ?? "running" : mapAgentStatus(p.status),
|
|
209
|
+
phase: typeof p.phase === "string" ? clean(p.phase) : prev?.phase,
|
|
210
|
+
lastOutcome: p.lastOutcome
|
|
211
|
+
? {
|
|
212
|
+
status: p.lastOutcome.status,
|
|
213
|
+
...(p.lastOutcome.message ? { message: truncateTail(p.lastOutcome.message) } : {}),
|
|
214
|
+
settledAt: p.lastOutcome.settledAt,
|
|
215
|
+
}
|
|
216
|
+
: prev?.lastOutcome,
|
|
201
217
|
tail: prev?.tail ?? "",
|
|
202
218
|
startedAt: prev?.startedAt ?? normalizeStartedAt(p.startedAt, now),
|
|
203
219
|
lastActivityAt: normalizeStartedAt(p.lastActivityAt, now),
|
|
@@ -207,7 +223,15 @@ export class AgentsStore {
|
|
|
207
223
|
? { parentCorrelationId: prev.parentCorrelationId }
|
|
208
224
|
: {}),
|
|
209
225
|
};
|
|
210
|
-
if (row.status
|
|
226
|
+
if (row.status === "running") {
|
|
227
|
+
row.turnStartedAt = now;
|
|
228
|
+
delete row.finishedAt;
|
|
229
|
+
delete row.failedAt;
|
|
230
|
+
} else if (row.status === "sleeping") {
|
|
231
|
+
row.finishedAt = row.lastOutcome?.settledAt ?? prev?.finishedAt ?? now;
|
|
232
|
+
} else if (row.status !== "done" && row.status !== "failed" && row.status !== "terminated") {
|
|
233
|
+
delete row.finishedAt;
|
|
234
|
+
}
|
|
211
235
|
this.roster.set(id, row);
|
|
212
236
|
}
|
|
213
237
|
|
|
@@ -239,7 +263,7 @@ export class AgentsStore {
|
|
|
239
263
|
correlationId: targetId,
|
|
240
264
|
agent: p.agent ?? "",
|
|
241
265
|
name: p.name,
|
|
242
|
-
status: p.status,
|
|
266
|
+
status: activityFromProgressStatus(p.status),
|
|
243
267
|
lastActivityAt: p.lastActivityAt,
|
|
244
268
|
...(p.taskCorrelationId && p.taskCorrelationId !== p.correlationId
|
|
245
269
|
? { spawnedBy: p.correlationId }
|
|
@@ -278,6 +302,8 @@ export class AgentsStore {
|
|
|
278
302
|
delete row.finishedAt;
|
|
279
303
|
}
|
|
280
304
|
}
|
|
305
|
+
if (typeof p.phase === "string") row.phase = clean(p.phase);
|
|
306
|
+
else if (row.status === "sleeping") delete row.phase;
|
|
281
307
|
if (typeof p.taskIndex === "number") row.taskIndex = p.taskIndex;
|
|
282
308
|
if (Array.isArray(p.dependencies)) row.dependencies = [...p.dependencies];
|
|
283
309
|
}
|
|
@@ -307,23 +333,38 @@ export class AgentsStore {
|
|
|
307
333
|
row.status = "terminated";
|
|
308
334
|
row.taskStatus = "terminated";
|
|
309
335
|
row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
|
|
336
|
+
row.lastOutcome = { status: "terminated", settledAt: row.finishedAt };
|
|
310
337
|
row.lastActivityAt = now;
|
|
311
338
|
delete row.activeTool;
|
|
312
339
|
delete row.failedAt;
|
|
340
|
+
} else if (row && id === p.correlationId && p.wakeable) {
|
|
341
|
+
// A recoverable logical agent is sleeping regardless of the previous
|
|
342
|
+
// run outcome. Failure remains visible as orthogonal outcome metadata.
|
|
343
|
+
row.status = "sleeping";
|
|
344
|
+
row.taskStatus = "sleeping";
|
|
345
|
+
row.phase = undefined;
|
|
346
|
+
row.finishedAt = terminalTime(row, p, now);
|
|
347
|
+
row.lastActivityAt = now;
|
|
348
|
+
row.lastOutcome = {
|
|
349
|
+
status: failedByExitCode ? "failed" : "completed",
|
|
350
|
+
...(failedByExitCode && row.error ? { message: row.error } : {}),
|
|
351
|
+
settledAt: row.finishedAt,
|
|
352
|
+
};
|
|
353
|
+
if (failedByExitCode) row.failedAt = now;
|
|
354
|
+
else delete row.failedAt;
|
|
355
|
+
delete row.activeTool;
|
|
313
356
|
} else if (row && (row.status === "failed" || failedByExitCode)) {
|
|
314
357
|
row.status = "failed";
|
|
315
358
|
row.taskStatus = "failed";
|
|
316
359
|
row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
|
|
360
|
+
row.lastOutcome = {
|
|
361
|
+
status: "failed",
|
|
362
|
+
...(row.error ? { message: row.error } : {}),
|
|
363
|
+
settledAt: row.finishedAt,
|
|
364
|
+
};
|
|
317
365
|
row.failedAt = now;
|
|
318
366
|
row.lastActivityAt = now;
|
|
319
367
|
delete row.activeTool;
|
|
320
|
-
} else if (row && id === p.correlationId && p.wakeable && p.exitCode === 0) {
|
|
321
|
-
// A wakeable agent enters sleeping state after completion.
|
|
322
|
-
// Keep the row visible so the user can see it is idle but recallable.
|
|
323
|
-
row.status = "sleeping";
|
|
324
|
-
row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
|
|
325
|
-
row.lastActivityAt = now;
|
|
326
|
-
delete row.activeTool;
|
|
327
368
|
} else {
|
|
328
369
|
this.roster.delete(id);
|
|
329
370
|
// Tombstone even when the row never existed: a `complete` that beats
|
|
@@ -375,7 +416,7 @@ export class AgentsStore {
|
|
|
375
416
|
correlationId: p.correlationId,
|
|
376
417
|
agent: p.agent,
|
|
377
418
|
name: p.name,
|
|
378
|
-
status: p.status,
|
|
419
|
+
status: activityFromProgressStatus(p.status),
|
|
379
420
|
startedAt: p.startedAt,
|
|
380
421
|
lastActivityAt: p.lastActivityAt,
|
|
381
422
|
...(parentCorrelationId !== p.correlationId
|
|
@@ -403,6 +444,8 @@ export class AgentsStore {
|
|
|
403
444
|
delete row.finishedAt;
|
|
404
445
|
}
|
|
405
446
|
}
|
|
447
|
+
if (typeof p.phase === "string") row.phase = clean(p.phase);
|
|
448
|
+
else if (row.status === "sleeping") delete row.phase;
|
|
406
449
|
row.resultReadyAt = p.resultReadyAt === undefined
|
|
407
450
|
? undefined
|
|
408
451
|
: normalizeStartedAt(p.resultReadyAt, now);
|
package/src/ambient.ts
CHANGED
|
@@ -21,6 +21,22 @@ export interface AmbientState {
|
|
|
21
21
|
hideLiveDuration?: boolean;
|
|
22
22
|
/** Separator glyph between label and elapsed time (e.g. " · "). */
|
|
23
23
|
separator?: string;
|
|
24
|
+
/** Session summary — the session_info name, else a short session id. */
|
|
25
|
+
session?: string;
|
|
26
|
+
/** Active model short id. */
|
|
27
|
+
model?: string;
|
|
28
|
+
/** Active thinking level (omitted while off). */
|
|
29
|
+
thinking?: string;
|
|
30
|
+
/** Git branch name, or "detached". */
|
|
31
|
+
gitBranch?: string;
|
|
32
|
+
/** Maestro workflow status (run status when a run is active). */
|
|
33
|
+
maestro?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Leading title glyph: spinner frames while a turn runs, a static marker
|
|
36
|
+
* when idle (Claude Code's `✳` / braille frames). Failure keeps its own
|
|
37
|
+
* mark and ignores this. Absent when the caller opts out.
|
|
38
|
+
*/
|
|
39
|
+
frame?: string;
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
function liveAgents(agents: readonly AgentRow[]): AgentRow[] {
|
|
@@ -51,24 +67,56 @@ export function workingMessage(state: AmbientState, now = Date.now()): string |
|
|
|
51
67
|
return `\x1b[3m${text}\x1b[23m`;
|
|
52
68
|
}
|
|
53
69
|
|
|
70
|
+
/** Composition options for titleFor. */
|
|
71
|
+
export interface TitleOptions {
|
|
72
|
+
/** Hard cap on the composed title. The middle is ellided, keeping head + state tail. */
|
|
73
|
+
maxLength?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
/**
|
|
55
77
|
* The terminal tab title.
|
|
56
78
|
*
|
|
57
79
|
* A developer with several tabs open cannot otherwise tell which run finished and
|
|
58
80
|
* which one needs them. Failure outranks progress, because that is the state that
|
|
59
|
-
* actually requires a human.
|
|
81
|
+
* actually requires a human. The session summary (when present) sits right after
|
|
82
|
+
* "pi"; model / thinking / git / maestro tags follow the working state. Fields
|
|
83
|
+
* are only appended when present, so callers decide what to expose per config.
|
|
60
84
|
*/
|
|
61
|
-
export function titleFor(
|
|
62
|
-
|
|
85
|
+
export function titleFor(
|
|
86
|
+
state: AmbientState,
|
|
87
|
+
marks: { ok: string; fail: string },
|
|
88
|
+
sep = " - ",
|
|
89
|
+
opts?: TitleOptions,
|
|
90
|
+
): string {
|
|
91
|
+
const head = ["pi"];
|
|
92
|
+
if (state.session) head.push(state.session);
|
|
93
|
+
if (state.cwd) head.push(state.cwd);
|
|
94
|
+
const base = head.join(sep);
|
|
63
95
|
const broken = failedAgents(state.agents).length + failedJobs(state.jobs).length;
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
const prefix = state.frame ? `${state.frame} ` : "";
|
|
97
|
+
let title: string;
|
|
98
|
+
if (broken > 0) {
|
|
99
|
+
title = `${marks.fail} ${base}${sep}${broken} failed`;
|
|
100
|
+
} else if (state.running) {
|
|
66
101
|
const live = liveAgents(state.agents).length;
|
|
67
|
-
|
|
102
|
+
title = live > 0 ? `${prefix}${base}${sep}${live} agents` : `${prefix}${base}${sep}working`;
|
|
103
|
+
} else {
|
|
104
|
+
const jobs = state.jobs.filter((job) => job.status === "running").length;
|
|
105
|
+
title = jobs > 0 ? `${prefix}${base}${sep}${jobs} bg` : `${prefix}${base}`;
|
|
106
|
+
}
|
|
107
|
+
const tags: string[] = [];
|
|
108
|
+
if (state.model) tags.push(`m:${state.model}`);
|
|
109
|
+
if (state.thinking) tags.push(`t:${state.thinking}`);
|
|
110
|
+
if (state.gitBranch) tags.push(`git:${state.gitBranch}`);
|
|
111
|
+
if (state.maestro) tags.push(`wf:${state.maestro}`);
|
|
112
|
+
if (tags.length > 0) title = `${title}${sep}${tags.join(sep)}`;
|
|
113
|
+
if (opts?.maxLength !== undefined && title.length > opts.maxLength) {
|
|
114
|
+
const cap = opts.maxLength;
|
|
115
|
+
const headLen = Math.max(8, Math.floor(cap * 0.4));
|
|
116
|
+
const tailLen = Math.max(8, Math.floor(cap * 0.5));
|
|
117
|
+
title = `${title.slice(0, headLen)}…${title.slice(-tailLen)}`;
|
|
68
118
|
}
|
|
69
|
-
|
|
70
|
-
if (jobs > 0) return `${base}${sep}${jobs} bg`;
|
|
71
|
-
return base;
|
|
119
|
+
return title;
|
|
72
120
|
}
|
|
73
121
|
|
|
74
122
|
/**
|
package/src/config.ts
CHANGED
|
@@ -30,6 +30,13 @@ export function mergeConfig(base: CockpitConfig, over: unknown): CockpitConfig {
|
|
|
30
30
|
const sidebarWidth = sidebarRaw && typeof sidebarRaw.width === "number" && Number.isFinite(sidebarRaw.width)
|
|
31
31
|
? Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(sidebarRaw.width)))
|
|
32
32
|
: base.sidebar.width;
|
|
33
|
+
const titleRaw = o.title && typeof o.title === "object" && !Array.isArray(o.title)
|
|
34
|
+
? (o.title as Record<string, unknown>)
|
|
35
|
+
: undefined;
|
|
36
|
+
const isBool = (v: unknown): v is boolean => typeof v === "boolean";
|
|
37
|
+
const titleMaxLength = titleRaw && typeof titleRaw.maxLength === "number" && Number.isFinite(titleRaw.maxLength)
|
|
38
|
+
? Math.min(200, Math.max(20, Math.round(titleRaw.maxLength)))
|
|
39
|
+
: base.title.maxLength;
|
|
33
40
|
return {
|
|
34
41
|
enabled: typeof o.enabled === "boolean" ? o.enabled : base.enabled,
|
|
35
42
|
staticMode: typeof o.staticMode === "boolean" ? o.staticMode : base.staticMode,
|
|
@@ -40,12 +47,26 @@ export function mergeConfig(base: CockpitConfig, over: unknown): CockpitConfig {
|
|
|
40
47
|
todoMode: isMode(o.todoMode) ? o.todoMode : base.todoMode,
|
|
41
48
|
todoExpanded: typeof o.todoExpanded === "boolean" ? o.todoExpanded : base.todoExpanded,
|
|
42
49
|
hideNativeAgents: typeof o.hideNativeAgents === "boolean" ? o.hideNativeAgents : base.hideNativeAgents,
|
|
50
|
+
pinEditorBottom: typeof o.pinEditorBottom === "boolean" ? o.pinEditorBottom : base.pinEditorBottom,
|
|
43
51
|
icons: { mode: iconsRaw && isIconMode(iconsRaw.mode) ? iconsRaw.mode : base.icons.mode },
|
|
44
52
|
sidebar: {
|
|
45
53
|
mode: sidebarRaw && isSidebarMode(sidebarRaw.mode) ? sidebarRaw.mode : base.sidebar.mode,
|
|
46
54
|
width: sidebarWidth,
|
|
47
55
|
density: sidebarRaw && isSidebarDensity(sidebarRaw.density) ? sidebarRaw.density : base.sidebar.density,
|
|
48
56
|
},
|
|
57
|
+
title: {
|
|
58
|
+
enabled: isBool(titleRaw?.enabled) ? titleRaw!.enabled : base.title.enabled,
|
|
59
|
+
showSession: isBool(titleRaw?.showSession) ? titleRaw!.showSession : base.title.showSession,
|
|
60
|
+
showCwd: isBool(titleRaw?.showCwd) ? titleRaw!.showCwd : base.title.showCwd,
|
|
61
|
+
showModel: isBool(titleRaw?.showModel) ? titleRaw!.showModel : base.title.showModel,
|
|
62
|
+
showThinking: isBool(titleRaw?.showThinking) ? titleRaw!.showThinking : base.title.showThinking,
|
|
63
|
+
showGit: isBool(titleRaw?.showGit) ? titleRaw!.showGit : base.title.showGit,
|
|
64
|
+
showMaestro: isBool(titleRaw?.showMaestro) ? titleRaw!.showMaestro : base.title.showMaestro,
|
|
65
|
+
generationModel: titleRaw && typeof titleRaw.generationModel === "string"
|
|
66
|
+
? titleRaw.generationModel
|
|
67
|
+
: base.title.generationModel,
|
|
68
|
+
maxLength: titleMaxLength,
|
|
69
|
+
},
|
|
49
70
|
theme: typeof o.theme === "string" ? o.theme : base.theme,
|
|
50
71
|
};
|
|
51
72
|
}
|
|
@@ -76,6 +97,39 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
|
|
|
76
97
|
}
|
|
77
98
|
}
|
|
78
99
|
|
|
100
|
+
export function mergeConfigDocument(raw: unknown, config: CockpitConfig): Record<string, unknown> {
|
|
101
|
+
const root = raw && typeof raw === "object" && !Array.isArray(raw)
|
|
102
|
+
? { ...(raw as Record<string, unknown>) }
|
|
103
|
+
: {};
|
|
104
|
+
const icons = root.icons && typeof root.icons === "object" && !Array.isArray(root.icons)
|
|
105
|
+
? { ...(root.icons as Record<string, unknown>), mode: config.icons.mode }
|
|
106
|
+
: { mode: config.icons.mode };
|
|
107
|
+
const sidebar = root.sidebar && typeof root.sidebar === "object" && !Array.isArray(root.sidebar)
|
|
108
|
+
? {
|
|
109
|
+
...(root.sidebar as Record<string, unknown>),
|
|
110
|
+
mode: config.sidebar.mode,
|
|
111
|
+
width: config.sidebar.width,
|
|
112
|
+
density: config.sidebar.density,
|
|
113
|
+
}
|
|
114
|
+
: { ...config.sidebar };
|
|
115
|
+
return {
|
|
116
|
+
...root,
|
|
117
|
+
enabled: config.enabled,
|
|
118
|
+
staticMode: config.staticMode,
|
|
119
|
+
quietMode: config.quietMode,
|
|
120
|
+
quietSymbols: config.quietSymbols,
|
|
121
|
+
toolPalette: config.toolPalette,
|
|
122
|
+
agentsMode: config.agentsMode,
|
|
123
|
+
todoMode: config.todoMode,
|
|
124
|
+
todoExpanded: config.todoExpanded,
|
|
125
|
+
hideNativeAgents: config.hideNativeAgents,
|
|
126
|
+
pinEditorBottom: config.pinEditorBottom,
|
|
127
|
+
icons,
|
|
128
|
+
sidebar,
|
|
129
|
+
theme: config.theme,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
79
133
|
export interface SaveResult {
|
|
80
134
|
ok: boolean;
|
|
81
135
|
error?: string;
|
|
@@ -95,7 +149,9 @@ export function saveConfig(config: CockpitConfig): SaveResult {
|
|
|
95
149
|
try {
|
|
96
150
|
const dir = getAgentDir();
|
|
97
151
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
98
|
-
|
|
152
|
+
let raw: unknown = {};
|
|
153
|
+
try { if (existsSync(path)) raw = JSON.parse(readFileSync(path, "utf8")); } catch { /* replace malformed config */ }
|
|
154
|
+
writeFileSync(tmp, JSON.stringify(mergeConfigDocument(raw, config), null, 2) + "\n", "utf8");
|
|
99
155
|
renameSync(tmp, path);
|
|
100
156
|
return { ok: true };
|
|
101
157
|
} catch (err) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export const EDITOR_BOTTOM_WIDGET_KEY = "cockpit-editor-bottom-anchor";
|
|
4
|
+
export const COCKPIT_EDITOR_BOTTOM_MARKER = Symbol.for("pi-cockpit.editor-bottom-render");
|
|
5
|
+
|
|
6
|
+
const EDITOR_BOTTOM_SENTINEL = "\u2063\u2064\u2063\u2064\u2063";
|
|
7
|
+
|
|
8
|
+
type RenderFunction = TUI["render"];
|
|
9
|
+
|
|
10
|
+
interface RenderMarker {
|
|
11
|
+
owner: object;
|
|
12
|
+
original: RenderFunction;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EditorBottomController {
|
|
16
|
+
attach(tui: TUI): void;
|
|
17
|
+
show(): void;
|
|
18
|
+
hide(): void;
|
|
19
|
+
isEnabled(): boolean;
|
|
20
|
+
requestRender(): void;
|
|
21
|
+
dispose(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EditorBottomControllerOptions {
|
|
25
|
+
onError?(error: unknown): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createEditorBottomSentinel(): Component {
|
|
29
|
+
return {
|
|
30
|
+
render: () => [EDITOR_BOTTOM_SENTINEL],
|
|
31
|
+
invalidate(): void {},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createEditorBottomController(
|
|
36
|
+
options: EditorBottomControllerOptions = {},
|
|
37
|
+
): EditorBottomController {
|
|
38
|
+
const owner = {};
|
|
39
|
+
let tui: TUI | undefined;
|
|
40
|
+
let originalRender: RenderFunction | undefined;
|
|
41
|
+
let wrappedRender: RenderFunction | undefined;
|
|
42
|
+
let enabled = false;
|
|
43
|
+
let disposed = false;
|
|
44
|
+
|
|
45
|
+
const reportError = (error: unknown): void => {
|
|
46
|
+
try {
|
|
47
|
+
options.onError?.(error);
|
|
48
|
+
} catch {
|
|
49
|
+
// Layout failure reporting must not break Pi's renderer.
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const requestRender = (): void => {
|
|
54
|
+
try {
|
|
55
|
+
tui?.requestRender();
|
|
56
|
+
} catch {
|
|
57
|
+
// The TUI may already be shutting down.
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const attach = (nextTui: TUI): void => {
|
|
62
|
+
if (disposed) return;
|
|
63
|
+
if (tui === nextTui) return;
|
|
64
|
+
if (tui) throw new Error("Cockpit editor-bottom layout is already attached to another TUI");
|
|
65
|
+
const existing = (nextTui.render as RenderFunction & Record<symbol, RenderMarker | undefined>)[COCKPIT_EDITOR_BOTTOM_MARKER];
|
|
66
|
+
if (existing?.owner === owner) return;
|
|
67
|
+
if (existing) throw new Error("Cockpit editor-bottom layout is already attached to this TUI");
|
|
68
|
+
|
|
69
|
+
tui = nextTui;
|
|
70
|
+
originalRender = nextTui.render;
|
|
71
|
+
const previousRender = nextTui.render;
|
|
72
|
+
wrappedRender = function (this: TUI, width: number): string[] {
|
|
73
|
+
const rendered = previousRender.call(nextTui, width);
|
|
74
|
+
const markerIndex = rendered.findIndex((line) => line.includes(EDITOR_BOTTOM_SENTINEL));
|
|
75
|
+
if (markerIndex < 0) return rendered;
|
|
76
|
+
const withoutMarker = [...rendered.slice(0, markerIndex), ...rendered.slice(markerIndex + 1)];
|
|
77
|
+
if (!enabled) return withoutMarker;
|
|
78
|
+
try {
|
|
79
|
+
const terminalRows = Math.max(1, Math.trunc(nextTui.terminal.rows));
|
|
80
|
+
const padding = Math.max(0, terminalRows - withoutMarker.length);
|
|
81
|
+
if (padding === 0) return withoutMarker;
|
|
82
|
+
return [
|
|
83
|
+
...withoutMarker.slice(0, markerIndex),
|
|
84
|
+
...Array.from({ length: padding }, () => ""),
|
|
85
|
+
...withoutMarker.slice(markerIndex),
|
|
86
|
+
];
|
|
87
|
+
} catch (error) {
|
|
88
|
+
enabled = false;
|
|
89
|
+
reportError(error);
|
|
90
|
+
return withoutMarker;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
Object.defineProperty(wrappedRender, COCKPIT_EDITOR_BOTTOM_MARKER, {
|
|
94
|
+
value: { owner, original: previousRender } satisfies RenderMarker,
|
|
95
|
+
configurable: false,
|
|
96
|
+
enumerable: false,
|
|
97
|
+
writable: false,
|
|
98
|
+
});
|
|
99
|
+
nextTui.render = wrappedRender;
|
|
100
|
+
requestRender();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
attach,
|
|
105
|
+
show() {
|
|
106
|
+
if (disposed || enabled) return;
|
|
107
|
+
enabled = true;
|
|
108
|
+
requestRender();
|
|
109
|
+
},
|
|
110
|
+
hide() {
|
|
111
|
+
if (!enabled) return;
|
|
112
|
+
enabled = false;
|
|
113
|
+
requestRender();
|
|
114
|
+
},
|
|
115
|
+
isEnabled: () => enabled,
|
|
116
|
+
requestRender,
|
|
117
|
+
dispose() {
|
|
118
|
+
if (disposed) return;
|
|
119
|
+
disposed = true;
|
|
120
|
+
enabled = false;
|
|
121
|
+
if (tui && originalRender && tui.render === wrappedRender) tui.render = originalRender;
|
|
122
|
+
requestRender();
|
|
123
|
+
tui = undefined;
|
|
124
|
+
originalRender = undefined;
|
|
125
|
+
wrappedRender = undefined;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|