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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cockpit",
3
- "version": "0.6.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.0.0"
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.3.1",
62
+ "pi-maestro-teammate": "1.5.0",
63
63
  "typescript": "^5.5.0"
64
64
  },
65
65
  "main": "./src/index.ts",
@@ -1,4 +1,9 @@
1
- import type { TeammateCompleteEvent } from "pi-maestro-teammate/v1/events";
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
- // Shapes mirror what pi-maestro-teammate emits on pi.events (teammate/.../index.ts:378/1636/3420).
23
- export interface StartedPayload {
24
- correlationId: string;
25
- agent: string;
26
- name?: string;
27
- spawnedBy?: string;
28
- startedAt?: number | string;
29
- lastActivityAt?: number | string;
30
- status?: string;
31
- }
32
- export interface ProgressPayload {
33
- agent: string;
34
- name?: string;
35
- correlationId: string;
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
- toolCount?: number;
45
- tokens?: number;
46
- inputTokens?: number;
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
- lastMessage?: string;
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: MessagePayload["recentTools"]): string | undefined {
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) this.applyProgress(p.correlationId, progress, now);
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 ?? p.correlationId;
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.message ?? p.lastMessage;
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") row.finishedAt ??= now;
264
- else delete row.finishedAt;
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 === "failed" || failedByExitCode)) {
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 loader line.
39
+ * The streaming working line.
35
40
  *
36
- * Keep the host's default "Working" label, elapsed time and interrupt hint.
37
- * Only replace the label while a foreground tool is actively executing.
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
- if (state.activeTool) return state.activeTool;
41
- if (state.running) return "working";
42
- return undefined;
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 · ${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} · ${broken} failed`;
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} · ${live} agents` : `${base} · working`;
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} · ${jobs} bg`;
70
+ if (jobs > 0) return `${base}${sep}${jobs} bg`;
62
71
  return base;
63
72
  }
64
73
 
@@ -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
- const REFRESH_ACK_MS = 1_500;
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}${duration}${exit}${sep}${oneLine(job.command)}`,
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
- field("Duration", formatDuration((job.finishedAt ?? Date.now()) - job.startedAt), width),
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),
@@ -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
- export function renderBashBgSummary(
19
- jobs: readonly BashBgJob[],
20
- width: number,
21
- theme: PaintTheme,
22
- utils: WidthUtils,
23
- options: BashBgRenderOptions,
24
- ): string[] {
25
- if (width < 1) return [];
26
- const active = jobs.filter((job) => job.status === "running" || job.status === "stopping");
27
- if (active.length === 0) return [];
28
- const current = active[0];
29
- const stopping = active.filter((job) => job.status === "stopping").length;
30
- const running = active.length - stopping;
31
- const counts = [
32
- running ? `${running} running` : "",
33
- stopping ? `${stopping} stopping` : "",
34
- ].filter(Boolean).join(" · ");
35
- const statusColor: ThemeColor = stopping > 0 ? "warning" : "accent";
36
- const elapsed = formatDuration(options.now - current.startedAt);
37
- const g = options.glyphs;
38
- const segments: PrioritizedSegment[] = [
39
- {
40
- text: theme.fg(statusColor, current.status === "stopping" ? g.blocked : options.spin),
41
- priority: 100,
42
- clippable: false,
43
- },
44
- { text: theme.fg("muted", "BG"), priority: 95, clippable: false },
45
- { text: theme.fg(statusColor, counts), priority: 90, 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 },
48
- ];
49
- return [fitLineByPriority(segments, width, utils, theme.fg("dim", g.separator), g.ellipsis)];
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,