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/src/footer.ts CHANGED
@@ -42,7 +42,21 @@ export function emptyTotals(): UsageTotals {
42
42
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, latestCacheHitRate: undefined };
43
43
  }
44
44
 
45
- let usageCache: { key: string; totals: UsageTotals } | undefined;
45
+ interface UsageCacheEntry {
46
+ key: string;
47
+ totals: UsageTotals;
48
+ computedAt: number;
49
+ }
50
+
51
+ let usageCache: UsageCacheEntry | undefined;
52
+ // Static mode lowers the token refresh cadence. The getter reads live config so
53
+ // a toggle applies on the next render without re-registering.
54
+ let usageThrottleMs: (() => number) | undefined;
55
+
56
+ /** Set the wall-clock throttle for token totals; 0/undefined means uncapped. */
57
+ export function setUsageThrottle(getMs: () => number): void {
58
+ usageThrottleMs = getMs;
59
+ }
46
60
 
47
61
  function entryIdentity(entry: unknown): string {
48
62
  const e = entry as {
@@ -77,9 +91,18 @@ export function invalidateUsageCache(): void {
77
91
  }
78
92
 
79
93
  // Sum assistant-message usage across session entries (shape: entry.message.usage).
80
- export function getUsageTotals(entries: readonly unknown[]): UsageTotals {
94
+ export function getUsageTotals(entries: readonly unknown[], now: number = Date.now()): UsageTotals {
81
95
  const key = entriesKey(entries);
82
96
  if (usageCache && usageCache.key === key) return usageCache.totals;
97
+ const throttleMs = Math.max(0, usageThrottleMs?.() ?? 0);
98
+ if (throttleMs > 0 && usageCache && now - usageCache.computedAt < throttleMs) {
99
+ // Within the window: keep the previous totals on screen. The cache entry is
100
+ // deliberately left untouched (key and computedAt stay put) so a later call
101
+ // past the window with these same entries still mismatches the cached key
102
+ // and recomputes — rebinding the new key here would pin stale totals to it
103
+ // forever, since the key-match fast path above would then win every time.
104
+ return usageCache.totals;
105
+ }
83
106
 
84
107
  const t = emptyTotals();
85
108
  for (const e of entries) {
@@ -98,7 +121,7 @@ export function getUsageTotals(entries: readonly unknown[]): UsageTotals {
98
121
  }
99
122
  }
100
123
  }
101
- usageCache = { key, totals: t };
124
+ usageCache = { key, totals: t, computedAt: now };
102
125
  return t;
103
126
  }
104
127
 
package/src/icons.ts CHANGED
@@ -14,6 +14,8 @@ export interface IconGlyphs {
14
14
  arrow: string;
15
15
  /** Points at a dependency/upstream ("blocked by", "waits on"). */
16
16
  depArrow: string;
17
+ /** Creator-to-assignee transfer indicator in actor labels. */
18
+ transferArrow: string;
17
19
  /** Tree connectors. Routed through the glyph set so ascii mode stays ascii. */
18
20
  treeBranch: string;
19
21
  treeLast: string;
@@ -60,6 +62,7 @@ const NERD_GLYPHS: IconGlyphs = {
60
62
  barPending: "░",
61
63
  arrow: "»",
62
64
  depArrow: "←",
65
+ transferArrow: "→",
63
66
  treeBranch: "├─",
64
67
  treeLast: "└─",
65
68
  treeVertical: "│ ",
@@ -92,7 +95,8 @@ const ASCII_GLYPHS: IconGlyphs = {
92
95
  barPending: "-",
93
96
  arrow: ">",
94
97
  depArrow: "<-",
95
- treeBranch: "|-",
98
+ transferArrow: "->",
99
+ treeBranch: "|",
96
100
  treeLast: "`-",
97
101
  treeVertical: "| ",
98
102
  treeSpace: " ",
package/src/index.ts CHANGED
@@ -12,10 +12,11 @@ import { renderBashBgSummary } from "./bash-bg-widget.ts";
12
12
  import { registerQuietTools } from "./quiet-tools.ts";
13
13
  import { ensureThinkingFolded, readHideThinkingBlock } from "./thinking-fold.ts";
14
14
  import { ThinkingFoldTimer } from "./thinking-timer.ts";
15
+ import { shouldAnimateFrames, shouldAnimateSidebar, shouldRunTick, type TickPolicyState } from "./tick-policy.ts";
15
16
  import { TodoStore } from "./todo-store.ts";
16
17
  import { makeTodoWidget, makeAgentWidget, terminalRows } from "./stack-widget.ts";
17
18
  import { activeThemeName, ThemePicker } from "./theme-picker.ts";
18
- import { getUsageTotals, invalidateUsageCache, renderFooter, type PaintTheme, type WidthUtils } from "./footer.ts";
19
+ import { getUsageTotals, invalidateUsageCache, renderFooter, setUsageThrottle, type PaintTheme, type WidthUtils } from "./footer.ts";
19
20
  import { collectExtensionStatuses } from "./extension-status.ts";
20
21
  import { ANIMATION_PERIOD_MS, resolveGlyphs, spinFrame } from "./icons.ts";
21
22
  import { ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
@@ -47,6 +48,8 @@ const BASH_BG_OVERLAY_KEY = "alt+j";
47
48
  const SIDEBAR_RESIZE_KEY = "ctrl+shift+r";
48
49
  const COCKPIT_STATUS_KEY = "cockpit";
49
50
  const WIDTH_POLL_INTERVAL_MS = 250;
51
+ // Static mode keeps token totals fresh enough without recomputing on every message.
52
+ const USAGE_REFRESH_THROTTLE_MS = 10_000;
50
53
  // Quiet mode's rename of pi's hidden-thinking label. The live thinking timer
51
54
  // derives its final "thoughts · 8.4s" labels from the same word.
52
55
  const QUIET_THINKING_LABEL = "thoughts";
@@ -80,6 +83,33 @@ function formatCwd(cwd: string): string {
80
83
  return rel === "" ? "~" : `~${sep}${rel}`;
81
84
  }
82
85
 
86
+ function isRecord(value: unknown): value is Record<string, unknown> {
87
+ return typeof value === "object" && value !== null;
88
+ }
89
+
90
+ function isStartedPayload(value: unknown): value is StartedPayload {
91
+ return isRecord(value)
92
+ && typeof value.correlationId === "string"
93
+ && value.correlationId.length > 0
94
+ && typeof value.agent === "string";
95
+ }
96
+
97
+ function isProgressPayload(value: unknown): value is MessagePayload {
98
+ return isRecord(value)
99
+ && value.isSend !== true
100
+ && value.isInteraction !== true
101
+ && typeof value.correlationId === "string"
102
+ && value.correlationId.length > 0;
103
+ }
104
+
105
+ function isCompletePayload(value: unknown): value is CompletePayload {
106
+ return isRecord(value)
107
+ && typeof value.correlationId === "string"
108
+ && value.correlationId.length > 0
109
+ && typeof value.exitCode === "number"
110
+ && Number.isFinite(value.exitCode);
111
+ }
112
+
83
113
  export default function (pi: ExtensionAPI): void {
84
114
  const agents = new AgentsStore();
85
115
  const bashBg = new BashBgStore();
@@ -92,7 +122,8 @@ export default function (pi: ExtensionAPI): void {
92
122
  let dockEffectiveVisible = false;
93
123
  let surfaceState: CockpitSurfaceState = "disabled";
94
124
  let running = false;
95
- const activeTools = new Map<string, string>();
125
+ let runningStartedAt: number | undefined;
126
+ const activeTools = new Map<string, { name: string; startedAt: number }>();
96
127
  let tick: ReturnType<typeof setInterval> | undefined;
97
128
  // Persisted rather than toasted: a config that failed to load silently downgrades
98
129
  // the whole session to defaults, so it belongs in a slot that does not scroll away.
@@ -109,14 +140,15 @@ export default function (pi: ExtensionAPI): void {
109
140
  // AgentSession construction — before renderInitialMessages runs.
110
141
  ensureConfigExists();
111
142
  config = loadConfig();
143
+ // Reads config live, so toggling static mode re-throttles without re-registering.
144
+ setUsageThrottle(() => (config.staticMode ? USAGE_REFRESH_THROTTLE_MS : 0));
112
145
  if (config.quietMode) {
113
146
  registerQuietTools(pi, () => config);
114
147
  quietToolsRegistered = true;
115
148
  }
116
149
 
117
- // Live labels for folded thinking rows: a spinner with running elapsed
118
- // time while the model thinks, then the actual duration once it settles.
119
- // Pi owns the fold; this only renames the label it already renders.
150
+ // Live elapsed for folded thinking rows — no spinner, just a running timer
151
+ // while the model thinks, then the settled duration. Pi owns the fold.
120
152
  const thinkingTimer = new ThinkingFoldTimer({
121
153
  getTui: () => capturedTui,
122
154
  requestRender: () => {
@@ -130,6 +162,7 @@ export default function (pi: ExtensionAPI): void {
130
162
  getGlyphs: () => resolveGlyphs(config.icons.mode),
131
163
  isThinkingHidden: () => (lastCtx ? readHideThinkingBlock(lastCtx.cwd) : false),
132
164
  isEnabled: () => config.enabled,
165
+ isStatic: () => config.staticMode,
133
166
  setGlobalLabel: (label) => {
134
167
  const ctx = lastCtx;
135
168
  if (!ctx || !isTuiContext(ctx)) return;
@@ -153,16 +186,20 @@ export default function (pi: ExtensionAPI): void {
153
186
  ctx.ui.setStatus(COCKPIT_STATUS_KEY, undefined);
154
187
  return;
155
188
  }
189
+ const activeTool = [...activeTools.values()].at(-1);
156
190
  const state: AmbientState = {
157
191
  todos: todos.snapshot(),
158
192
  agents: agents.snapshot(),
159
193
  jobs: bashBg.snapshot(),
160
194
  running,
161
195
  cwd: formatCwd(ctx.sessionManager.getCwd()),
162
- activeTool: [...activeTools.values()].at(-1),
196
+ activeTool: activeTool?.name,
197
+ workingStartedAt: activeTool?.startedAt ?? runningStartedAt,
198
+ hideLiveDuration: config.staticMode,
199
+ separator: ` ${g.separator} `,
163
200
  };
164
201
  ctx.ui.setWorkingMessage(workingMessage(state));
165
- ctx.ui.setTitle(titleFor(state, { ok: g.check, fail: g.cross }));
202
+ ctx.ui.setTitle(titleFor(state, { ok: g.check, fail: g.cross }, g.separator));
166
203
  ctx.ui.setStatus(COCKPIT_STATUS_KEY, statusText(configProblem, g.blocked));
167
204
  } catch {
168
205
  // ambient surfaces are best-effort; never let them break a render
@@ -178,18 +215,39 @@ export default function (pi: ExtensionAPI): void {
178
215
  // tui may be gone between sessions
179
216
  }
180
217
  };
218
+ const policy = (): TickPolicyState => ({
219
+ staticMode: config.staticMode,
220
+ running,
221
+ bashActive: bashBg.hasActive(),
222
+ lingering: agents.hasLingering(),
223
+ ticking: tick !== undefined,
224
+ });
181
225
  // True only while a redraw loop is actually running; widgets use it to avoid
182
226
  // painting a frozen spinner frame that reads as a hung UI.
183
- const isAnimating = (): boolean => tick !== undefined && (running || bashBg.hasActive());
227
+ const isAnimating = (): boolean => shouldAnimateFrames(policy());
184
228
  // A failed agent lingers for a while after it completes, and the loop has to
185
229
  // outlive the session that produced it — otherwise the row would sit there
186
- // until some unrelated event happened to expire it.
187
- const needsTick = (): boolean => running || bashBg.hasActive() || agents.hasLingering();
230
+ // until some unrelated event happened to expire it. Static mode still keeps
231
+ // that loop alive: failure retention is correctness, not animation. What it
232
+ // drops is the running/job churn that used to repaint every quarter second.
233
+ const needsTick = (): boolean => shouldRunTick(policy());
188
234
  const startTick = (): void => {
189
235
  if (tick) return;
190
236
  tick = setInterval(() => {
191
- if (needsTick()) req();
192
- else syncTick();
237
+ if (needsTick()) {
238
+ // Static mode keeps the loop alive only so lingering rows can expire
239
+ // (prune is read-driven). The rows themselves are static, so skip the
240
+ // full repaint and repaint just once when a prune removed something.
241
+ if (config.staticMode) {
242
+ const before = agents.size;
243
+ agents.snapshot();
244
+ if (agents.size !== before) req();
245
+ } else {
246
+ req();
247
+ }
248
+ } else {
249
+ syncTick();
250
+ }
193
251
  }, ANIMATION_PERIOD_MS);
194
252
  tick.unref?.();
195
253
  };
@@ -267,6 +325,7 @@ export default function (pi: ExtensionAPI): void {
267
325
  }
268
326
  };
269
327
 
328
+
270
329
  const clearWidgets = (ctx: ExtensionContext): void => {
271
330
  ctx.ui.setWidget(STACK_WIDGET_KEY, undefined);
272
331
  ctx.ui.setWidget(AGENT_WIDGET_KEY, undefined);
@@ -319,6 +378,7 @@ export default function (pi: ExtensionAPI): void {
319
378
  getAgents: () => agents.snapshot(),
320
379
  getJobs: () => bashBg.snapshot(),
321
380
  getConfig: () => config,
381
+ shouldAnimate: () => shouldAnimateSidebar(policy()),
322
382
  onVisibilityChange: (visible) => {
323
383
  dockEffectiveVisible = visible;
324
384
  const activeCtx = lastCtx;
@@ -366,6 +426,7 @@ export default function (pi: ExtensionAPI): void {
366
426
  // Leaving these set would strand a title and a status line owned by an
367
427
  // extension that is no longer painting anything.
368
428
  ctx.ui.setWorkingMessage(undefined);
429
+ ctx.ui.setWorkingIndicator();
369
430
  ctx.ui.setStatus(COCKPIT_STATUS_KEY, undefined);
370
431
  } catch {
371
432
  // ambient surfaces are best-effort
@@ -380,15 +441,30 @@ export default function (pi: ExtensionAPI): void {
380
441
  uninstallUi(ctx);
381
442
  return;
382
443
  }
444
+ ctx.ui.setWorkingIndicator({ frames: [] });
383
445
  ctx.ui.setFooter((tui, theme, footerData) => {
384
446
  capturedTui = tui;
385
447
  let observedWidth = tui.terminal.columns;
386
448
  const widthTimer = setInterval(() => {
387
- const nextWidth = tui.terminal.columns;
388
- if (!Number.isFinite(nextWidth) || nextWidth <= 0 || nextWidth === observedWidth) return;
389
- observedWidth = nextWidth;
390
- tui.invalidate();
391
- tui.requestRender(true);
449
+ try {
450
+ const nextWidth = tui.terminal.columns;
451
+ if (!Number.isFinite(nextWidth) || nextWidth <= 0 || nextWidth === observedWidth) return;
452
+ observedWidth = nextWidth;
453
+ // Deliberately skip tui.invalidate(): upstream pi 0.83.0 has a bug
454
+ // where ToolExecutionComponent.updateDisplay() can push undefined
455
+ // into Container.children (renderer returns undefined for a
456
+ // "working" tool). Container.invalidate() then does
457
+ // `child.invalidate?.()` on that undefined child → TypeError →
458
+ // uncaughtException in this timer callback → pi process exit.
459
+ // requestRender(true) already clears previousLines and forces a
460
+ // full re-render with the new width, which is all we need here.
461
+ tui.requestRender(true);
462
+ } catch {
463
+ // Last-resort guard: a synchronous exception in this timer
464
+ // callback would become an uncaughtException and kill the pi
465
+ // process. Swallow it so the session survives; the next tick
466
+ // will retry.
467
+ }
392
468
  }, WIDTH_POLL_INTERVAL_MS);
393
469
  widthTimer.unref?.();
394
470
  const unsubscribeBranch = footerData.onBranchChange(() => tui.requestRender());
@@ -409,6 +485,7 @@ export default function (pi: ExtensionAPI): void {
409
485
  glyphs,
410
486
  spin: spinFrame(glyphs, now, isAnimating()),
411
487
  now,
488
+ hideLiveDuration: config.staticMode,
412
489
  },
413
490
  )[0];
414
491
  return renderFooter({
@@ -444,16 +521,19 @@ export default function (pi: ExtensionAPI): void {
444
521
  };
445
522
 
446
523
  // --- teammate lifecycle (custom event bus; subscribed once for the extension lifetime) ---
447
- pi.events.on(TEAMMATE_STARTED_EVENT, (d) => {
448
- agents.applyStarted(d as StartedPayload);
524
+ pi.events.on(TEAMMATE_STARTED_EVENT, (payload) => {
525
+ if (!isStartedPayload(payload)) return;
526
+ agents.applyStarted(payload);
449
527
  req();
450
528
  });
451
- pi.events.on(TEAMMATE_MESSAGE_EVENT, (d) => {
452
- agents.applyMessage(d as MessagePayload);
529
+ pi.events.on(TEAMMATE_MESSAGE_EVENT, (payload) => {
530
+ if (!isProgressPayload(payload)) return;
531
+ agents.applyMessage(payload);
453
532
  req();
454
533
  });
455
- pi.events.on(TEAMMATE_COMPLETE_EVENT, (d) => {
456
- agents.applyComplete(d as CompletePayload);
534
+ pi.events.on(TEAMMATE_COMPLETE_EVENT, (payload) => {
535
+ if (!isCompletePayload(payload)) return;
536
+ agents.applyComplete(payload);
457
537
  // A failure that arrives after the session went idle still needs a loop to
458
538
  // expire it, so the tick is re-evaluated rather than assumed to be running.
459
539
  syncTick();
@@ -478,7 +558,7 @@ export default function (pi: ExtensionAPI): void {
478
558
 
479
559
  // --- foreground tool label + todo changes ---
480
560
  pi.on("tool_execution_start", (e) => {
481
- activeTools.set(e.toolCallId, e.toolName);
561
+ activeTools.set(e.toolCallId, { name: e.toolName, startedAt: Date.now() });
482
562
  req();
483
563
  });
484
564
  pi.on("tool_execution_end", (e, ctx) => {
@@ -557,6 +637,7 @@ export default function (pi: ExtensionAPI): void {
557
637
  pi.events.emit(COCKPIT_UI_OWNERSHIP_EVENT, released);
558
638
  lastCtx = undefined;
559
639
  running = false;
640
+ runningStartedAt = undefined;
560
641
  activeTools.clear();
561
642
  thinkingTimer.reset();
562
643
  invalidateUsageCache();
@@ -567,26 +648,28 @@ export default function (pi: ExtensionAPI): void {
567
648
 
568
649
  pi.on("agent_start", () => {
569
650
  running = true;
651
+ runningStartedAt = Date.now();
570
652
  startTick();
571
653
  req();
572
654
  });
573
655
  pi.on("agent_end", () => {
574
656
  running = false;
657
+ runningStartedAt = undefined;
575
658
  activeTools.clear();
576
659
  thinkingTimer.stop();
577
660
  syncTick();
578
661
  req();
579
662
  });
580
663
 
581
- // --- live labels for folded thinking rows ---
664
+ // --- live elapsed for folded thinking rows ---
582
665
  pi.on("message_update", (e) => {
583
666
  thinkingTimer.onAssistantMessageEvent(e.assistantMessageEvent);
584
667
  });
585
668
 
586
669
  // --- redraw triggers for the footer's live data ---
587
670
  pi.on("message_end", (e, ctx) => {
588
- // Settle an interrupted thinking run: message_end fires for aborted
589
- // and failed messages too, while the row is still mounted.
671
+ // Settle an interrupted thinking run: message_end fires for aborted and
672
+ // failed messages too, while the row is still mounted.
590
673
  if (e.message.role === "assistant") thinkingTimer.onAssistantMessageEnd();
591
674
  invalidateUsageCache();
592
675
  if (isTuiContext(ctx)) req();
@@ -609,6 +692,7 @@ export default function (pi: ExtensionAPI): void {
609
692
  close: () => done(undefined),
610
693
  theme,
611
694
  glyphs: resolveGlyphs(config.icons.mode),
695
+ hideLiveDuration: config.staticMode,
612
696
  getTerminalRows: () => terminalRows(tui),
613
697
  }), {
614
698
  overlay: true,
@@ -715,6 +799,23 @@ export default function (pi: ExtensionAPI): void {
715
799
  req();
716
800
  };
717
801
 
802
+ const setStaticMode = (target: boolean): boolean => {
803
+ if (config.staticMode === target) return true;
804
+ config = { ...config, staticMode: target };
805
+ const result = saveConfig(config);
806
+ if (!result.ok) {
807
+ configProblem = `static mode save failed: ${result.error ?? "unknown error"}`;
808
+ } else if (configProblem?.startsWith("static mode save failed:")) {
809
+ configProblem = undefined;
810
+ }
811
+ // Restart or stop the animation loop for the new mode: turning static on
812
+ // must kill a running tick, turning it off must revive one mid-run.
813
+ syncTick();
814
+ thinkingTimer.syncMode();
815
+ req();
816
+ return result.ok;
817
+ };
818
+
718
819
  pi.registerShortcut(BASH_BG_OVERLAY_KEY, {
719
820
  description: "Open background Bash jobs — live status, command, cwd, duration and output tail",
720
821
  async handler(ctx) {
@@ -741,6 +842,7 @@ export default function (pi: ExtensionAPI): void {
741
842
  const candidates = [
742
843
  "quiet", "quiet on", "quiet off", "bg", "jobs", "todo", "todo expand", "todo collapse",
743
844
  "sidebar", "sidebar auto", "sidebar on", "sidebar off", "sidebar resize",
845
+ "static", "static on", "static off",
744
846
  ];
745
847
  const matches = candidates.filter((c) => c.startsWith(query));
746
848
  return matches.length > 0 ? matches.map((c) => ({ value: c, label: c })) : null;
@@ -795,6 +897,28 @@ export default function (pi: ExtensionAPI): void {
795
897
  req();
796
898
  return;
797
899
  }
900
+ if (action === "static" || action === "static toggle") {
901
+ const ok = setStaticMode(!config.staticMode);
902
+ ctx.ui.notify(
903
+ ok
904
+ ? `Cockpit static mode ${config.staticMode ? "on — live updates reduced" : "off"}`
905
+ : "Cockpit static mode kept for this session; save failed",
906
+ ok ? "info" : "warning",
907
+ );
908
+ return;
909
+ }
910
+ if (action === "static on" || action === "static off") {
911
+ const target = action === "static on";
912
+ if (config.staticMode === target) return;
913
+ const ok = setStaticMode(target);
914
+ ctx.ui.notify(
915
+ ok
916
+ ? `Cockpit static mode ${target ? "on — live updates reduced" : "off"}`
917
+ : "Cockpit static mode kept for this session; save failed",
918
+ ok ? "info" : "warning",
919
+ );
920
+ return;
921
+ }
798
922
  await openSettings(ctx);
799
923
  },
800
924
  });
@@ -821,6 +945,7 @@ export default function (pi: ExtensionAPI): void {
821
945
  const apply = (key: string): void => {
822
946
  const wasEnabled = config.enabled;
823
947
  const wasQuiet = config.quietMode;
948
+ const wasStatic = config.staticMode;
824
949
  const wasSidebarMode = config.sidebar.mode;
825
950
  if (key === "theme") {
826
951
  // Delegate: the picker previews live and reverts on Esc, neither of
@@ -870,6 +995,10 @@ export default function (pi: ExtensionAPI): void {
870
995
  if (wasQuiet !== config.quietMode) {
871
996
  applyQuietMode(ctx, wasQuiet, config.quietMode);
872
997
  }
998
+ if (wasStatic !== config.staticMode) {
999
+ syncTick();
1000
+ thinkingTimer.syncMode();
1001
+ }
873
1002
  req();
874
1003
  };
875
1004
  const ui = {