pi-cockpit 0.18.0 → 0.20.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 CHANGED
@@ -120,7 +120,7 @@ The tab title is `frame + pi - <session> - <working state>`. The session part fo
120
120
  - `theme`: named theme override; empty string follows the Pi session theme.
121
121
  - `usage.enabled`: master switch for the usage bars (quota, balance, spend). On by default; off hides the footer segment and disables the `/usage` command. Ports the [`hknet/pi-usage-bars`](https://github.com/hknet/pi-usage-bars) extension.
122
122
  - `usage.footer`: when `true`, render the live quota bar on a dedicated footer line. The `/usage` command works regardless of this toggle. On by default.
123
- - `usage.pollIntervalMs`: how often to refresh usage data, in milliseconds. Clamped to `30000`..`1800000` (30s..30min). Default `120000` (2min). Lower values hit provider APIs more often.
123
+ - `usage.pollIntervalMs`: how often to refresh usage data, in milliseconds. Clamped to `30000`..`1800000` (30s..30min); `0` switches to manual refresh — no background polling and no footer bar; the `/usage` overlay fetches on open and re-fetches with `r`. Default `120000` (2min). Lower values hit provider APIs more often.
124
124
  - `usage.barWidth`: characters per quota bar in the footer. Clamped to `4`..`16`. Default `8`.
125
125
  - `usage.commandKey`: the `/`-command that opens the usage selector overlay. Default `"usage"`. Requires `/reload` to take effect (commands register at session start).
126
126
 
@@ -136,7 +136,7 @@ The tab title is `frame + pi - <session> - <working state>`. The session part fo
136
136
  - `/cockpit sidebar auto|on|off` — selects dock behavior.
137
137
  - `/cockpit sidebar resize` or `Ctrl+Shift+R` — enters temporary Resize mode. Left/Right adjusts one column, Shift+Left/Shift+Right adjusts four, Enter accepts, and Escape rolls back. Mouse reporting is active only during Resize mode.
138
138
  - `/cockpit quiet` — toggles quiet mode; `/cockpit bg` shows background jobs.
139
- - `/usage` — opens the usage selector overlay: quota, balance, and spend for every configured provider, with the active one marked `✓`. Search filters by name; arrows/`j`/`k` move, Enter expands the selected provider, Esc closes. Expanding a provider also shows its **7-day token trend** — a sparkline of daily token usage, a `total tok · $cost · turns/day` summary, and the top 2 models by token share — read from the optional `pi-maestro-flow` usage-history store (`~/.pi/agent/usage-history`), so it appears once that extension has recorded turns and is omitted on bare Pi. The command key is configurable via `usage.commandKey` (default `"usage"`, requires `/reload`). Disabled while `usage.enabled` is off.
139
+ - `/usage` — opens the usage selector overlay: quota, balance, and spend for every configured provider, with the active one marked `✓`. Search filters by name; arrows/`j`/`k` move, Enter expands the selected provider, Esc closes; `r` (with an empty search box) re-fetches all providers; `p` (with an empty search box) toggles background polling — persisted to `usage.pollIntervalMs` (off writes `0`, on restores the default `120000`), so the footer bar starts/stops. Expanding a provider also shows its **7-day token trend** — a sparkline of daily token usage, a `total tok · $cost · turns/day` summary, and the top 2 models by token share — read from the optional `pi-maestro-flow` usage-history store (`~/.pi/agent/usage-history`), so it appears once that extension has recorded turns and is omitted on bare Pi. The command key is configurable via `usage.commandKey` (default `"usage"`, requires `/reload`). Disabled while `usage.enabled` is off.
140
140
  - `/theme` — switch theme with live preview; `/theme <name>` applies directly. `cockpit-zen` is the restrained warm-gold theme designed for the Zen stack; selecting it does not change `stackStyle`. Pi ships no standalone theme command; cockpit provides one.
141
141
 
142
142
  ## Sidebar compatibility
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cockpit",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -67,12 +67,12 @@
67
67
  }
68
68
  },
69
69
  "devDependencies": {
70
- "@earendil-works/pi-agent-core": "0.83.0",
71
- "@earendil-works/pi-ai": "0.83.0",
72
- "@earendil-works/pi-coding-agent": "0.83.0",
73
- "@earendil-works/pi-tui": "0.83.0",
70
+ "@earendil-works/pi-agent-core": "0.84.4",
71
+ "@earendil-works/pi-ai": "0.84.4",
72
+ "@earendil-works/pi-coding-agent": "0.84.4",
73
+ "@earendil-works/pi-tui": "0.84.4",
74
74
  "@types/node": "^22.0.0",
75
- "pi-maestro-teammate": "2.1.0",
75
+ "pi-maestro-teammate": "2.3.0",
76
76
  "typebox": "1.3.7",
77
77
  "typescript": "^5.5.0"
78
78
  },
package/src/agent-bar.ts CHANGED
@@ -15,6 +15,7 @@ import type { AgentRow } from "./types.ts";
15
15
  import { effectiveAgentStatus, isCliAgent, type AgentDisplayStatus } from "./agents-store.ts";
16
16
  import { visibleAgentRows } from "./stack-widget.ts";
17
17
  import { tuiT } from "./tui-i18n.ts";
18
+ import { ownerDisplayToken } from "./window-autocomplete.ts";
18
19
 
19
20
  export const SESSION_BAR_WIDGET_KEY = "cockpit-session-bar";
20
21
  export const AGENT_BAR_WIDGET_KEY = SESSION_BAR_WIDGET_KEY;
@@ -37,12 +38,19 @@ export interface SessionBarDeps {
37
38
  getCurrentSession?: () => { label: string; color: ThemeColor } | undefined;
38
39
  }
39
40
 
41
+ export interface SessionBarHint {
42
+ text: string;
43
+ color?: ThemeColor;
44
+ }
45
+
46
+ export type SessionBarHintValue = string | SessionBarHint;
47
+
40
48
  export interface AgentBarDeps {
41
49
  getEndpoints: () => readonly CockpitEndpoint[];
42
50
  getState: () => SessionUiState;
43
51
  getNow: () => number;
44
52
  isMainRunning?: () => boolean;
45
- getShortcutHint?: () => string | undefined;
53
+ getShortcutHint?: () => SessionBarHintValue | undefined;
46
54
  }
47
55
 
48
56
  export const AGENT_SESSION_COLORS = [
@@ -154,7 +162,7 @@ function endpointColor(endpoint: CockpitEndpoint, status: AgentBarStatus, now: n
154
162
  export interface AgentBarRenderOptions {
155
163
  mainRunning?: boolean;
156
164
  now?: number;
157
- shortcutHint?: string;
165
+ shortcutHint?: SessionBarHintValue;
158
166
  }
159
167
 
160
168
  /** Chip-to-chip separator in the agent bar. */
@@ -231,11 +239,12 @@ export function renderSessionBarLine(
231
239
  renderContent: (width: number) => string,
232
240
  width: number,
233
241
  theme: Theme,
234
- shortcutHint?: string,
242
+ shortcutHint?: SessionBarHintValue,
235
243
  ): string {
236
244
  const w = Math.max(1, width);
237
245
  if (!shortcutHint) return renderContent(w);
238
- const hint = theme.fg("dim", shortcutHint);
246
+ const hintSpec = typeof shortcutHint === "string" ? { text: shortcutHint, color: "dim" as const } : shortcutHint;
247
+ const hint = theme.fg(hintSpec.color ?? "dim", hintSpec.text);
239
248
  const hintWidth = visibleWidth(hint);
240
249
  const gap = 2;
241
250
  if (w < hintWidth + gap + 8) return renderContent(w);
@@ -269,9 +278,10 @@ export function renderAgentBar(
269
278
  const outcome = status === "done" || status === "failed" || status === "terminated" || status === "sleeping"
270
279
  ? endpoint.agentRow?.lastOutcome
271
280
  : undefined;
281
+ const ownerId = endpoint.kind === "root" ? endpoint.registryEndpoint?.ownerId : undefined;
272
282
  return {
273
283
  id: endpoint.id,
274
- label: endpoint.label,
284
+ label: ownerId ? ownerDisplayToken(endpoint.label, ownerId) : endpoint.label,
275
285
  ordinal: endpoint.ordinal,
276
286
  unread: state.endpoint(endpoint.id).unread,
277
287
  endpoint,
package/src/ambient.ts CHANGED
@@ -16,6 +16,7 @@ export interface AmbientState {
16
16
  agents: readonly AgentRow[];
17
17
  jobs: readonly BashBgJob[];
18
18
  running: boolean;
19
+ waitingForInput?: boolean;
19
20
  cwd?: string;
20
21
  activeTool?: string;
21
22
  workingStartedAt?: number;
@@ -54,17 +55,32 @@ function failedJobs(jobs: readonly BashBgJob[]): BashBgJob[] {
54
55
  return jobs.filter((job) => job.status === "failed" || (job.exitCode !== null && job.exitCode !== 0));
55
56
  }
56
57
 
58
+ export function nextUiPromptDepth(depth: number, event: "start" | "end"): number {
59
+ return event === "start" ? depth + 1 : Math.max(0, depth - 1);
60
+ }
61
+
62
+ export function shouldHideWorkingDuration(
63
+ mode: "regular" | "fullscreen" | undefined,
64
+ staticMode: boolean,
65
+ ): boolean {
66
+ // Pi's regular renderer shares scrollback between the transcript and status
67
+ // row, so a moving live timer can be preserved as transcript height changes.
68
+ return staticMode || mode !== "fullscreen";
69
+ }
70
+
57
71
  /**
58
72
  * The streaming working line.
59
73
  *
60
- * Cockpit hides the host indicator and renders the active state with a live
61
- * elapsed value, matching the compact folded-thinking label.
74
+ * Cockpit hides the host indicator and renders the active state. Fullscreen may
75
+ * add a live elapsed value; the regular main screen keeps this row stable.
62
76
  */
63
77
  export function workingMessage(state: AmbientState, now = Date.now()): string | undefined {
64
- const label = state.activeTool ?? (state.running ? tuiT("ambient.working") : undefined);
78
+ const label = state.waitingForInput
79
+ ? tuiT("ambient.waitingForInput")
80
+ : state.activeTool ?? (state.running ? tuiT("ambient.working") : undefined);
65
81
  if (!label) return undefined;
66
82
  const sep = state.separator ?? " ";
67
- const text = (state.hideLiveDuration || state.workingStartedAt === undefined)
83
+ const text = (state.waitingForInput || state.hideLiveDuration || state.workingStartedAt === undefined)
68
84
  ? label
69
85
  : `${label}${sep}${formatThinkingDuration(now - state.workingStartedAt)}`;
70
86
  return `\x1b[3m${text}\x1b[23m`;
@@ -100,6 +116,8 @@ export function titleFor(
100
116
  let title: string;
101
117
  if (broken > 0) {
102
118
  title = `${marks.fail} ${base}${sep}${tuiT("ambient.failed", { count: broken })}`;
119
+ } else if (state.waitingForInput) {
120
+ title = `${prefix}${base}${sep}${tuiT("ambient.waitingForInput")}`;
103
121
  } else if (state.running) {
104
122
  const live = liveAgents(state.agents).length;
105
123
  title = live > 0
@@ -135,3 +153,51 @@ export function titleFor(
135
153
  export function statusText(problem: string | undefined, mark: string): string | undefined {
136
154
  return problem ? `${mark} cockpit: ${problem}` : undefined;
137
155
  }
156
+
157
+ /**
158
+ * Some host UI setters invalidate the whole TUI even when the value did not
159
+ * change. Cache the last ambient values so the 250ms Cockpit tick only mutates
160
+ * host surfaces when there is a real text change.
161
+ */
162
+ export class AmbientSurfaceCache {
163
+ private workingMessageKnown = false;
164
+ private workingMessage: string | undefined;
165
+ private titleKnown = false;
166
+ private title: string | undefined;
167
+ private readonly statuses = new Map<string, string | undefined>();
168
+
169
+ reset(): void {
170
+ this.workingMessageKnown = false;
171
+ this.workingMessage = undefined;
172
+ this.titleKnown = false;
173
+ this.title = undefined;
174
+ this.statuses.clear();
175
+ }
176
+
177
+ setWorkingMessage(setter: (message: string | undefined) => void, message: string | undefined): boolean {
178
+ if (this.workingMessageKnown && this.workingMessage === message) return false;
179
+ this.workingMessageKnown = true;
180
+ this.workingMessage = message;
181
+ setter(message);
182
+ return true;
183
+ }
184
+
185
+ setTitle(setter: (title: string) => void, title: string): boolean {
186
+ if (this.titleKnown && this.title === title) return false;
187
+ this.titleKnown = true;
188
+ this.title = title;
189
+ setter(title);
190
+ return true;
191
+ }
192
+
193
+ setStatus(
194
+ key: string,
195
+ setter: (key: string, text: string | undefined) => void,
196
+ text: string | undefined,
197
+ ): boolean {
198
+ if (this.statuses.has(key) && this.statuses.get(key) === text) return false;
199
+ this.statuses.set(key, text);
200
+ setter(key, text);
201
+ return true;
202
+ }
203
+ }
package/src/config.ts CHANGED
@@ -23,7 +23,9 @@ function mergeUsageConfig(base: UsageConfig, raw: unknown): UsageConfig {
23
23
  const isFiniteInt = (v: unknown): v is number =>
24
24
  typeof v === "number" && Number.isFinite(v) && Number.isSafeInteger(v);
25
25
  const pollIntervalMs = isFiniteInt(o.pollIntervalMs)
26
- ? Math.min(USAGE_POLL_MAX_MS, Math.max(USAGE_POLL_MIN_MS, o.pollIntervalMs))
26
+ ? o.pollIntervalMs <= 0
27
+ ? 0 // 0 = manual refresh: no background poll, /usage fetches on open and on `r`
28
+ : Math.min(USAGE_POLL_MAX_MS, Math.max(USAGE_POLL_MIN_MS, o.pollIntervalMs))
27
29
  : base.pollIntervalMs;
28
30
  const barWidth = isFiniteInt(o.barWidth)
29
31
  ? Math.min(USAGE_BAR_MAX_WIDTH, Math.max(USAGE_BAR_MIN_WIDTH, o.barWidth))
@@ -7,7 +7,7 @@
7
7
  * so a brand new session picks up where the previous one left off.
8
8
  */
9
9
 
10
- import { createHash } from "node:crypto";
10
+ import { createHash, randomUUID } from "node:crypto";
11
11
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { basename, join, resolve } from "node:path";
@@ -19,6 +19,9 @@ const DEFAULT_MAX_ENTRIES = 100;
19
19
  const DEFAULT_SAVE_DEBOUNCE_MS = 250;
20
20
  const PRIVATE_DIRECTORY_MODE = 0o700;
21
21
  const PRIVATE_FILE_MODE = 0o600;
22
+ const DEFAULT_SAVE_RETRY_DELAYS_MS = Object.freeze([25, 75, 150]);
23
+
24
+ type RenameFile = (oldPath: string, newPath: string) => Promise<void>;
22
25
 
23
26
  /**
24
27
  * Serializes every save across store instances so a fresh store's load() never
@@ -32,6 +35,8 @@ export interface InputHistoryStoreOptions {
32
35
  maxEntries?: number;
33
36
  debounceMs?: number;
34
37
  onError?: (error: unknown) => void;
38
+ renameFile?: RenameFile;
39
+ retryDelaysMs?: readonly number[];
35
40
  }
36
41
 
37
42
  /**
@@ -59,6 +64,8 @@ export class InputHistoryStore {
59
64
  private readonly maxEntries: number;
60
65
  private readonly debounceMs: number;
61
66
  private readonly onError: ((error: unknown) => void) | undefined;
67
+ private readonly renameFile: RenameFile;
68
+ private readonly retryDelaysMs: readonly number[];
62
69
  private entries: string[] = [];
63
70
  private timer: NodeJS.Timeout | undefined;
64
71
  private writes: Promise<void> = Promise.resolve();
@@ -70,6 +77,8 @@ export class InputHistoryStore {
70
77
  this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES);
71
78
  this.debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_SAVE_DEBOUNCE_MS);
72
79
  this.onError = options.onError;
80
+ this.renameFile = options.renameFile ?? rename;
81
+ this.retryDelaysMs = retryDelays(options.retryDelaysMs);
73
82
  }
74
83
 
75
84
  /** Newest first. */
@@ -124,21 +133,30 @@ export class InputHistoryStore {
124
133
  }
125
134
 
126
135
  private async save(): Promise<void> {
136
+ await mkdir(this.dir, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
137
+ for (let attempt = 0; ; attempt += 1) {
138
+ await this.mergeDiskEntries();
139
+ const temp = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
140
+ try {
141
+ const payload = JSON.stringify({ version: 1, entries: this.entries }, null, 2);
142
+ await writeFile(temp, payload, { mode: PRIVATE_FILE_MODE, flag: "wx" });
143
+ await this.renameFile(temp, this.filePath);
144
+ return;
145
+ } catch (error) {
146
+ await rm(temp, { force: true }).catch(() => undefined);
147
+ const retryDelay = this.retryDelaysMs[attempt];
148
+ if (retryDelay === undefined || !isTransientReplaceError(error)) throw error;
149
+ await delay(retryDelay);
150
+ }
151
+ }
152
+ }
153
+
154
+ private async mergeDiskEntries(): Promise<void> {
127
155
  // Another pi window in the same workspace may have written since we loaded;
128
156
  // keep whatever it added instead of overwriting it.
129
157
  const known = new Set(this.entries);
130
158
  const disk = (await this.readFromDisk()).filter((value) => !known.has(value));
131
159
  this.entries = capped([...this.entries, ...disk], this.maxEntries);
132
- await mkdir(this.dir, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
133
- const temp = `${this.filePath}.${process.pid}.tmp`;
134
- try {
135
- const payload = JSON.stringify({ version: 1, entries: this.entries }, null, 2);
136
- await writeFile(temp, payload, { mode: PRIVATE_FILE_MODE });
137
- await rename(temp, this.filePath);
138
- } catch (error) {
139
- await rm(temp, { force: true });
140
- throw error;
141
- }
142
160
  }
143
161
 
144
162
  private async readFromDisk(): Promise<string[]> {
@@ -171,3 +189,17 @@ function parseEntries(raw: unknown): string[] {
171
189
  function capped(entries: string[], maxEntries: number): string[] {
172
190
  return entries.length > maxEntries ? entries.slice(0, maxEntries) : entries;
173
191
  }
192
+
193
+ function retryDelays(value: readonly number[] | undefined): readonly number[] {
194
+ const source = value ?? DEFAULT_SAVE_RETRY_DELAYS_MS;
195
+ return Object.freeze(source.map((delayMs) => Math.max(0, delayMs)).filter(Number.isFinite));
196
+ }
197
+
198
+ function isTransientReplaceError(error: unknown): boolean {
199
+ const code = (error as NodeJS.ErrnoException | null)?.code;
200
+ return code === "EPERM" || code === "EBUSY" || code === "EACCES";
201
+ }
202
+
203
+ function delay(ms: number): Promise<void> {
204
+ return new Promise((resolve) => setTimeout(resolve, ms));
205
+ }
@@ -507,7 +507,7 @@ export class EndpointStore {
507
507
  id: `${MONITOR_CONTROL_ENDPOINT_PREFIX}${encodeURIComponent(root.id)}`,
508
508
  logicalKey: `monitor-control:${root.ownerId}:${root.ownerNonce}`,
509
509
  kind: "window",
510
- label: "control",
510
+ label: `control·${root.ownerId.slice(0, 6)}`,
511
511
  ordinal: Number.MIN_SAFE_INTEGER,
512
512
  status: root.status,
513
513
  contentRevision: revisionOf([root.contentRevision, controlActivityRevision]),
package/src/icons.ts CHANGED
@@ -149,13 +149,11 @@ export function resolveGlyphs(mode: IconMode): IconGlyphs {
149
149
  /**
150
150
  * One source of truth for animation cadence.
151
151
  *
152
- * The frame clock and the redraw tick must be the same number. When frames
153
- * advanced every 120ms but the UI only repainted every 250ms, each repaint
154
- * skipped ~2 frames: the 6-frame braille spinner ran at half speed and the
155
- * 4-frame ASCII spinner degenerated into a two-glyph flip (| ↔ -), which reads
156
- * as blinking rather than rotation.
152
+ * The frame clock and redraw tick stay aligned so terminal animation advances
153
+ * one frame per paint. A half-second cadence remains legible without keeping
154
+ * the terminal renderer busy during long-running work.
157
155
  */
158
- export const ANIMATION_PERIOD_MS = 250;
156
+ export const ANIMATION_PERIOD_MS = 500;
159
157
 
160
158
  /**
161
159
  * The frame to draw at `now`, or a stable glyph when nothing is driving redraws.