pi-cockpit 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/agents-store.ts +103 -60
- package/src/ambient.ts +21 -12
- package/src/bash-bg-overlay.ts +30 -7
- package/src/bash-bg-widget.ts +54 -50
- package/src/config.ts +4 -0
- package/src/footer.ts +26 -3
- package/src/icons.ts +5 -1
- package/src/index.ts +156 -27
- package/src/layout.ts +181 -129
- package/src/quiet-tools.ts +367 -310
- package/src/render.ts +107 -25
- package/src/settings-view.ts +23 -0
- package/src/sidebar-controller.ts +317 -266
- package/src/sidebar-render.ts +409 -312
- package/src/split-pane.ts +382 -377
- package/src/stack-widget.ts +16 -12
- package/src/thinking-timer.ts +249 -224
- package/src/tick-policy.ts +54 -0
- package/src/types.ts +40 -5
- package/src/viewport-stability.ts +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cockpit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
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/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/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
|
|
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
22
|
"typecheck": "tsc --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"pi": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@earendil-works/pi-ai": "*",
|
|
35
35
|
"@earendil-works/pi-coding-agent": "*",
|
|
36
36
|
"@earendil-works/pi-tui": "*",
|
|
37
|
-
"pi-maestro-teammate": "^1.
|
|
37
|
+
"pi-maestro-teammate": "^1.5.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@earendil-works/pi-agent-core": {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"@earendil-works/pi-coding-agent": "0.82.1",
|
|
60
60
|
"@earendil-works/pi-tui": "0.82.1",
|
|
61
61
|
"@types/node": "^22.0.0",
|
|
62
|
-
"pi-maestro-teammate": "1.
|
|
62
|
+
"pi-maestro-teammate": "1.5.0",
|
|
63
63
|
"typescript": "^5.5.0"
|
|
64
64
|
},
|
|
65
65
|
"main": "./src/index.ts",
|
package/src/agents-store.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
TeammateCompleteEvent,
|
|
3
|
+
TeammateProgressMessageEvent,
|
|
4
|
+
TeammateStartedEvent,
|
|
5
|
+
} from "pi-maestro-teammate/v1/events";
|
|
6
|
+
import type { AgentProgressSnapshot } from "pi-maestro-teammate/v1/types";
|
|
2
7
|
import { sanitizeExtensionStatusText } from "./extension-status.ts";
|
|
3
8
|
import type { AgentRow, AgentStatus } from "./types.ts";
|
|
4
9
|
|
|
@@ -19,56 +24,28 @@ function truncateTail(raw: string): string {
|
|
|
19
24
|
return flat.length > TAIL_MAX ? flat.slice(0, TAIL_MAX - 1) + "…" : flat;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
taskIndex: number;
|
|
37
|
-
dependencies?: number[];
|
|
38
|
-
status?: string;
|
|
39
|
-
startedAt?: number | string;
|
|
40
|
-
completedAt?: number | string;
|
|
41
|
-
durationMs?: number;
|
|
42
|
-
lastActivityAt?: number | string;
|
|
27
|
+
// Store inputs are compatibility-relaxed projections of the versioned public
|
|
28
|
+
// contract. The event boundary validates discriminators and identifiers; the
|
|
29
|
+
// optional fields let older teammate versions continue to feed the same store.
|
|
30
|
+
export type StartedPayload = Pick<TeammateStartedEvent, "correlationId" | "agent">
|
|
31
|
+
& Partial<Omit<TeammateStartedEvent, "correlationId" | "agent" | "startedAt">>
|
|
32
|
+
& { startedAt?: number | string };
|
|
33
|
+
export type ProgressPayload = Pick<AgentProgressSnapshot, "correlationId" | "agent" | "taskIndex">
|
|
34
|
+
& Partial<Omit<AgentProgressSnapshot, "correlationId" | "agent" | "taskIndex" | "startedAt" | "completedAt">>
|
|
35
|
+
& { startedAt?: number | string; completedAt?: number | string };
|
|
36
|
+
export type MessagePayload = Partial<Omit<
|
|
37
|
+
TeammateProgressMessageEvent,
|
|
38
|
+
"progress" | "recentTools" | "isSend" | "isInteraction"
|
|
39
|
+
>> & {
|
|
40
|
+
progress?: ProgressPayload[];
|
|
43
41
|
recentTools?: Array<string | { name?: string; status?: string }>;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
outputTokens?: number;
|
|
48
|
-
lastMessage?: string;
|
|
49
|
-
}
|
|
50
|
-
export interface MessagePayload {
|
|
51
|
-
correlationId: string;
|
|
52
|
-
taskCorrelationId?: string;
|
|
53
|
-
// Present on progress/interaction deltas (teammate/.../index.ts publishProgress);
|
|
54
|
-
// absent on send deltas. Used to self-heal a row when `started` was missed.
|
|
55
|
-
agent?: string;
|
|
56
|
-
name?: string;
|
|
57
|
-
taskIndex?: number;
|
|
58
|
-
dependencies?: number[];
|
|
42
|
+
isSend?: boolean;
|
|
43
|
+
isInteraction?: boolean;
|
|
44
|
+
/** Compatibility with pre-v1 progress deltas; discriminated send events are ignored. */
|
|
59
45
|
message?: string;
|
|
60
|
-
|
|
61
|
-
recentTools?: Array<string | { name?: string; status?: string }>;
|
|
62
|
-
toolCount?: number;
|
|
63
|
-
tokens?: number;
|
|
64
|
-
inputTokens?: number;
|
|
65
|
-
outputTokens?: number;
|
|
66
|
-
status?: string;
|
|
67
|
-
lastActivityAt?: number | string;
|
|
68
|
-
progress?: ProgressPayload[];
|
|
69
|
-
}
|
|
46
|
+
};
|
|
70
47
|
export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exitCode">
|
|
71
|
-
& Partial<Pick<TeammateCompleteEvent, "durationMs" | "wakeable">>;
|
|
48
|
+
& Partial<Pick<TeammateCompleteEvent, "durationMs" | "wakeable" | "cancelled">>;
|
|
72
49
|
|
|
73
50
|
/**
|
|
74
51
|
* How long a failed agent stays on screen after it completes.
|
|
@@ -86,6 +63,14 @@ export const FAILED_LINGER_MS = 30_000;
|
|
|
86
63
|
*/
|
|
87
64
|
export const SLEEPING_LINGER_MS = 60_000;
|
|
88
65
|
|
|
66
|
+
/**
|
|
67
|
+
* How long a terminated (cancelled/aborted) row stays on screen after it ends.
|
|
68
|
+
*
|
|
69
|
+
* Shorter than a failure: cancellation is expected, but the user should still
|
|
70
|
+
* see the × long enough to read which agent was taken down.
|
|
71
|
+
*/
|
|
72
|
+
export const TERMINATED_LINGER_MS = 15_000;
|
|
73
|
+
|
|
89
74
|
/**
|
|
90
75
|
* How long a completed agent's tombstone suppresses self-healing.
|
|
91
76
|
*
|
|
@@ -121,12 +106,23 @@ export function mapAgentStatus(status: unknown): AgentStatus {
|
|
|
121
106
|
return "done";
|
|
122
107
|
case "failed":
|
|
123
108
|
return "failed";
|
|
109
|
+
case "terminated":
|
|
110
|
+
return "terminated";
|
|
124
111
|
case "running":
|
|
125
112
|
default:
|
|
126
113
|
return "running";
|
|
127
114
|
}
|
|
128
115
|
}
|
|
129
116
|
|
|
117
|
+
export const AGENT_STALL_TIMEOUT_MS = 30_000;
|
|
118
|
+
export type AgentDisplayStatus = AgentStatus | "result-ready" | "stalled";
|
|
119
|
+
|
|
120
|
+
export function effectiveAgentStatus(row: AgentRow, now: number = Date.now()): AgentDisplayStatus {
|
|
121
|
+
if (row.status !== "running") return row.status;
|
|
122
|
+
if (row.resultReadyAt !== undefined) return "result-ready";
|
|
123
|
+
return now - row.lastActivityAt >= AGENT_STALL_TIMEOUT_MS ? "stalled" : "running";
|
|
124
|
+
}
|
|
125
|
+
|
|
130
126
|
function normalizeStartedAt(value: number | string | undefined, fallback: number): number {
|
|
131
127
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
132
128
|
if (typeof value === "string") {
|
|
@@ -149,7 +145,7 @@ function terminalTime(
|
|
|
149
145
|
return row.finishedAt ?? now;
|
|
150
146
|
}
|
|
151
147
|
|
|
152
|
-
function latestTool(tools:
|
|
148
|
+
function latestTool(tools: Array<string | { name?: string; status?: string }> | undefined): string | undefined {
|
|
153
149
|
if (!tools?.length) return undefined;
|
|
154
150
|
const tool = tools.find((candidate) => typeof candidate === "object" && candidate?.status === "running")
|
|
155
151
|
?? tools.at(-1);
|
|
@@ -187,6 +183,7 @@ export class AgentsStore {
|
|
|
187
183
|
}
|
|
188
184
|
|
|
189
185
|
applyStarted(p: StartedPayload, now: number = Date.now()): void {
|
|
186
|
+
if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
|
|
190
187
|
const id = p.correlationId;
|
|
191
188
|
// An explicit start is authoritative: a woken or re-dispatched agent
|
|
192
189
|
// reuses its correlationId and must reappear even if an earlier run was
|
|
@@ -210,15 +207,22 @@ export class AgentsStore {
|
|
|
210
207
|
? { parentCorrelationId: prev.parentCorrelationId }
|
|
211
208
|
: {}),
|
|
212
209
|
};
|
|
213
|
-
if (row.status !== "done" && row.status !== "failed") delete row.finishedAt;
|
|
210
|
+
if (row.status !== "done" && row.status !== "failed" && row.status !== "terminated") delete row.finishedAt;
|
|
214
211
|
this.roster.set(id, row);
|
|
215
212
|
}
|
|
216
213
|
|
|
217
214
|
applyMessage(p: MessagePayload, now = Date.now()): void {
|
|
215
|
+
if (p.isSend === true || p.isInteraction === true) return;
|
|
216
|
+
if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
|
|
218
217
|
if (p.progress) {
|
|
219
|
-
for (const progress of p.progress)
|
|
218
|
+
for (const progress of p.progress) {
|
|
219
|
+
if (typeof progress.correlationId !== "string" || progress.correlationId.length === 0) continue;
|
|
220
|
+
this.applyProgress(p.correlationId, progress, now);
|
|
221
|
+
}
|
|
220
222
|
}
|
|
221
|
-
const targetId = p.taskCorrelationId
|
|
223
|
+
const targetId = typeof p.taskCorrelationId === "string" && p.taskCorrelationId.length > 0
|
|
224
|
+
? p.taskCorrelationId
|
|
225
|
+
: p.correlationId;
|
|
222
226
|
const parentId = p.taskCorrelationId && p.taskCorrelationId !== p.correlationId
|
|
223
227
|
? p.correlationId
|
|
224
228
|
: undefined;
|
|
@@ -246,7 +250,7 @@ export class AgentsStore {
|
|
|
246
250
|
if (!row) return;
|
|
247
251
|
const progressActivity = p.progress?.find((progress) => progress.correlationId === targetId)?.lastActivityAt;
|
|
248
252
|
row.lastActivityAt = normalizeStartedAt(p.lastActivityAt ?? progressActivity, now);
|
|
249
|
-
const tail = p.
|
|
253
|
+
const tail = p.lastMessage ?? p.message;
|
|
250
254
|
if (typeof tail === "string" && tail.length > 0) row.tail = truncateTail(tail);
|
|
251
255
|
if (p.recentTools) {
|
|
252
256
|
const tool = latestTool(p.recentTools);
|
|
@@ -257,17 +261,29 @@ export class AgentsStore {
|
|
|
257
261
|
if (typeof p.tokens === "number") row.tokens = p.tokens;
|
|
258
262
|
if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
|
|
259
263
|
if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
|
|
264
|
+
if (typeof p.cacheReadTokens === "number") row.cacheReadTokens = p.cacheReadTokens;
|
|
265
|
+
if (typeof p.cacheWriteTokens === "number") row.cacheWriteTokens = p.cacheWriteTokens;
|
|
266
|
+
if (typeof p.error === "string") row.error = truncateTail(p.error);
|
|
267
|
+
if (typeof p.requestedModel === "string") row.requestedModel = clean(p.requestedModel);
|
|
268
|
+
if (typeof p.resolvedModel === "string") row.resolvedModel = clean(p.resolvedModel);
|
|
269
|
+
if (Array.isArray(p.attemptedModels)) {
|
|
270
|
+
row.attemptedModels = p.attemptedModels.map((model) => clean(model)).filter(Boolean);
|
|
271
|
+
}
|
|
260
272
|
if (typeof p.status === "string") {
|
|
261
273
|
row.taskStatus = p.status;
|
|
262
274
|
row.status = mapAgentStatus(p.status);
|
|
263
|
-
if (row.status === "done" || row.status === "failed"
|
|
264
|
-
|
|
275
|
+
if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
|
|
276
|
+
row.finishedAt ??= now;
|
|
277
|
+
} else {
|
|
278
|
+
delete row.finishedAt;
|
|
279
|
+
}
|
|
265
280
|
}
|
|
266
281
|
if (typeof p.taskIndex === "number") row.taskIndex = p.taskIndex;
|
|
267
282
|
if (Array.isArray(p.dependencies)) row.dependencies = [...p.dependencies];
|
|
268
283
|
}
|
|
269
284
|
|
|
270
285
|
applyComplete(p: CompletePayload, now = Date.now()): void {
|
|
286
|
+
if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
|
|
271
287
|
const pending = [p.correlationId];
|
|
272
288
|
const visited = new Set<string>();
|
|
273
289
|
while (pending.length > 0) {
|
|
@@ -281,17 +297,27 @@ export class AgentsStore {
|
|
|
281
297
|
// only evidence of the failure disappeared in the same frame it appeared.
|
|
282
298
|
// Successes still vanish immediately — the work has simply moved on.
|
|
283
299
|
const row = this.roster.get(id);
|
|
300
|
+
const cancelledById = id === p.correlationId && p.cancelled === true;
|
|
284
301
|
const failedByExitCode = id === p.correlationId
|
|
285
302
|
&& Number.isFinite(p.exitCode)
|
|
286
303
|
&& p.exitCode !== 0;
|
|
287
|
-
if (row && (row.status === "
|
|
304
|
+
if (row && (cancelledById || row.status === "terminated")) {
|
|
305
|
+
// Cancelled/terminated runs carry exitCode 1 but are not failures:
|
|
306
|
+
// a user abort or a result-ready reclaim must not light the red ✗.
|
|
307
|
+
row.status = "terminated";
|
|
308
|
+
row.taskStatus = "terminated";
|
|
309
|
+
row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
|
|
310
|
+
row.lastActivityAt = now;
|
|
311
|
+
delete row.activeTool;
|
|
312
|
+
delete row.failedAt;
|
|
313
|
+
} else if (row && (row.status === "failed" || failedByExitCode)) {
|
|
288
314
|
row.status = "failed";
|
|
289
315
|
row.taskStatus = "failed";
|
|
290
316
|
row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
|
|
291
317
|
row.failedAt = now;
|
|
292
318
|
row.lastActivityAt = now;
|
|
293
319
|
delete row.activeTool;
|
|
294
|
-
} else if (row && p.wakeable && p.exitCode === 0) {
|
|
320
|
+
} else if (row && id === p.correlationId && p.wakeable && p.exitCode === 0) {
|
|
295
321
|
// A wakeable agent enters sleeping state after completion.
|
|
296
322
|
// Keep the row visible so the user can see it is idle but recallable.
|
|
297
323
|
row.status = "sleeping";
|
|
@@ -307,13 +333,16 @@ export class AgentsStore {
|
|
|
307
333
|
}
|
|
308
334
|
}
|
|
309
335
|
|
|
310
|
-
/** Drop failed or expired sleeping rows that have had their time on screen. Returns true if any went. */
|
|
336
|
+
/** Drop failed, terminated or expired sleeping rows that have had their time on screen. Returns true if any went. */
|
|
311
337
|
prune(now = Date.now()): boolean {
|
|
312
338
|
let changed = false;
|
|
313
339
|
for (const [id, row] of this.roster) {
|
|
314
340
|
if (row.failedAt !== undefined && now - row.failedAt >= FAILED_LINGER_MS) {
|
|
315
341
|
this.roster.delete(id);
|
|
316
342
|
changed = true;
|
|
343
|
+
} else if (row.status === "terminated" && row.finishedAt !== undefined && now - row.finishedAt >= TERMINATED_LINGER_MS) {
|
|
344
|
+
this.roster.delete(id);
|
|
345
|
+
changed = true;
|
|
317
346
|
} else if (row.status === "sleeping" && row.finishedAt !== undefined && now - row.finishedAt >= SLEEPING_LINGER_MS) {
|
|
318
347
|
this.roster.delete(id);
|
|
319
348
|
changed = true;
|
|
@@ -325,10 +354,11 @@ export class AgentsStore {
|
|
|
325
354
|
return changed;
|
|
326
355
|
}
|
|
327
356
|
|
|
328
|
-
/** True while a failed or sleeping row is still counting down, so the redraw loop must run. */
|
|
357
|
+
/** True while a failed, terminated or sleeping row is still counting down, so the redraw loop must run. */
|
|
329
358
|
hasLingering(): boolean {
|
|
330
359
|
for (const row of this.roster.values()) {
|
|
331
360
|
if (row.failedAt !== undefined) return true;
|
|
361
|
+
if (row.status === "terminated") return true;
|
|
332
362
|
if (row.status === "sleeping") return true;
|
|
333
363
|
}
|
|
334
364
|
return false;
|
|
@@ -367,12 +397,15 @@ export class AgentsStore {
|
|
|
367
397
|
if (typeof p.status === "string") {
|
|
368
398
|
row.taskStatus = p.status;
|
|
369
399
|
row.status = mapAgentStatus(p.status);
|
|
370
|
-
if (row.status === "done" || row.status === "failed") {
|
|
400
|
+
if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
|
|
371
401
|
row.finishedAt = terminalTime(row, p, now);
|
|
372
402
|
} else {
|
|
373
403
|
delete row.finishedAt;
|
|
374
404
|
}
|
|
375
405
|
}
|
|
406
|
+
row.resultReadyAt = p.resultReadyAt === undefined
|
|
407
|
+
? undefined
|
|
408
|
+
: normalizeStartedAt(p.resultReadyAt, now);
|
|
376
409
|
row.taskIndex = p.taskIndex;
|
|
377
410
|
row.dependencies = Array.isArray(p.dependencies) ? [...p.dependencies] : [];
|
|
378
411
|
row.lastActivityAt = normalizeStartedAt(p.lastActivityAt, now);
|
|
@@ -385,6 +418,16 @@ export class AgentsStore {
|
|
|
385
418
|
if (typeof p.tokens === "number") row.tokens = p.tokens;
|
|
386
419
|
if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
|
|
387
420
|
if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
|
|
421
|
+
row.cacheReadTokens = p.cacheReadTokens;
|
|
422
|
+
row.cacheWriteTokens = p.cacheWriteTokens;
|
|
423
|
+
if (p.error === undefined) delete row.error;
|
|
424
|
+
else row.error = truncateTail(p.error);
|
|
425
|
+
if (p.requestedModel === undefined) delete row.requestedModel;
|
|
426
|
+
else row.requestedModel = clean(p.requestedModel);
|
|
427
|
+
if (p.resolvedModel === undefined) delete row.resolvedModel;
|
|
428
|
+
else row.resolvedModel = clean(p.resolvedModel);
|
|
429
|
+
if (p.attemptedModels === undefined) delete row.attemptedModels;
|
|
430
|
+
else row.attemptedModels = p.attemptedModels.map((model) => clean(model)).filter(Boolean);
|
|
388
431
|
if (typeof p.lastMessage === "string" && p.lastMessage.length > 0) {
|
|
389
432
|
row.tail = truncateTail(p.lastMessage);
|
|
390
433
|
}
|
package/src/ambient.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// those three surfaces — they add information without costing a single row.
|
|
9
9
|
|
|
10
10
|
import type { AgentRow, BashBgJob, TodoItem } from "./types.ts";
|
|
11
|
+
import { formatThinkingDuration } from "./thinking-timer.ts";
|
|
11
12
|
|
|
12
13
|
export interface AmbientState {
|
|
13
14
|
todos: readonly TodoItem[];
|
|
@@ -16,6 +17,10 @@ export interface AmbientState {
|
|
|
16
17
|
running: boolean;
|
|
17
18
|
cwd?: string;
|
|
18
19
|
activeTool?: string;
|
|
20
|
+
workingStartedAt?: number;
|
|
21
|
+
hideLiveDuration?: boolean;
|
|
22
|
+
/** Separator glyph between label and elapsed time (e.g. " · "). */
|
|
23
|
+
separator?: string;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
function liveAgents(agents: readonly AgentRow[]): AgentRow[] {
|
|
@@ -31,15 +36,19 @@ function failedJobs(jobs: readonly BashBgJob[]): BashBgJob[] {
|
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/**
|
|
34
|
-
* The streaming
|
|
39
|
+
* The streaming working line.
|
|
35
40
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
41
|
+
* Cockpit hides the host indicator and renders the active state with a live
|
|
42
|
+
* elapsed value, matching the compact folded-thinking label.
|
|
38
43
|
*/
|
|
39
|
-
export function workingMessage(state: AmbientState): string | undefined {
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
44
|
+
export function workingMessage(state: AmbientState, now = Date.now()): string | undefined {
|
|
45
|
+
const label = state.activeTool ?? (state.running ? "working" : undefined);
|
|
46
|
+
if (!label) return undefined;
|
|
47
|
+
const sep = state.separator ?? " ";
|
|
48
|
+
const text = (state.hideLiveDuration || state.workingStartedAt === undefined)
|
|
49
|
+
? label
|
|
50
|
+
: `${label}${sep}${formatThinkingDuration(now - state.workingStartedAt)}`;
|
|
51
|
+
return `\x1b[3m${text}\x1b[23m`;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
/**
|
|
@@ -49,16 +58,16 @@ export function workingMessage(state: AmbientState): string | undefined {
|
|
|
49
58
|
* which one needs them. Failure outranks progress, because that is the state that
|
|
50
59
|
* actually requires a human.
|
|
51
60
|
*/
|
|
52
|
-
export function titleFor(state: AmbientState, marks: { ok: string; fail: string }): string {
|
|
53
|
-
const base = state.cwd ? `pi
|
|
61
|
+
export function titleFor(state: AmbientState, marks: { ok: string; fail: string }, sep = " - "): string {
|
|
62
|
+
const base = state.cwd ? `pi${sep}${state.cwd}` : "pi";
|
|
54
63
|
const broken = failedAgents(state.agents).length + failedJobs(state.jobs).length;
|
|
55
|
-
if (broken > 0) return `${marks.fail} ${base}
|
|
64
|
+
if (broken > 0) return `${marks.fail} ${base}${sep}${broken} failed`;
|
|
56
65
|
if (state.running) {
|
|
57
66
|
const live = liveAgents(state.agents).length;
|
|
58
|
-
return live > 0 ? `${base}
|
|
67
|
+
return live > 0 ? `${base}${sep}${live} agents` : `${base}${sep}working`;
|
|
59
68
|
}
|
|
60
69
|
const jobs = state.jobs.filter((job) => job.status === "running").length;
|
|
61
|
-
if (jobs > 0) return `${base}
|
|
70
|
+
if (jobs > 0) return `${base}${sep}${jobs} bg`;
|
|
62
71
|
return base;
|
|
63
72
|
}
|
|
64
73
|
|
package/src/bash-bg-overlay.ts
CHANGED
|
@@ -24,14 +24,18 @@ export interface BashBgOverlayParams {
|
|
|
24
24
|
glyphs: IconGlyphs;
|
|
25
25
|
/** Live terminal height, so the card can use the space it already reserves. */
|
|
26
26
|
getTerminalRows?: () => number | undefined;
|
|
27
|
+
/** Static mode: hide live durations on running/stopping jobs, like the footer line. */
|
|
28
|
+
hideLiveDuration?: boolean;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
// Rows the card spends on itself: two borders, header, separator, help line.
|
|
30
32
|
const CARD_CHROME_ROWS = 5;
|
|
31
33
|
|
|
32
34
|
// How long the header keeps acknowledging a manual refresh. The snapshot arrives
|
|
33
|
-
// asynchronously, so without this the keypress looks like it did nothing.
|
|
34
|
-
|
|
35
|
+
// asynchronously, so without this the keypress looks like it did nothing. The ack
|
|
36
|
+
// must also expire on its own: when the main redraw loop is stopped (static mode)
|
|
37
|
+
// no unrelated event may come along to repaint it away.
|
|
38
|
+
export const REFRESH_ACK_MS = 1_500;
|
|
35
39
|
|
|
36
40
|
type BashBgOverlayMode = "list" | "detail";
|
|
37
41
|
|
|
@@ -41,11 +45,15 @@ export class BashBgOverlay implements Component, Focusable {
|
|
|
41
45
|
private selected = 0;
|
|
42
46
|
private selectedId: string | undefined;
|
|
43
47
|
private refreshedAt = 0;
|
|
48
|
+
private ackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
44
49
|
|
|
45
50
|
constructor(private readonly params: BashBgOverlayParams) {}
|
|
46
51
|
|
|
47
52
|
invalidate(): void {}
|
|
48
|
-
dispose(): void {
|
|
53
|
+
dispose(): void {
|
|
54
|
+
if (this.ackTimer) clearTimeout(this.ackTimer);
|
|
55
|
+
this.ackTimer = undefined;
|
|
56
|
+
}
|
|
49
57
|
|
|
50
58
|
handleInput(data: string): void {
|
|
51
59
|
if (data === "\x1b") {
|
|
@@ -71,8 +79,15 @@ export class BashBgOverlay implements Component, Focusable {
|
|
|
71
79
|
}
|
|
72
80
|
if (data === "\x12" || data === "\x1b[15~") {
|
|
73
81
|
// Ctrl+R / F5 — refresh is acknowledged immediately even though the
|
|
74
|
-
// authoritative snapshot only arrives later.
|
|
82
|
+
// authoritative snapshot only arrives later. Schedule the ack's own
|
|
83
|
+
// expiry so it disappears even without any follow-up repaint.
|
|
75
84
|
this.refreshedAt = Date.now();
|
|
85
|
+
if (this.ackTimer) clearTimeout(this.ackTimer);
|
|
86
|
+
this.ackTimer = setTimeout(() => {
|
|
87
|
+
this.ackTimer = undefined;
|
|
88
|
+
this.params.requestRender();
|
|
89
|
+
}, REFRESH_ACK_MS);
|
|
90
|
+
this.ackTimer.unref?.();
|
|
76
91
|
this.params.requestRefresh();
|
|
77
92
|
this.params.requestRender();
|
|
78
93
|
}
|
|
@@ -153,12 +168,16 @@ export class BashBgOverlay implements Component, Focusable {
|
|
|
153
168
|
|
|
154
169
|
private jobRow(job: BashBgJob, selected: boolean, width: number): string {
|
|
155
170
|
const visual = jobVisual(job.status, this.params.glyphs);
|
|
156
|
-
const duration = formatDuration((job.finishedAt ?? Date.now()) - job.startedAt);
|
|
157
171
|
const sep = this.params.glyphs.separator;
|
|
172
|
+
const live = job.status === "running" || job.status === "stopping";
|
|
173
|
+
const duration = this.params.hideLiveDuration && live
|
|
174
|
+
? ""
|
|
175
|
+
: formatDuration((job.finishedAt ?? Date.now()) - job.startedAt);
|
|
158
176
|
const exit = job.exitCode === null ? "" : `${sep}exit ${job.exitCode}`;
|
|
177
|
+
const meta = duration !== "" || exit !== "" ? `${duration}${exit}${sep}` : "";
|
|
159
178
|
return fitLine(
|
|
160
179
|
`${selected ? this.params.glyphs.selectMarker : " "} ${this.params.theme.fg(visual.color, visual.glyph)} `
|
|
161
|
-
+ `${this.orderOf(job)}${sep}${job.status}${sep}${
|
|
180
|
+
+ `${this.orderOf(job)}${sep}${job.status}${sep}${meta}${oneLine(job.command)}`,
|
|
162
181
|
width,
|
|
163
182
|
);
|
|
164
183
|
}
|
|
@@ -167,10 +186,14 @@ export class BashBgOverlay implements Component, Focusable {
|
|
|
167
186
|
if (!job) return [fitLine(this.emptyState(), width)];
|
|
168
187
|
const visual = jobVisual(job.status, this.params.glyphs);
|
|
169
188
|
const theme = this.params.theme;
|
|
189
|
+
const live = job.status === "running" || job.status === "stopping";
|
|
190
|
+
const durationField = this.params.hideLiveDuration && live
|
|
191
|
+
? []
|
|
192
|
+
: [field("Duration", formatDuration((job.finishedAt ?? Date.now()) - job.startedAt), width)];
|
|
170
193
|
const lines: string[] = [
|
|
171
194
|
fitLine(`${theme.fg(visual.color, theme.bold(`${visual.glyph} ${job.id}`))}${this.params.glyphs.separator}${job.status}`, width),
|
|
172
195
|
field("PID", String(job.pid), width),
|
|
173
|
-
|
|
196
|
+
...durationField,
|
|
174
197
|
field("Started", formatTimestamp(job.startedAt), width),
|
|
175
198
|
field("Updated", formatTimestamp(job.updatedAt), width),
|
|
176
199
|
field("Exit", job.exitCode === null ? this.params.glyphs.dotIdle : String(job.exitCode), width),
|
package/src/bash-bg-widget.ts
CHANGED
|
@@ -1,50 +1,54 @@
|
|
|
1
|
-
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { IconGlyphs } from "./icons.ts";
|
|
3
|
-
import { fitLineByPriority, type PrioritizedSegment } from "./layout.ts";
|
|
4
|
-
import { formatDuration, type PaintTheme, type WidthUtils } from "./render.ts";
|
|
5
|
-
import type { BashBgJob } from "./types.ts";
|
|
6
|
-
|
|
7
|
-
// Registered by cockpit itself (index.ts), so this hint is always accurate.
|
|
8
|
-
export const DEFAULT_BASH_BG_HINT = "Alt+J";
|
|
9
|
-
|
|
10
|
-
export interface BashBgRenderOptions {
|
|
11
|
-
glyphs: IconGlyphs;
|
|
12
|
-
spin: string;
|
|
13
|
-
now: number;
|
|
14
|
-
/** Overrides the advertised overlay shortcut when the binding differs. */
|
|
15
|
-
hint?: string;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{ text: theme.fg(
|
|
46
|
-
{ text: theme.fg(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { IconGlyphs } from "./icons.ts";
|
|
3
|
+
import { fitLineByPriority, type PrioritizedSegment } from "./layout.ts";
|
|
4
|
+
import { formatDuration, type PaintTheme, type WidthUtils } from "./render.ts";
|
|
5
|
+
import type { BashBgJob } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
// Registered by cockpit itself (index.ts), so this hint is always accurate.
|
|
8
|
+
export const DEFAULT_BASH_BG_HINT = "Alt+J";
|
|
9
|
+
|
|
10
|
+
export interface BashBgRenderOptions {
|
|
11
|
+
glyphs: IconGlyphs;
|
|
12
|
+
spin: string;
|
|
13
|
+
now: number;
|
|
14
|
+
/** Overrides the advertised overlay shortcut when the binding differs. */
|
|
15
|
+
hint?: string;
|
|
16
|
+
/** Static mode: drop the live elapsed on running/stopping jobs. */
|
|
17
|
+
hideLiveDuration?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderBashBgSummary(
|
|
21
|
+
jobs: readonly BashBgJob[],
|
|
22
|
+
width: number,
|
|
23
|
+
theme: PaintTheme,
|
|
24
|
+
utils: WidthUtils,
|
|
25
|
+
options: BashBgRenderOptions,
|
|
26
|
+
): string[] {
|
|
27
|
+
if (width < 1) return [];
|
|
28
|
+
const active = jobs.filter((job) => job.status === "running" || job.status === "stopping");
|
|
29
|
+
if (active.length === 0) return [];
|
|
30
|
+
const current = active[0];
|
|
31
|
+
const stopping = active.filter((job) => job.status === "stopping").length;
|
|
32
|
+
const running = active.length - stopping;
|
|
33
|
+
const g = options.glyphs;
|
|
34
|
+
const counts = [
|
|
35
|
+
running ? `${running} running` : "",
|
|
36
|
+
stopping ? `${stopping} stopping` : "",
|
|
37
|
+
].filter(Boolean).join(g.separator);
|
|
38
|
+
const statusColor: ThemeColor = stopping > 0 ? "warning" : "accent";
|
|
39
|
+
const segments: PrioritizedSegment[] = [
|
|
40
|
+
{
|
|
41
|
+
text: theme.fg(statusColor, current.status === "stopping" ? g.blocked : options.spin),
|
|
42
|
+
priority: 100,
|
|
43
|
+
clippable: false,
|
|
44
|
+
},
|
|
45
|
+
{ text: theme.fg("muted", "BG"), priority: 95, clippable: false },
|
|
46
|
+
{ text: theme.fg(statusColor, counts), priority: 90, clippable: false },
|
|
47
|
+
];
|
|
48
|
+
if (!options.hideLiveDuration) {
|
|
49
|
+
const elapsed = formatDuration(options.now - current.startedAt);
|
|
50
|
+
segments.push({ text: theme.fg("dim", elapsed), priority: 80, clippable: false });
|
|
51
|
+
}
|
|
52
|
+
segments.push({ text: theme.fg("dim", `${options.hint ?? DEFAULT_BASH_BG_HINT} details`), priority: 70, clippable: false });
|
|
53
|
+
return [fitLineByPriority(segments, width, utils, theme.fg("dim", g.separator), g.ellipsis)];
|
|
54
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -16,6 +16,8 @@ export function mergeConfig(base: CockpitConfig, over: unknown): CockpitConfig {
|
|
|
16
16
|
const o = over as Record<string, unknown>;
|
|
17
17
|
const isMode = (v: unknown): v is "list" | "compact" => v === "list" || v === "compact";
|
|
18
18
|
const isQuietSymbolMode = (v: unknown): v is "check" | "dot" => v === "check" || v === "dot";
|
|
19
|
+
const isToolPaletteMode = (v: unknown): v is "classic" | "family" | "readwrite" | "search" | "mono" =>
|
|
20
|
+
v === "classic" || v === "family" || v === "readwrite" || v === "search" || v === "mono";
|
|
19
21
|
const isIconMode = (v: unknown): v is "auto" | "nerd" | "ascii" => v === "auto" || v === "nerd" || v === "ascii";
|
|
20
22
|
const isSidebarMode = (v: unknown): v is "auto" | "on" | "off" => v === "auto" || v === "on" || v === "off";
|
|
21
23
|
const isSidebarDensity = (v: unknown): v is "comfortable" | "compact" => v === "comfortable" || v === "compact";
|
|
@@ -30,8 +32,10 @@ export function mergeConfig(base: CockpitConfig, over: unknown): CockpitConfig {
|
|
|
30
32
|
: base.sidebar.width;
|
|
31
33
|
return {
|
|
32
34
|
enabled: typeof o.enabled === "boolean" ? o.enabled : base.enabled,
|
|
35
|
+
staticMode: typeof o.staticMode === "boolean" ? o.staticMode : base.staticMode,
|
|
33
36
|
quietMode: typeof o.quietMode === "boolean" ? o.quietMode : base.quietMode,
|
|
34
37
|
quietSymbols: isQuietSymbolMode(o.quietSymbols) ? o.quietSymbols : base.quietSymbols,
|
|
38
|
+
toolPalette: isToolPaletteMode(o.toolPalette) ? o.toolPalette : base.toolPalette,
|
|
35
39
|
agentsMode: isMode(o.agentsMode) ? o.agentsMode : base.agentsMode,
|
|
36
40
|
todoMode: isMode(o.todoMode) ? o.todoMode : base.todoMode,
|
|
37
41
|
todoExpanded: typeof o.todoExpanded === "boolean" ? o.todoExpanded : base.todoExpanded,
|