gentle-pi 3.2.0 → 3.3.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.
Files changed (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -1,5 +1,6 @@
1
- import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1
+ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
2
  import { renderUsagePanel, type ActiveProvider, type UsageStore, type UsageTheme } from "./shell-usage.ts";
3
+ import { paintHoverable } from "./shell-hover.ts";
3
4
 
4
5
  // Gentle Shell subscriptions overlay: a framed panel over the usage store.
5
6
  // It reads the store on every render, so a refresh only needs to record.
@@ -33,10 +34,32 @@ function fit(text: string, width: number): string {
33
34
  return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
34
35
  }
35
36
 
37
+ // Column offset of the footer's hint text within the rendered line: the
38
+ // frame draws "│ " before the fitted content starts.
39
+ const HINT_CONTENT_OFFSET = 2;
40
+ const HINT_GAP = " ";
41
+
42
+ type HintAction = "refresh" | "close";
43
+
44
+ interface HintSpan {
45
+ start: number;
46
+ end: number;
47
+ action: HintAction;
48
+ }
49
+
50
+ interface PointerLayout {
51
+ width: number;
52
+ height: number;
53
+ row: number;
54
+ spans: HintSpan[];
55
+ }
56
+
36
57
  export class UsageView {
37
58
  private readonly store: UsageStore;
38
59
  private readonly deps: UsageViewDeps;
39
60
  private refreshing = false;
61
+ private pointer: PointerLayout | undefined;
62
+ private hoveredHint: HintAction | undefined;
40
63
 
41
64
  constructor(store: UsageStore, deps: UsageViewDeps) {
42
65
  this.store = store;
@@ -48,13 +71,25 @@ export class UsageView {
48
71
  this.deps.onClose();
49
72
  return;
50
73
  }
51
- if (data === "r" && !this.refreshing) {
52
- this.refreshing = true;
74
+ if (data === "r") this.refresh();
75
+ }
76
+
77
+ // A failing usage fetch is the store's problem to report (its rows already
78
+ // carry the last error); the panel only clears its "refreshing" state. The
79
+ // rejection must never leave this method: an unhandled rejection is fatal
80
+ // to the whole shell on current Node.
81
+ private refresh(): void {
82
+ if (this.refreshing) return;
83
+ this.refreshing = true;
84
+ this.deps.requestRender();
85
+ const settle = () => {
86
+ this.refreshing = false;
53
87
  this.deps.requestRender();
54
- void this.deps.onRefresh().finally(() => {
55
- this.refreshing = false;
56
- this.deps.requestRender();
57
- });
88
+ };
89
+ try {
90
+ this.deps.onRefresh().then(settle, settle);
91
+ } catch {
92
+ settle();
58
93
  }
59
94
  }
60
95
 
@@ -66,11 +101,64 @@ export class UsageView {
66
101
  const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active()).map(
67
102
  (line) => `${theme.fg(FRAME_ROLE, "│")} ${fit(line, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`,
68
103
  );
69
- const keys = KEYS.map(([key, label]) => `${theme.fg(KEY_ROLE, key)} ${theme.fg(KEY_TEXT_ROLE, label)}`).join(" ");
104
+ const hints = KEYS.map(([key, label]) => ({ key, label, text: `${key} ${label}`, action: (key === "r" ? "refresh" : "close") as HintAction }));
105
+ // The hovered hint paints entirely in the shared hover role (key and
106
+ // label together, one color) instead of its ordinary two-role split --
107
+ // the same treatment every other clickable surface uses.
108
+ const keys = hints
109
+ .map(({ key, label, action }) =>
110
+ this.hoveredHint === action ? paintHoverable(theme, `${key} ${label}`, true) : `${theme.fg(KEY_ROLE, key)} ${theme.fg(KEY_TEXT_ROLE, label)}`,
111
+ )
112
+ .join(HINT_GAP);
70
113
  const keysLine = `${theme.fg(FRAME_ROLE, "│")} ${fit(keys, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`;
71
114
  const bottom = theme.fg(FRAME_ROLE, `╰${rule(inner)}╯`);
72
- return [top, ...body, keysLine, bottom];
115
+ const lines = [top, ...body, keysLine, bottom];
116
+ this.pointer = this.hintLayout(width, lines.length, body.length + 1, hints, inner - 2);
117
+ return lines;
73
118
  }
74
119
 
75
- invalidate(): void {}
120
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
121
+ if (event.type === "move" && event.button === "none") {
122
+ const layout = this.pointer;
123
+ const action = layout && event.width === layout.width && event.height === layout.height && event.y === layout.row
124
+ ? layout.spans.find((candidate) => event.x >= candidate.start && event.x < candidate.end)?.action
125
+ : undefined;
126
+ if (action === this.hoveredHint) return action ? { handled: true } : undefined;
127
+ this.hoveredHint = action;
128
+ return { handled: true, render: true };
129
+ }
130
+ if (event.type !== "click" || event.button !== "left") return undefined;
131
+ const layout = this.pointer;
132
+ if (!layout || event.width !== layout.width || event.height !== layout.height || event.y !== layout.row) return undefined;
133
+ const span = layout.spans.find((candidate) => event.x >= candidate.start && event.x < candidate.end);
134
+ if (!span) return undefined;
135
+ if (span.action === "close") {
136
+ this.deps.onClose();
137
+ return { handled: true, render: true };
138
+ }
139
+ this.refresh();
140
+ return { handled: true, render: true };
141
+ }
142
+
143
+ invalidate(): void {
144
+ this.pointer = undefined;
145
+ this.hoveredHint = undefined;
146
+ }
147
+
148
+ // Spans are only registered when the hints text fits without truncation:
149
+ // past that point `fit` clips it with an ellipsis and per-hint columns no
150
+ // longer line up with the plain "key label" text used here.
151
+ private hintLayout(width: number, height: number, row: number, hints: Array<{ text: string; action: HintAction }>, contentWidth: number): PointerLayout | undefined {
152
+ const plainWidth = hints.reduce((total, hint) => total + hint.text.length, 0) + HINT_GAP.length * Math.max(0, hints.length - 1);
153
+ if (plainWidth > contentWidth) return undefined;
154
+ const spans: HintSpan[] = [];
155
+ let cursor = HINT_CONTENT_OFFSET;
156
+ for (const hint of hints) {
157
+ const start = cursor;
158
+ const end = start + hint.text.length;
159
+ spans.push({ start, end, action: hint.action });
160
+ cursor = end + HINT_GAP.length;
161
+ }
162
+ return { width, height, row, spans };
163
+ }
76
164
  }
@@ -11,6 +11,11 @@ export interface UsageWindow {
11
11
  usedPercent: number;
12
12
  windowSeconds: number;
13
13
  resetAt: number | null;
14
+ // Raw allowance numbers, kept only by providers that report them (NaN).
15
+ // Aggregates are weighted by budget, so averaging percentages is never
16
+ // needed; nothing renders these fields directly.
17
+ used?: number;
18
+ budget?: number;
14
19
  }
15
20
 
16
21
  export interface UsageLimit {
@@ -54,8 +59,27 @@ interface RawCodexUsage {
54
59
  additional_rate_limits?: RawAdditionalLimit[] | null;
55
60
  }
56
61
 
62
+ interface RawNanModel {
63
+ model?: unknown;
64
+ cap?: unknown;
65
+ fullCap?: unknown;
66
+ tokensUsed?: unknown;
67
+ periodEnd?: unknown;
68
+ windowHours?: unknown;
69
+ windowTokens?: unknown;
70
+ fullWindowTokens?: unknown;
71
+ windowTokensUsed?: unknown;
72
+ windowResetsAt?: unknown;
73
+ }
74
+
75
+ interface RawNanQuota {
76
+ models?: unknown;
77
+ periodEnd?: unknown;
78
+ }
79
+
57
80
  export const CODEX_PROVIDER = "openai-codex";
58
81
  export const ANTHROPIC_PROVIDER = "anthropic";
82
+ export const NAN_PROVIDER = "nan";
59
83
  const ANTHROPIC_MAIN_LIMIT = "claude";
60
84
  const ANTHROPIC_PREFIX = "anthropic-ratelimit-unified-";
61
85
  const ANTHROPIC_WINDOWS: ReadonlyArray<[key: string, seconds: number]> = [
@@ -63,6 +87,17 @@ const ANTHROPIC_WINDOWS: ReadonlyArray<[key: string, seconds: number]> = [
63
87
  ["7d", 604_800],
64
88
  ];
65
89
  export const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
90
+ // The NaN Cloud dashboard backend; not part of NaN's published OpenAPI, so the
91
+ // fetch that uses it is fixed-origin, redirect-refusing, and schema-validated.
92
+ export const NAN_QUOTA_URL = "https://cloud-api.nan.builders/api/usage/quota";
93
+ // The model's own allowance for the billing period carries no label: the model
94
+ // id names it in the bar, and the reset text says what the window is in the
95
+ // panel. Only a sub-window on top of it (a rolling `4h`) needs a name.
96
+ const NAN_PERIOD_LABEL = "";
97
+ // The dashboard's own published fallbacks for a model that reports rolling
98
+ // numbers without naming its budget.
99
+ const NAN_DEFAULT_WINDOW_TOKENS = 400_000_000;
100
+ const NAN_DEFAULT_WINDOW_HOURS = 4;
66
101
  const CODEX_MAIN_LIMIT = "codex";
67
102
  const CODEX_ACCOUNT_CLAIM = "https://api.openai.com/auth";
68
103
  const HEADER_PREFIX = "x-codex-";
@@ -81,9 +116,10 @@ const ROLE = {
81
116
  SEPARATOR: "muted",
82
117
  } as const;
83
118
  export const USAGE_EMPTY_MESSAGE = "No subscription usage yet. Usage arrives with the next response, or press r to fetch it.";
84
- export const SUPPORTED_USAGE_PROVIDERS: readonly string[] = [CODEX_PROVIDER, ANTHROPIC_PROVIDER];
119
+ export const SUPPORTED_USAGE_PROVIDERS: readonly string[] = [CODEX_PROVIDER, ANTHROPIC_PROVIDER, NAN_PROVIDER];
85
120
  const PENDING_NOTE: Record<string, string> = {
86
121
  [CODEX_PROVIDER]: "no usage yet · r to fetch",
122
+ [NAN_PROVIDER]: "no usage yet · r to fetch",
87
123
  [ANTHROPIC_PROVIDER]: "usage arrives with the first response",
88
124
  };
89
125
  const UNSUPPORTED_NOTE = "no subscription usage for this provider";
@@ -174,6 +210,178 @@ export function parseUsageHeaders(headers: Record<string, string>, now: number):
174
210
  return parseCodexHeaders(headers, now) ?? parseAnthropicHeaders(headers, now);
175
211
  }
176
212
 
213
+ // NaN Cloud reports one allowance per model for the billing period, plus the
214
+ // rolling window the model applies on top of it. Percentages follow the
215
+ // dashboard exactly: tokens used over the period allowance, and window tokens
216
+ // over the full window budget. Every field is optional, because this payload
217
+ // lives outside NaN's published contract.
218
+ function quotaTimestamp(value: unknown): number | null {
219
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value * 1000;
220
+ if (typeof value !== "string") return null;
221
+ const parsed = Date.parse(value);
222
+ return Number.isNaN(parsed) ? null : parsed;
223
+ }
224
+
225
+ function quotaNumber(value: unknown, positive: boolean): number | undefined {
226
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
227
+ return positive ? (value > 0 ? value : undefined) : value >= 0 ? value : undefined;
228
+ }
229
+
230
+ function nanRollingWindow(raw: RawNanModel): UsageWindow | undefined {
231
+ const used = quotaNumber(raw.windowTokensUsed, false);
232
+ if (used === undefined) return undefined;
233
+ const budget = quotaNumber(raw.fullWindowTokens, true) ?? quotaNumber(raw.windowTokens, true) ?? NAN_DEFAULT_WINDOW_TOKENS;
234
+ const hours = quotaNumber(raw.windowHours, true) ?? NAN_DEFAULT_WINDOW_HOURS;
235
+ return { label: windowLabel(hours * HOUR), usedPercent: (used / budget) * 100, windowSeconds: hours * HOUR, resetAt: quotaTimestamp(raw.windowResetsAt) };
236
+ }
237
+
238
+ // The allowance the dashboard divides by is the full-period cap, because `cap`
239
+ // is the allowance of the period in progress and comes back prorated on a first
240
+ // period. A model that reports neither figure reports no allowance at all, which
241
+ // is a state the surfaces already know how to draw nothing for.
242
+ function nanEffectiveAllowance(raw: RawNanModel): number | undefined {
243
+ return quotaNumber(raw.fullCap, true) ?? quotaNumber(raw.cap, true);
244
+ }
245
+
246
+ // One allowance per metered model, weighted by that model's own cap. The raw
247
+ // numbers travel with the period window so the bar and the panel can aggregate
248
+ // without ever averaging percentages.
249
+ function nanPeriodWindow(tokensUsed: number, cap: number, resetAt: number | null, now: number): UsageWindow {
250
+ return {
251
+ label: NAN_PERIOD_LABEL,
252
+ usedPercent: (tokensUsed / cap) * 100,
253
+ windowSeconds: resetAt === null ? 0 : Math.max(0, Math.round((resetAt - now) / 1000)),
254
+ resetAt,
255
+ used: tokensUsed,
256
+ budget: cap,
257
+ };
258
+ }
259
+
260
+ export function parseNanQuota(payload: unknown, now: number): ProviderUsage {
261
+ const raw = (payload ?? {}) as RawNanQuota;
262
+ const fallbackResetAt = quotaTimestamp(raw.periodEnd);
263
+ const limits: UsageLimit[] = [];
264
+ if (Array.isArray(raw.models)) {
265
+ for (const entry of raw.models) {
266
+ if (!entry || typeof entry !== "object") continue;
267
+ const model = entry as RawNanModel;
268
+ if (typeof model.model !== "string" || model.model.length === 0) continue;
269
+ const allowance = nanEffectiveAllowance(model);
270
+ // A model that reports no allowance is not drift — the dashboard draws
271
+ // nothing for it either, and the live payload carries such entries. A metered
272
+ // allowance whose usage cannot be read is drift: a partial snapshot would
273
+ // understate every aggregate it feeds, so the read fails whole and the last
274
+ // valid snapshot survives instead.
275
+ if (allowance === undefined) continue;
276
+ const tokensUsed = quotaNumber(model.tokensUsed, false);
277
+ if (tokensUsed === undefined) return { provider: NAN_PROVIDER, plan: undefined, limits: [], fetchedAt: now };
278
+ const resetAt = quotaTimestamp(model.periodEnd) ?? fallbackResetAt;
279
+ const windows: UsageWindow[] = [nanPeriodWindow(tokensUsed, allowance, resetAt, now)];
280
+ const rolling = nanRollingWindow(model);
281
+ if (rolling) windows.push(rolling);
282
+ limits.push({ name: model.model, windows, limitReached: tokensUsed >= allowance });
283
+ }
284
+ }
285
+ return { provider: NAN_PROVIDER, plan: undefined, limits, fetchedAt: now };
286
+ }
287
+
288
+ // Aggregation. NaN reports one allowance per metered model and the payload
289
+ // order is the server's business, so surfaces pick by meaning, not by position.
290
+ function rawAllowance(limit: UsageLimit): UsageWindow | undefined {
291
+ const [first] = limit.windows;
292
+ if (!first || first.used === undefined || first.budget === undefined) return undefined;
293
+ return first.budget > 0 ? first : undefined;
294
+ }
295
+
296
+ // The leading alphabetic run of a model id: glm5.3-flash and glm5.2 are both
297
+ // "glm". Derived from the id the payload reports, never from a vendor list.
298
+ export function modelFamily(modelId: string): string {
299
+ return (/^[a-z]+/i.exec(modelId)?.[0] ?? modelId).toLowerCase();
300
+ }
301
+
302
+ // Only NaN carries raw allowance numbers, so this one gate is what keeps Codex
303
+ // and Anthropic on exactly the rows and the meter they had before. One metered
304
+ // model is still a payload that carries them: the gate answers "does this
305
+ // provider report allowances", never "are there enough rows to sort", because
306
+ // a single allowance read as "no allowances" sent the bar back to whichever
307
+ // model the payload listed first.
308
+ export function allowanceGroupsSupported(limits: readonly UsageLimit[]): boolean {
309
+ return limits.length > 0 && limits.every((limit) => rawAllowance(limit) !== undefined);
310
+ }
311
+
312
+ function percentOf(limit: UsageLimit): number {
313
+ return limit.windows[0]?.usedPercent ?? 0;
314
+ }
315
+
316
+ const GROUP_SUFFIX = " total";
317
+
318
+ // A group is an allowance share, never an average of shares: Σused / Σbudget.
319
+ // It carries no reset, because its members close their own billing period on
320
+ // their own date, and a single reset would be a lie.
321
+ function allowanceTotal(name: string, limits: readonly UsageLimit[]): UsageLimit | undefined {
322
+ const windows = limits.map(rawAllowance).filter((window): window is UsageWindow => window !== undefined);
323
+ if (windows.length === 0 || windows.length !== limits.length) return undefined;
324
+ const used = windows.reduce((total, window) => total + (window.used ?? 0), 0);
325
+ const budget = windows.reduce((total, window) => total + (window.budget ?? 0), 0);
326
+ if (budget <= 0) return undefined;
327
+ return {
328
+ name,
329
+ windows: [{ label: NAN_PERIOD_LABEL, usedPercent: (used / budget) * 100, windowSeconds: 0, resetAt: null }],
330
+ limitReached: limits.some((limit) => limit.limitReached),
331
+ };
332
+ }
333
+
334
+ // What the grouping is for now that the totals are gone: the order. A family
335
+ // stays together, families sort by what they consume and the members inside one
336
+ // follow the same rule, most used first. The account and family totals are the
337
+ // bar's fallback ladder only — they are never rows, because a total nobody can
338
+ // act on only costs space. Every row is a limit block, so nothing here
339
+ // introduces a shape the other providers do not already use.
340
+ export function groupUsageLimits(limits: readonly UsageLimit[]): UsageLimit[] {
341
+ if (!allowanceGroupsSupported(limits)) return [...limits];
342
+ const order: string[] = [];
343
+ const members = new Map<string, UsageLimit[]>();
344
+ for (const limit of limits) {
345
+ const family = modelFamily(limit.name);
346
+ if (!members.has(family)) {
347
+ members.set(family, []);
348
+ order.push(family);
349
+ }
350
+ members.get(family)?.push(limit);
351
+ }
352
+ return order
353
+ .map((family) => {
354
+ const sorted = [...(members.get(family) ?? [])].sort((a, b) => percentOf(b) - percentOf(a));
355
+ const total = allowanceTotal(`${family}${GROUP_SUFFIX}`, sorted);
356
+ return { percent: total?.windows[0]?.usedPercent ?? percentOf(sorted[0]), sorted };
357
+ })
358
+ .sort((a, b) => b.percent - a.percent)
359
+ .flatMap((family) => family.sorted);
360
+ }
361
+
362
+ // The bar follows the model the session is using: exact allowance, then its
363
+ // family, then the account total, then the first limit (which is what every
364
+ // provider without raw numbers keeps using, and what a missing model keeps).
365
+ export function selectUsageLimit(usage: ProviderUsage, activeModelId?: string): UsageLimit | undefined {
366
+ if (!activeModelId) return usage.limits[0];
367
+ const exact = usage.limits.find((limit) => limit.name === activeModelId);
368
+ if (exact) return exact;
369
+ if (allowanceGroupsSupported(usage.limits)) {
370
+ const family = modelFamily(activeModelId);
371
+ const members = usage.limits.filter((limit) => modelFamily(limit.name) === family);
372
+ // The family rung is about the name of the meter, not about printing a row,
373
+ // so a single member counts: its family is a closer statement of what the
374
+ // session is drawing from than the whole account.
375
+ if (members.length > 0) {
376
+ const total = allowanceTotal(`${family}${GROUP_SUFFIX}`, members);
377
+ if (total) return total;
378
+ }
379
+ const account = allowanceTotal(`${usage.provider}${GROUP_SUFFIX}`, usage.limits);
380
+ if (account) return account;
381
+ }
382
+ return usage.limits[0];
383
+ }
384
+
177
385
  export function accountIdFromToken(token: string): string | undefined {
178
386
  const parts = token.split(".");
179
387
  if (parts.length !== 3) return undefined;
@@ -190,11 +398,12 @@ function paintMeter(percent: number, cells: number, theme: UsageTheme): string {
190
398
  return paintGauge(percent, theme, cells);
191
399
  }
192
400
 
193
- export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme): string | undefined {
194
- const main = usage.limits[0];
401
+ export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme, activeModelId?: string): string | undefined {
402
+ const main = selectUsageLimit(usage, activeModelId);
195
403
  const [first, ...rest] = main?.windows ?? [];
196
404
  if (!first) return undefined;
197
- const head = `${theme.fg(ROLE.LABEL, main.name)} ${theme.fg(ROLE.LABEL, first.label)} ${paintMeter(first.usedPercent, 8, theme)} ${theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)}`;
405
+ // An unlabeled window prints as the name, the meter and the percentage.
406
+ const head = [theme.fg(ROLE.LABEL, main.name), ...(first.label.length === 0 ? [] : [theme.fg(ROLE.LABEL, first.label)]), paintMeter(first.usedPercent, 8, theme), theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)].join(" ");
198
407
  const tail = rest.map((window) => `${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.LABEL, window.label)} ${theme.fg(ROLE.PERCENT, `${Math.round(window.usedPercent)}%`)}`);
199
408
  return [head, ...tail].join(" ");
200
409
  }
@@ -218,12 +427,19 @@ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, wid
218
427
  const mark = usage === activeUsage ? `${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ` : "";
219
428
  const plan = usage.plan ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.PLAN, usage.plan)}` : "";
220
429
  lines.push(`${mark}${theme.fg(ROLE.PROVIDER, usage.provider)}${plan} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, updatedAgo(usage.fetchedAt, now))}`);
221
- for (const limit of usage.limits) {
222
- lines.push(` ${theme.fg(ROLE.LIMIT, limit.name)}`);
223
- for (const window of limit.windows) {
224
- const percent = `${Math.round(window.usedPercent)}%`.padStart(4);
225
- lines.push(` ${theme.fg(ROLE.LABEL, window.label.padEnd(5))} ${paintMeter(window.usedPercent, PANEL_METER_CELLS, theme)} ${theme.fg(ROLE.PERCENT, percent)} ${theme.fg(ROLE.RESET, formatReset(window.resetAt, now))}`);
226
- }
430
+ // One row per window: the limit name, its meter, its percentage and the reset
431
+ // that window reports, all on one line. A window without its own label (the
432
+ // model's allowance) is named by its limit alone, and one without a reset ends
433
+ // at its percentage, never on a dangling separator.
434
+ const rows = groupUsageLimits(usage.limits).flatMap((limit) =>
435
+ limit.windows.map((window) => ({ name: [limit.name, window.label].filter((part) => part.length > 0).join(" "), window })),
436
+ );
437
+ const nameWidth = rows.reduce((widest, row) => Math.max(widest, row.name.length), 0);
438
+ for (const row of rows) {
439
+ const percent = `${Math.round(row.window.usedPercent)}%`.padStart(4);
440
+ const reset = formatReset(row.window.resetAt, now);
441
+ const tail = reset.length > 0 ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, reset)}` : "";
442
+ lines.push(` ${theme.fg(ROLE.LABEL, row.name.padEnd(nameWidth))} ${paintMeter(row.window.usedPercent, PANEL_METER_CELLS, theme)} ${theme.fg(ROLE.PERCENT, percent)}${tail}`);
227
443
  }
228
444
  }
229
445
  return lines.map((line) => truncateToWidth(line, width, "…"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,6 +38,7 @@
38
38
  ],
39
39
  "scripts": {
40
40
  "check:provider-contract": "node scripts/check-provider-contract.mjs",
41
+ "mirror:odd-routing": "node scripts/mirror-odd-routing.mjs",
41
42
  "postinstall": "node scripts/install-gentle-ai.mjs",
42
43
  "test": "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
43
44
  "test:harness": "node --experimental-strip-types tests/runtime-harness.mjs",
@@ -996,6 +996,29 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
996
996
  // remain dark because neither is proven to reach the negotiated START
997
997
  // path Pi consumes.
998
998
  "3.1.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
999
+ // v3.2.1 changed the ODD orchestrator contract only (gentle-ai #4714
1000
+ // follow-up). Ground-truthed by diffing contracts/review-integration/v2 and
1001
+ // contracts/review-provider-contract between the v3.1.0 and v3.2.1 tags
1002
+ // in the gentle-ai source tree: zero bytes changed (provider contract
1003
+ // stays 1.2.0). Neither change touches the closed START/STATUS fields
1004
+ // this row negotiates, so it repeats 3.1.0 exactly. riskEvidence and hint
1005
+ // remain dark because neither is proven to reach the negotiated START
1006
+ // path Pi consumes.
1007
+ "3.2.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1008
+ // v3.4.0 (gentle-pi never pinned the intervening v3.3.0 tag, so it gets no
1009
+ // row here) added capabilities/v2.6 and status/v8-v9, and extended
1010
+ // `review assess` with review_due/review_due_reason/next_transition
1011
+ // (gentle-ai #4714 follow-up). Ground-truthed by diffing
1012
+ // contracts/review-integration/v2 and contracts/review-provider-contract
1013
+ // between the v3.2.1 and v3.4.0 tags in the gentle-ai source tree: the
1014
+ // provider contract stays byte-identical at 1.2.0, and every
1015
+ // review-integration/v2 change is an additive superset (new optional
1016
+ // schema/fields) that decodeReviewStatusV3 and the capabilities
1017
+ // negotiator already accept without touching the closed START/STATUS
1018
+ // fields this row negotiates, so it repeats 3.2.1 exactly. riskEvidence
1019
+ // and hint remain dark because neither is proven to reach the negotiated
1020
+ // START path Pi consumes.
1021
+ "3.4.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
999
1022
  });
1000
1023
 
1001
1024