pi-cockpit 0.18.0 → 0.19.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.19.0",
4
4
  "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -72,7 +72,7 @@
72
72
  "@earendil-works/pi-coding-agent": "0.83.0",
73
73
  "@earendil-works/pi-tui": "0.83.0",
74
74
  "@types/node": "^22.0.0",
75
- "pi-maestro-teammate": "2.1.0",
75
+ "pi-maestro-teammate": "2.2.0",
76
76
  "typebox": "1.3.7",
77
77
  "typescript": "^5.5.0"
78
78
  },
package/src/ambient.ts CHANGED
@@ -135,3 +135,51 @@ export function titleFor(
135
135
  export function statusText(problem: string | undefined, mark: string): string | undefined {
136
136
  return problem ? `${mark} cockpit: ${problem}` : undefined;
137
137
  }
138
+
139
+ /**
140
+ * Some host UI setters invalidate the whole TUI even when the value did not
141
+ * change. Cache the last ambient values so the 250ms Cockpit tick only mutates
142
+ * host surfaces when there is a real text change.
143
+ */
144
+ export class AmbientSurfaceCache {
145
+ private workingMessageKnown = false;
146
+ private workingMessage: string | undefined;
147
+ private titleKnown = false;
148
+ private title: string | undefined;
149
+ private readonly statuses = new Map<string, string | undefined>();
150
+
151
+ reset(): void {
152
+ this.workingMessageKnown = false;
153
+ this.workingMessage = undefined;
154
+ this.titleKnown = false;
155
+ this.title = undefined;
156
+ this.statuses.clear();
157
+ }
158
+
159
+ setWorkingMessage(setter: (message: string | undefined) => void, message: string | undefined): boolean {
160
+ if (this.workingMessageKnown && this.workingMessage === message) return false;
161
+ this.workingMessageKnown = true;
162
+ this.workingMessage = message;
163
+ setter(message);
164
+ return true;
165
+ }
166
+
167
+ setTitle(setter: (title: string) => void, title: string): boolean {
168
+ if (this.titleKnown && this.title === title) return false;
169
+ this.titleKnown = true;
170
+ this.title = title;
171
+ setter(title);
172
+ return true;
173
+ }
174
+
175
+ setStatus(
176
+ key: string,
177
+ setter: (key: string, text: string | undefined) => void,
178
+ text: string | undefined,
179
+ ): boolean {
180
+ if (this.statuses.has(key) && this.statuses.get(key) === text) return false;
181
+ this.statuses.set(key, text);
182
+ setter(key, text);
183
+ return true;
184
+ }
185
+ }
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
+ }
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.
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import type { TUI } from "@earendil-works/pi-tui";
5
5
  import { ambientKeysShouldYield, capturingOverlayVisible } from "./capturing-overlay.ts";
6
6
  import { Key, decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
7
  import { AgentReadStoreRouter, effectiveAgentStatus, type CompletePayload, type MessagePayload, type StartedPayload } from "./agents-store.ts";
8
- import { statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
8
+ import { AmbientSurfaceCache, statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
9
9
  import { generateTitleWithModel } from "./title-llm.ts";
10
10
  import { suggestTitle } from "./title-gen.ts";
11
11
  import { BashBgStore } from "./bash-bg-store.ts";
@@ -89,6 +89,7 @@ import {
89
89
  COCKPIT_PREEMPT_RESIZE_EVENT,
90
90
  COCKPIT_SESSION_LIST_EVENT,
91
91
  COCKPIT_TODO_TOGGLE_EVENT,
92
+ MAESTRO_TODO_STATE_CHANGED_EVENT,
92
93
  MAESTRO_UI_SNAPSHOT_EVENT,
93
94
  MAESTRO_UI_SNAPSHOT_VERSION,
94
95
  SUPERVISION_EVENT,
@@ -152,7 +153,7 @@ const COCKPIT_STATUS_KEY = "cockpit";
152
153
  // a turn runs (screens/REPL.tsx TITLE_STATIC_PREFIX / TITLE_ANIMATION_FRAMES).
153
154
  const TITLE_STATIC = "✳";
154
155
  const TITLE_FRAMES = ["⠂", "⠐"];
155
- const WIDTH_POLL_INTERVAL_MS = 250;
156
+ const WIDTH_POLL_INTERVAL_MS = 500;
156
157
  // Static mode keeps token totals fresh enough without recomputing on every message.
157
158
  const USAGE_REFRESH_THROTTLE_MS = 10_000;
158
159
  // Quiet mode's rename of pi's hidden-thinking label. The live thinking timer
@@ -416,6 +417,7 @@ export default function (pi: ExtensionAPI): void {
416
417
  // Persisted rather than toasted: a config that failed to load silently downgrades
417
418
  // the whole session to defaults, so it belongs in a slot that does not scroll away.
418
419
  let configProblem: string | undefined;
420
+ const ambientSurfaces = new AmbientSurfaceCache();
419
421
  let quietToolsRegistered = false;
420
422
 
421
423
  // Register quiet tools at extension load time, not just in session_start.
@@ -483,8 +485,8 @@ export default function (pi: ExtensionAPI): void {
483
485
  const g = resolveGlyphs(config.icons.mode);
484
486
  try {
485
487
  if (!config.enabled) {
486
- ctx.ui.setWorkingMessage(undefined);
487
- ctx.ui.setStatus(COCKPIT_STATUS_KEY, undefined);
488
+ ambientSurfaces.setWorkingMessage((message) => ctx.ui.setWorkingMessage(message), undefined);
489
+ ambientSurfaces.setStatus(COCKPIT_STATUS_KEY, (key, text) => ctx.ui.setStatus(key, text), undefined);
488
490
  return;
489
491
  }
490
492
  const activeTool = [...activeTools.values()].at(-1);
@@ -500,7 +502,7 @@ export default function (pi: ExtensionAPI): void {
500
502
  hideLiveDuration: config.staticMode,
501
503
  separator: ` ${g.separator} `,
502
504
  };
503
- ctx.ui.setWorkingMessage(workingMessage(state, now));
505
+ ambientSurfaces.setWorkingMessage((message) => ctx.ui.setWorkingMessage(message), workingMessage(state, now));
504
506
  if (title.enabled) {
505
507
  state.session = title.showSession ? sessionTag(ctx.sessionManager, aiTitle) : undefined;
506
508
  state.model = title.showModel ? modelTag(ctx.model) : undefined;
@@ -511,9 +513,12 @@ export default function (pi: ExtensionAPI): void {
511
513
  ? maestroKnowledgeTag(maestro.snapshot()?.workflow?.knowledge)
512
514
  : undefined;
513
515
  state.frame = titleFrame(running);
514
- ctx.ui.setTitle(titleFor(state, { ok: g.check, fail: g.cross }, g.separator, { maxLength: title.maxLength }));
516
+ ambientSurfaces.setTitle(
517
+ (value) => ctx.ui.setTitle(value),
518
+ titleFor(state, { ok: g.check, fail: g.cross }, g.separator, { maxLength: title.maxLength }),
519
+ );
515
520
  }
516
- ctx.ui.setStatus(COCKPIT_STATUS_KEY, statusText(configProblem, g.blocked));
521
+ ambientSurfaces.setStatus(COCKPIT_STATUS_KEY, (key, text) => ctx.ui.setStatus(key, text), statusText(configProblem, g.blocked));
517
522
  } catch {
518
523
  // ambient surfaces are best-effort; never let them break a render
519
524
  }
@@ -601,6 +606,12 @@ export default function (pi: ExtensionAPI): void {
601
606
  pi,
602
607
  getConfig: () => config,
603
608
  onStatusChange: () => req(),
609
+ setPollIntervalMs: (ms: number) => {
610
+ config = { ...config, usage: { ...config.usage, pollIntervalMs: ms } };
611
+ const result = saveConfig(config);
612
+ req();
613
+ return result.ok;
614
+ },
604
615
  });
605
616
  const policy = (): TickPolicyState => ({
606
617
  staticMode: config.staticMode,
@@ -1333,6 +1344,7 @@ export default function (pi: ExtensionAPI): void {
1333
1344
  } catch {
1334
1345
  // ambient surfaces are best-effort
1335
1346
  }
1347
+ ambientSurfaces.reset();
1336
1348
  stopTick();
1337
1349
  viewportStabilityPatch?.detach();
1338
1350
  viewportStabilityPatch = undefined;
@@ -1694,6 +1706,12 @@ export default function (pi: ExtensionAPI): void {
1694
1706
  if (!maestro.applySnapshot(payload)) return;
1695
1707
  req();
1696
1708
  }),
1709
+ pi.events.on(MAESTRO_TODO_STATE_CHANGED_EVENT, (payload) => {
1710
+ if (!payload || typeof payload !== "object" || (payload as { version?: unknown }).version !== 1) return;
1711
+ const ctx = lastCtx;
1712
+ if (!ctx || !todos.hydrateFromEntries(ctx.sessionManager.getEntries())) return;
1713
+ req();
1714
+ }),
1697
1715
  pi.events.on(SUPERVISION_EVENT, (payload) => {
1698
1716
  if (!supervision.applyEvent(payload)) return;
1699
1717
  req();
@@ -1748,6 +1766,7 @@ export default function (pi: ExtensionAPI): void {
1748
1766
  // --- session + agent lifecycle ---
1749
1767
  pi.on("session_start", (_e, ctx) => {
1750
1768
  lastCtx = ctx;
1769
+ ambientSurfaces.reset();
1751
1770
  agentReads.bindSession(ctx.sessionManager.getSessionId());
1752
1771
  agents = agentReads.current;
1753
1772
  usingRuntimeV2 = false;
@@ -2465,7 +2484,8 @@ export default function (pi: ExtensionAPI): void {
2465
2484
  else usageSubsystem?.stop(ctx);
2466
2485
  }
2467
2486
  // footer/footer-toggle/barWidth change only the rendered string (the
2468
- // next render reads them fresh); pollIntervalMs change restarts the poller.
2487
+ // next render reads them fresh); pollIntervalMs change restarts the
2488
+ // poller (0 switches to manual refresh: no timer, no footer bar).
2469
2489
  if (previous.usage.pollIntervalMs !== nextConfig.usage.pollIntervalMs && nextConfig.usage.enabled) {
2470
2490
  usageSubsystem?.rescheduleTimer();
2471
2491
  usageSubsystem?.refresh();
@@ -3,6 +3,10 @@
3
3
  export const COCKPIT_MAESTRO_QUERY_EVENT = "cockpit:maestro-query";
4
4
  export const MAESTRO_UI_SNAPSHOT_EVENT = "maestro:ui-snapshot";
5
5
  export const MAESTRO_UI_SNAPSHOT_VERSION = 1 as const;
6
+ export const MAESTRO_TODO_STATE_CHANGED_EVENT = "maestro:todo-state-changed";
7
+ export interface MaestroTodoStateChangedV1 {
8
+ version: 1;
9
+ }
6
10
 
7
11
  /**
8
12
  * Unified supervision telemetry emitted by the shared supervision layer
@@ -190,6 +194,7 @@ export interface CockpitUiOwnershipV1 {
190
194
  export interface MaestroEventMapV1 {
191
195
  "cockpit:maestro-query": MaestroQueryV1;
192
196
  "maestro:ui-snapshot": MaestroUiSnapshotV1;
197
+ "maestro:todo-state-changed": MaestroTodoStateChangedV1;
193
198
  "cockpit:ui-ownership": CockpitUiOwnershipV1;
194
199
  "cockpit:open-session-list": CockpitSessionListRequestV1;
195
200
  "cockpit:preempt-resize": undefined;
@@ -211,7 +211,7 @@ const CATALOGS = {
211
211
  "cockpit.usage.footer": "Show in footer",
212
212
  "cockpit.usage.footer.description": "Render the live quota bar on a dedicated footer line. The /usage command works regardless of this toggle.",
213
213
  "cockpit.usage.pollIntervalMs": "Poll interval (ms)",
214
- "cockpit.usage.pollIntervalMs.description": "How often to refresh usage data. Clamped to 30s..30min. Lower values hit provider APIs more often.",
214
+ "cockpit.usage.pollIntervalMs.description": "How often to refresh usage data. Clamped to 30s..30min; 0 = manual refresh only (no polling, no footer bar; /usage fetches on open and with `r`). Lower values hit provider APIs more often.",
215
215
  "cockpit.usage.barWidth": "Bar width",
216
216
  "cockpit.usage.barWidth.description": "Characters per quota bar in the footer. Clamped to 4..16.",
217
217
  "cockpit.usage.commandKey": "Command key",
@@ -296,7 +296,7 @@ const CATALOGS = {
296
296
  "cockpit.usage.footer": "在状态栏显示",
297
297
  "cockpit.usage.footer.description": "在专属状态栏行渲染实时配额条。/usage 命令不受此开关影响。",
298
298
  "cockpit.usage.pollIntervalMs": "轮询间隔(毫秒)",
299
- "cockpit.usage.pollIntervalMs.description": "刷新用量数据的频率,钳制在 30 秒..30 分钟。值越小调用供应商 API 越频繁。",
299
+ "cockpit.usage.pollIntervalMs.description": "刷新用量数据的频率,钳制在 30 秒..30 分钟;0 = 仅手动刷新(不轮询、不显示状态栏;进入 /usage 时抓取并按 r 刷新)。值越小调用供应商 API 越频繁。",
300
300
  "cockpit.usage.barWidth": "进度条宽度",
301
301
  "cockpit.usage.barWidth.description": "状态栏中每条配额条的字符宽度,钳制在 4..16。",
302
302
  "cockpit.usage.commandKey": "命令键",
@@ -423,7 +423,7 @@ const DEFINITIONS: readonly SettingDefinition[] = [
423
423
  activation: "live",
424
424
  sensitivity: "public",
425
425
  reversibility: "full",
426
- editor: { kind: "integer", min: 30_000, max: 1_800_000, step: 1000 },
426
+ editor: { kind: "integer", min: 0, max: 1_800_000, step: 1000 },
427
427
  },
428
428
  {
429
429
  key: "usage.barWidth",
@@ -795,7 +795,7 @@ function validValue(key: CockpitSettingKey, value: JsonValue): boolean {
795
795
  if (key === "title.generationModel") return typeof value === "string";
796
796
  if (key.startsWith("title.")) return typeof value === "boolean";
797
797
  if (key === "usage.enabled" || key === "usage.footer") return typeof value === "boolean";
798
- if (key === "usage.pollIntervalMs") return typeof value === "number" && Number.isSafeInteger(value) && value >= 30_000 && value <= 1_800_000;
798
+ if (key === "usage.pollIntervalMs") return typeof value === "number" && Number.isSafeInteger(value) && (value === 0 || (value >= 30_000 && value <= 1_800_000));
799
799
  if (key === "usage.barWidth") return typeof value === "number" && Number.isSafeInteger(value) && value >= 4 && value <= 16;
800
800
  if (key === "usage.commandKey") return typeof value === "string" && value.trim().length > 0;
801
801
  if (key === "quietSymbols") return value === "check" || value === "dot";
package/src/todo-store.ts CHANGED
@@ -48,6 +48,17 @@ function asSkills(value: unknown): TodoSkill[] {
48
48
  return skills;
49
49
  }
50
50
 
51
+ function sameActor(left: TodoActor | undefined, right: TodoActor | undefined): boolean {
52
+ return left?.id === right?.id && left?.label === right?.label;
53
+ }
54
+
55
+ function sameSkills(left: readonly TodoSkill[], right: readonly TodoSkill[]): boolean {
56
+ return left.length === right.length && left.every((skill, index) => {
57
+ const candidate = right[index];
58
+ return candidate !== undefined && skill.name === candidate.name && skill.role === candidate.role;
59
+ });
60
+ }
61
+
51
62
  // Map the todo tool's status strings onto our four display states.
52
63
  // "deleted" is returned verbatim so the caller can drop the task.
53
64
  export function mapStatus(raw: unknown): TodoState | "deleted" {
@@ -115,7 +126,11 @@ export class TodoStore {
115
126
  || item.status !== candidate.status
116
127
  || item.subject !== candidate.subject
117
128
  || item.blockedBy.length !== candidate.blockedBy.length
118
- || item.blockedBy.some((id, blockedIndex) => id !== candidate.blockedBy[blockedIndex]);
129
+ || item.blockedBy.some((id, blockedIndex) => id !== candidate.blockedBy[blockedIndex])
130
+ || !sameActor(item.createdBy, candidate.createdBy)
131
+ || !sameActor(item.assignee, candidate.assignee)
132
+ || !sameSkills(item.skills, candidate.skills)
133
+ || item.updatedAt !== candidate.updatedAt;
119
134
  });
120
135
  if (!changed) return false;
121
136
  this.items = next;
package/src/types.ts CHANGED
@@ -221,7 +221,7 @@ export interface UsageConfig {
221
221
  enabled: boolean;
222
222
  /** Show the live quota bar in the Cockpit footer (a dedicated footer line). */
223
223
  footer: boolean;
224
- /** Poll interval in milliseconds; clamped 30s..30min. Default 2min. */
224
+ /** Poll interval in milliseconds; clamped 30s..30min. Default 2min. 0 = manual refresh (no polling, no footer bar; /usage fetches on open and on `r`). */
225
225
  pollIntervalMs: number;
226
226
  /** Characters per quota bar in the footer; clamped 4..16. Default 8. */
227
227
  barWidth: number;