gentle-pi 3.2.0 → 3.2.1

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.
@@ -5,7 +5,11 @@ import { join, posix, win32 } from "node:path";
5
5
 
6
6
  export const OPAQUE_PI_REVIEWER_ARGV = Object.freeze([
7
7
  "--print",
8
- "--mode", "text",
8
+ // #1140: text mode turns a run that spent its turn on a tool call into zero
9
+ // bytes and exit 0. JSON mode emits pi's own event stream, so the transport
10
+ // can always tell a silent child from an assistant answer, and can recover
11
+ // the answer text even when tool calls interleaved with it.
12
+ "--mode", "json",
9
13
  "--no-session",
10
14
  "--no-tools",
11
15
  "--no-extensions",
@@ -32,6 +36,12 @@ export interface OpaquePiReviewerOptions {
32
36
  readonly environment?: NodeJS.ProcessEnv;
33
37
  readonly timeoutMs?: number;
34
38
  readonly signal?: AbortSignal;
39
+ /**
40
+ * Caller-owned tokens appended verbatim after the frozen argv (spawn keeps
41
+ * shell:false, so every token is one argv entry). The adapter never
42
+ * interprets them; its caller owns their meaning and validation.
43
+ */
44
+ readonly extraArguments?: readonly string[];
35
45
  }
36
46
 
37
47
  export interface OpaquePiReviewerResult {
@@ -49,6 +59,8 @@ export interface OpaquePiReviewerTransportDetails {
49
59
  readonly elapsedMs?: number;
50
60
  /** The bound applied to a launched Pi process; absent when no process was launched. */
51
61
  readonly timeoutMs?: number;
62
+ /** What the child's output stream revealed when no assistant answer could be recovered. */
63
+ readonly evidence?: PiReviewOutputEvidence;
52
64
  }
53
65
 
54
66
  export class OpaquePiReviewerTransportError extends Error {
@@ -59,6 +71,7 @@ export class OpaquePiReviewerTransportError extends Error {
59
71
  readonly cancelled: boolean;
60
72
  readonly elapsedMs: number | null;
61
73
  readonly timeoutMs: number | null;
74
+ readonly evidence: PiReviewOutputEvidence | undefined;
62
75
 
63
76
  constructor(kind: OpaquePiReviewerTransportFailureKind, message: string, details: OpaquePiReviewerTransportDetails = {}) {
64
77
  super(message);
@@ -70,6 +83,7 @@ export class OpaquePiReviewerTransportError extends Error {
70
83
  this.cancelled = details.cancelled ?? false;
71
84
  this.elapsedMs = details.elapsedMs ?? null;
72
85
  this.timeoutMs = details.timeoutMs ?? null;
86
+ this.evidence = details.evidence;
73
87
  }
74
88
  }
75
89
 
@@ -107,25 +121,126 @@ export function resolvePiLaunch(
107
121
  piExecutable: string | undefined,
108
122
  platform: NodeJS.Platform = process.platform,
109
123
  host: PiHostProcess = { execPath: process.execPath, entry: process.argv[1] },
124
+ extraArguments: readonly string[] = [],
110
125
  ): PiLaunch {
111
- if (piExecutable !== undefined) return { file: piExecutable, arguments: [...OPAQUE_PI_REVIEWER_ARGV] };
112
- if (platform !== "win32") return { file: "pi", arguments: [...OPAQUE_PI_REVIEWER_ARGV] };
126
+ const arguments_ = [...OPAQUE_PI_REVIEWER_ARGV, ...extraArguments];
127
+ if (piExecutable !== undefined) return { file: piExecutable, arguments: arguments_ };
128
+ if (platform !== "win32") return { file: "pi", arguments: arguments_ };
113
129
  if (typeof host.entry !== "string" || host.entry.length === 0 || !(platform === "win32" ? win32 : posix).isAbsolute(host.entry)) {
114
130
  throw new Error(`Pi host entry could not be resolved from the running process (received ${JSON.stringify(host.entry ?? null)}); a bare pi launcher cannot be spawned on Windows without a shell`);
115
131
  }
116
- return { file: host.execPath, arguments: [host.entry, ...OPAQUE_PI_REVIEWER_ARGV] };
132
+ return { file: host.execPath, arguments: [host.entry, ...arguments_] };
117
133
  }
118
134
 
119
135
  function errorMessage(error: unknown): string {
120
136
  return error instanceof Error ? error.message : String(error);
121
137
  }
122
138
 
139
+ // ---------------------------------------------------------------------------
140
+ // The child runs `pi --mode json`, so its stdout is a newline-delimited pi
141
+ // event stream. The transport's output is the assistant text of that stream —
142
+ // the same bytes an interactive run would have rendered — and everything else
143
+ // about the stream becomes typed evidence. A run whose turn was spent on a
144
+ // tool call therefore reports what happened (which reviewer selection ran,
145
+ // that a tool call was attempted) instead of exiting 0 in silence (#1140).
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export interface PiReviewOutputEvidence {
149
+ readonly stdoutKind: "no-output" | "not-a-pi-event-stream" | "no-assistant-text";
150
+ /** The reviewer selection the child itself reported in its events, when it named one. */
151
+ readonly reviewerModel?: string;
152
+ readonly toolCallAttempted?: boolean;
153
+ }
154
+
155
+ export type PiReviewExtraction =
156
+ | { readonly kind: "text"; readonly text: string }
157
+ | { readonly kind: "none"; readonly evidence: PiReviewOutputEvidence };
158
+
159
+ interface PiEventMessagePart {
160
+ type?: unknown;
161
+ text?: unknown;
162
+ }
163
+
164
+ interface PiEventMessage {
165
+ role?: unknown;
166
+ content?: unknown;
167
+ }
168
+
169
+ interface PiEventEnvelope {
170
+ type?: unknown;
171
+ message?: unknown;
172
+ }
173
+
174
+ function parsePiEventLines(stdout: Buffer): PiEventEnvelope[] | undefined {
175
+ const lines = stdout.toString("utf8").split("\n").filter((line) => line.trim().length > 0);
176
+ if (lines.length === 0) return undefined;
177
+ const events: PiEventEnvelope[] = [];
178
+ for (const line of lines) {
179
+ let parsed: unknown;
180
+ try {
181
+ parsed = JSON.parse(line);
182
+ } catch {
183
+ return undefined;
184
+ }
185
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
186
+ events.push(parsed as PiEventEnvelope);
187
+ }
188
+ return events;
189
+ }
190
+
191
+ export function extractPiAssistantText(stdout: Buffer): PiReviewExtraction {
192
+ if (stdout.length === 0) return { kind: "none", evidence: { stdoutKind: "no-output" } };
193
+ const events = parsePiEventLines(stdout);
194
+ if (events === undefined) return { kind: "none", evidence: { stdoutKind: "not-a-pi-event-stream" } };
195
+ const parts: string[] = [];
196
+ let reviewerModel: string | undefined;
197
+ let toolCallAttempted = false;
198
+ for (const event of events) {
199
+ // The final content of one assistant message lives in its end event;
200
+ // start and update events repeat or stream the same parts.
201
+ if (event.type !== "message_end") continue;
202
+ const message = event.message;
203
+ if (!message || typeof message !== "object" || Array.isArray(message)) continue;
204
+ const candidate = message as PiEventMessage;
205
+ if (candidate.role !== "assistant") continue;
206
+ // The event envelope names the selection the child itself ran with the wire
207
+ // key pi uses for it; read as quoted data, never as an identifier.
208
+ const reported = (candidate as Record<string, unknown>)["model"];
209
+ if (typeof reported === "string" && reported.length > 0 && reviewerModel === undefined) reviewerModel = reported;
210
+ if (!Array.isArray(candidate.content)) continue;
211
+ for (const part of candidate.content as PiEventMessagePart[]) {
212
+ if (!part || typeof part !== "object") continue;
213
+ if (typeof part.type === "string" && part.type.startsWith("tool")) toolCallAttempted = true;
214
+ if (part.type === "text" && typeof part.text === "string") parts.push(part.text);
215
+ }
216
+ }
217
+ const text = parts.join("");
218
+ if (text.length === 0) {
219
+ return {
220
+ kind: "none",
221
+ evidence: {
222
+ stdoutKind: "no-assistant-text",
223
+ ...(reviewerModel === undefined ? {} : { reviewerModel }),
224
+ ...(toolCallAttempted ? { toolCallAttempted } : {}),
225
+ },
226
+ };
227
+ }
228
+ return { kind: "text", text };
229
+ }
230
+
231
+ function emptyOutputMessage(evidence: PiReviewOutputEvidence): string {
232
+ const details = [`stdout kind: ${evidence.stdoutKind}`];
233
+ if (evidence.reviewerModel !== undefined) details.push(`reviewer selection: ${evidence.reviewerModel}`);
234
+ if (evidence.toolCallAttempted) details.push("a tool call was attempted");
235
+ return `Pi process produced no assistant text (${details.join("; ")})`;
236
+ }
237
+
123
238
  function runPiProcess(prompt: Buffer, scratchDirectory: string, options: OpaquePiReviewerOptions): Promise<OpaquePiProcessResult> {
124
239
  return new Promise((resolve, reject) => {
125
240
  const startedAt = Date.now();
126
241
  let launch: PiLaunch;
127
242
  try {
128
- launch = resolvePiLaunch(options.piExecutable);
243
+ launch = resolvePiLaunch(options.piExecutable, process.platform, { execPath: process.execPath, entry: process.argv[1] }, options.extraArguments ?? []);
129
244
  } catch (error) {
130
245
  reject(error);
131
246
  return;
@@ -197,6 +312,9 @@ export async function runOpaquePiReviewer(prompt: Buffer, options: OpaquePiRevie
197
312
  { cancelled: true },
198
313
  );
199
314
  }
315
+ if (options.extraArguments !== undefined && options.extraArguments.some((token) => typeof token !== "string" || token.length === 0)) {
316
+ throw new TypeError("Pi reviewer launch arguments must all be non-empty strings");
317
+ }
200
318
 
201
319
  let scratchDirectory: string | undefined;
202
320
  let primaryFailure = false;
@@ -252,17 +370,19 @@ export async function runOpaquePiReviewer(prompt: Buffer, options: OpaquePiRevie
252
370
  { exitCode: processResult.exitCode, stderr: processResult.stderr, ...timing },
253
371
  );
254
372
  }
255
- if (processResult.stdout.length === 0) {
373
+ const extraction = extractPiAssistantText(processResult.stdout);
374
+ if (extraction.kind === "none") {
256
375
  throw new OpaquePiReviewerTransportError(
257
376
  OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
258
- "Pi process produced no output bytes",
259
- { exitCode: 0, stderr: processResult.stderr, ...timing },
377
+ emptyOutputMessage(extraction.evidence),
378
+ { exitCode: 0, stderr: processResult.stderr, evidence: extraction.evidence, ...timing },
260
379
  );
261
380
  }
381
+ const stdout = Buffer.from(extraction.text, "utf8");
262
382
  return {
263
- stdout: processResult.stdout,
383
+ stdout,
264
384
  promptByteLength: prompt.length,
265
- stdoutByteLength: processResult.stdout.length,
385
+ stdoutByteLength: stdout.length,
266
386
  };
267
387
  } catch (error) {
268
388
  primaryFailure = true;
@@ -30,10 +30,14 @@ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
30
30
  import { tmpdir } from "node:os";
31
31
  import { isAbsolute, join } from "node:path";
32
32
  import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
33
+ import { SAFE_MODEL_ID_PATTERN } from "./model-routing-authority.ts";
34
+ import { existsSync } from "node:fs";
35
+ import { delimiter as pathDelimiter } from "node:path";
33
36
  import {
34
37
  OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
35
38
  OpaquePiReviewerTransportError,
36
39
  runOpaquePiReviewer,
40
+ type PiReviewOutputEvidence,
37
41
  type OpaquePiReviewerResult,
38
42
  } from "./opaque-pi-reviewer-adapter.ts";
39
43
  import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
@@ -60,6 +64,11 @@ export const REVIEW_HOST_RELAY_FAILURE = {
60
64
  // continuation instead of hiding inside `pi-failed`.
61
65
  PI_TIMED_OUT: "pi-timed-out",
62
66
  PI_EMPTY_OUTPUT: "pi-empty-output",
67
+ // gentle-shell#1158 / #1136: a caller-owned reviewer selection that cannot
68
+ // possibly launch (a malformed selection id, a relative or missing extension
69
+ // path) is a configuration failure, refused typed before anything runs —
70
+ // never a mid-review transport mystery.
71
+ REVIEWER_CONFIG_INVALID: "reviewer-config-invalid",
63
72
  SUBMISSION_REFUSED: "submission-refused",
64
73
  } as const;
65
74
  export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE];
@@ -90,7 +99,9 @@ export class ReviewHostRelayError extends Error {
90
99
  // "none" again: the provider states that the lens slot was not consumed
91
100
  // (gentle-pi#522 / #524).
92
101
  readonly mutationOutcome: "none" | "unknown";
93
- constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown" }) {
102
+ /** What the reviewer child's own event stream revealed on an empty-output failure. */
103
+ readonly reviewerEvidence: PiReviewOutputEvidence | undefined;
104
+ constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown"; reviewerEvidence?: PiReviewOutputEvidence }) {
94
105
  super(message);
95
106
  this.name = "ReviewHostRelayError";
96
107
  this.kind = kind;
@@ -101,6 +112,7 @@ export class ReviewHostRelayError extends Error {
101
112
  this.elapsedMs = details?.elapsedMs ?? null;
102
113
  this.timeoutMs = details?.timeoutMs ?? null;
103
114
  this.mutationOutcome = details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none");
115
+ this.reviewerEvidence = details?.reviewerEvidence;
104
116
  }
105
117
  }
106
118
 
@@ -295,6 +307,21 @@ export interface ReviewHostRelayRequest {
295
307
  readonly piExecutable?: string;
296
308
  readonly environment?: NodeJS.ProcessEnv;
297
309
  readonly gentleAiTimeoutMs?: number;
310
+ /**
311
+ * User-owned reviewer selection forwarded to the child as `--model`. The
312
+ * relay never invents one; the default launch stays selection-free. Must
313
+ * match {@link SAFE_MODEL_ID_PATTERN}; anything else is refused typed
314
+ * before any process launches (gentle-shell#1136).
315
+ */
316
+ readonly reviewerModel?: string;
317
+ /**
318
+ * User-owned extension files loaded into the isolated child through
319
+ * explicit `-e` paths (pi keeps explicit loads under `--no-extensions`),
320
+ * so a subscription provider's auth adapter can ride along without
321
+ * re-enabling extension discovery (gentle-shell#1158). Every path must be
322
+ * absolute and exist; anything else is refused typed before launch.
323
+ */
324
+ readonly reviewerExtensionPaths?: readonly string[];
298
325
  /**
299
326
  * Overrides the reviewer bound entirely. Production leaves it unset and the
300
327
  * relay derives the bound from the materialized prompt bytes and
@@ -464,7 +491,18 @@ function relayPiTransportError(error: unknown, promptByteLength: number, piTimeo
464
491
  );
465
492
  }
466
493
  if (error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT) {
467
- return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", details);
494
+ // #1156: the envelope says what the child's own stream revealed, and the
495
+ // message names the remedies, because a bare kind code is what sent the
496
+ // #1140 reporter into a retry loop with nothing to inspect.
497
+ const evidence = error.evidence;
498
+ const summary: string[] = [`stdout kind: ${evidence?.stdoutKind ?? "unknown"}`];
499
+ if (evidence?.reviewerModel !== undefined) summary.push(`reviewer selection: ${evidence.reviewerModel}`);
500
+ if (evidence?.toolCallAttempted) summary.push("a tool call was attempted");
501
+ const stderrExcerpt = details.stderr.length > 0 ? ` child stderr: ${details.stderr.slice(0, 400).replace(/\s+/g, " ").trim()}` : "";
502
+ const message = `pi subprocess produced no assistant text (${summary.join("; ")}).${stderrExcerpt}`
503
+ + " A reviewer that spent its turn on a tool call, a selection the child could not resolve, or a stream pi did not produce all land here; the evidence above says which."
504
+ + " Assign the lens a reviewer selection that answers in text (the lens's entry in the agent model routing config), and when the provider needs an auth adapter, name its absolute file path in " + REVIEW_HOST_RELAY_EXTENSIONS_ENV + ".";
505
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", message, { ...details, ...(evidence === undefined ? {} : { reviewerEvidence: evidence }) });
468
506
  }
469
507
  if (
470
508
  error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED
@@ -482,8 +520,60 @@ function assertTokens(name: string, tokens: readonly string[]): void {
482
520
  }
483
521
  }
484
522
 
523
+ export const REVIEW_HOST_RELAY_EXTENSIONS_ENV = "GENTLE_PI_REVIEW_RELAY_EXTENSIONS";
524
+
525
+ /**
526
+ * The user-owned extension allowlist for the reviewer child, read from the
527
+ * environment (gentle-shell#1158). Entries are split on the platform path
528
+ * delimiter; empty entries are skipped. Validation of each path happens at
529
+ * snapshot time, so a broken entry is refused typed before anything launches.
530
+ */
531
+ export function resolveReviewHostRelayExtensionPaths(environment: NodeJS.ProcessEnv = process.env): readonly string[] {
532
+ const configured = environment[REVIEW_HOST_RELAY_EXTENSIONS_ENV];
533
+ if (configured === undefined || configured.trim().length === 0) return [];
534
+ return configured.split(pathDelimiter).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
535
+ }
536
+
537
+ // The tokens a validated caller-owned selection contributes to the child's
538
+ // argv. Only these two shapes may ride the forwarding path.
539
+ function reviewerLaunchArguments(reviewerModel: string | undefined, reviewerExtensionPaths: readonly string[] | undefined): readonly string[] {
540
+ return [
541
+ ...(reviewerModel === undefined ? [] : ["--model", reviewerModel]),
542
+ ...(reviewerExtensionPaths ?? []).flatMap((path) => ["-e", path]),
543
+ ];
544
+ }
545
+
546
+ function validateReviewerLaunchConfiguration(request: ReviewHostRelayRequest): { reviewerModel?: string; reviewerExtensionPaths?: readonly string[] } {
547
+ if (request.reviewerModel !== undefined) {
548
+ if (typeof request.reviewerModel !== "string" || request.reviewerModel.length === 0 || !SAFE_MODEL_ID_PATTERN.test(request.reviewerModel)) {
549
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", `Pi host relay reviewer launch configuration is invalid: the caller-owned reviewer selection ${JSON.stringify(request.reviewerModel)} is not a safe model id`);
550
+ }
551
+ }
552
+ if (request.reviewerExtensionPaths !== undefined) {
553
+ if (!Array.isArray(request.reviewerExtensionPaths) || request.reviewerExtensionPaths.some((path) => typeof path !== "string" || path.length === 0)) {
554
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", "Pi host relay reviewer launch configuration is invalid: extension paths must all be non-empty strings");
555
+ }
556
+ for (const path of request.reviewerExtensionPaths) {
557
+ if (!isAbsolute(path)) {
558
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", `Pi host relay reviewer launch configuration is invalid: the extension path ${JSON.stringify(path)} is not absolute`);
559
+ }
560
+ if (!existsSync(path)) {
561
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", `Pi host relay reviewer launch configuration is invalid: the extension path ${JSON.stringify(path)} does not exist`);
562
+ }
563
+ }
564
+ }
565
+ return {
566
+ ...(request.reviewerModel === undefined ? {} : { reviewerModel: request.reviewerModel }),
567
+ ...(request.reviewerExtensionPaths === undefined || request.reviewerExtensionPaths.length === 0 ? {} : { reviewerExtensionPaths: Object.freeze([...request.reviewerExtensionPaths]) }),
568
+ };
569
+ }
570
+
485
571
  function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): ReviewHostRelayRequest {
486
572
  assertTokens("capture", request.captureArgumentTokens);
573
+ // Caller-owned reviewer selection is validated before any process launches:
574
+ // a broken configuration is a typed refusal, never a mid-review transport
575
+ // failure (gentle-shell#1158 / #1136).
576
+ const reviewerLaunch = validateReviewerLaunchConfiguration(request);
487
577
  // The completing form is validated before any process launches: a materialize
488
578
  // slot without a provider-owned submission is a typed contract mismatch,
489
579
  // never a synthesized invocation.
@@ -500,6 +590,7 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
500
590
  ...request,
501
591
  captureArgumentTokens: Object.freeze([...request.captureArgumentTokens]),
502
592
  ...(submission === undefined ? {} : { submission }),
593
+ ...reviewerLaunch,
503
594
  gentleAiExecutable,
504
595
  environment,
505
596
  gentleAiTimeoutMs: request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS,
@@ -574,6 +665,7 @@ export async function prepareReviewHostRelaySlot(
574
665
 
575
666
  // The pure adapter owns the fresh isolated Pi process. Its input and output
576
667
  // are opaque bytes; this coordinator only maps transport failures.
668
+ const launchArguments = reviewerLaunchArguments(preparedRequest.reviewerModel, preparedRequest.reviewerExtensionPaths);
577
669
  let piResult: OpaquePiReviewerResult;
578
670
  try {
579
671
  piResult = await reviewer(promptBytes, {
@@ -581,6 +673,7 @@ export async function prepareReviewHostRelaySlot(
581
673
  environment: preparedRequest.environment,
582
674
  timeoutMs: piTimeoutMs,
583
675
  ...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
676
+ ...(launchArguments.length === 0 ? {} : { extraArguments: launchArguments }),
584
677
  });
585
678
  } catch (error) {
586
679
  throw relayPiTransportError(error, promptBytes.length, piTimeoutMs);
package/lib/shell-bar.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugeTone } from "./shell-gauge.ts";
3
- import { renderUsageBar, type ProviderUsage } from "./shell-usage.ts";
3
+ import { allowanceGroupsSupported, groupUsageLimits, renderUsageBar, selectUsageLimit, type ProviderUsage } from "./shell-usage.ts";
4
4
  import { sanitizeTerminalText } from "./terminal-theme.ts";
5
5
  import { CARD_TONE, cardInnerWidth, renderCard } from "./shell-card.ts";
6
6
 
@@ -48,11 +48,17 @@ const ROLE = {
48
48
  SESSION: "dim",
49
49
  } as const;
50
50
 
51
- export const SHELL_BAR_BRAND = "✿ gentle-pi";
51
+ export const SHELL_BAR_BRAND = "✿ gentle shell";
52
52
  export const SHELL_BAR_SEPARATOR = "⟡";
53
53
  export const SHELL_BAR_GAUGE_CELLS = GAUGE_CELLS;
54
54
  const RIGHT_PADDING = 2;
55
55
  const COMPACT_BRANCH_WIDTH = 15;
56
+ // The rows the sidebar prints for a provider with per-model allowances use the
57
+ // bar's shorter meter: the rail is 50 columns wide, and the panel's 16 cells
58
+ // would leave no room for the model ids.
59
+ const SIDEBAR_USAGE_METER_CELLS = GAUGE_CELLS;
60
+ // Meter, its two spaces and the right-aligned percentage.
61
+ const SIDEBAR_USAGE_ROW_FIXED = SIDEBAR_USAGE_METER_CELLS + 6;
56
62
 
57
63
  export function shellEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
58
64
  if (env.GENTLE_PI_AGENTS_CHILD === "1") return false;
@@ -90,7 +96,7 @@ function buildSegments(model: ShellBarModel, theme: ShellBarTheme): string[] {
90
96
  const percentText = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
91
97
  const context = `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(model.contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
92
98
  const cost = theme.fg(ROLE.VALUE, formatCost(model.costTotal, model.subscription));
93
- const usage = model.usage ? renderUsageBar(model.usage, theme) : undefined;
99
+ const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId) : undefined;
94
100
  const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
95
101
  return [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND), location, modelSegment, context, cost, ...(usage ? [usage] : []), ...statuses];
96
102
  }
@@ -119,6 +125,52 @@ function joinSegments(segments: string[], theme: ShellBarTheme): string {
119
125
  return segments.join(` ${theme.fg(ROLE.SEPARATOR, SHELL_BAR_SEPARATOR)} `);
120
126
  }
121
127
 
128
+ // A row exists to show what is being consumed, so a window that consumed
129
+ // nothing is noise the sidebar drops. The threshold is the row's own number and
130
+ // nothing else: the render path prints `Math.round(percent)`, so a fraction
131
+ // below half a percent prints `0%` and disappears while half a percent keeps its
132
+ // row and prints `1%` — no second scale and no separate epsilon. A window whose
133
+ // percent is not a number never equals zero, so it keeps its row instead of
134
+ // being dropped in silence. The bar and the panel keep their own contract and
135
+ // still print a zero allowance.
136
+ function consumedNothing(usedPercent: number): boolean {
137
+ return Math.round(usedPercent) === 0;
138
+ }
139
+
140
+ // The sidebar is the surface that never needs opening, so a provider with
141
+ // per-model allowances prints the panel's model rows there too — the most
142
+ // consumed family first, its models inside it — and leaves the aggregate totals
143
+ // and the reset dates to the bar and the panel. Providers without raw
144
+ // allowances keep the one aggregate line the bar has always drawn for the model
145
+ // in use.
146
+ function sidebarUsageLines(usage: ProviderUsage, modelId: string, theme: ShellBarTheme, available: number): string[] {
147
+ if (!allowanceGroupsSupported(usage.limits)) {
148
+ // The aggregate line is one row, so its windows decide together: one
149
+ // consumed window keeps the sharing row, all of them zero drop it. A limit
150
+ // with no windows is not "zero consumption" — there is nothing to draw, and
151
+ // renderUsageBar already answers that — so the rule only speaks when there
152
+ // is a window to judge.
153
+ const windows = selectUsageLimit(usage, modelId)?.windows ?? [];
154
+ if (windows.length > 0 && windows.every((window) => consumedNothing(window.usedPercent))) return [];
155
+ const line = renderUsageBar(usage, theme, modelId);
156
+ return line ? [line] : [];
157
+ }
158
+ const rows = groupUsageLimits(usage.limits)
159
+ .flatMap((limit) =>
160
+ limit.windows.map((window) => ({ name: [limit.name, window.label].filter((part) => part.length > 0).join(" "), window })),
161
+ )
162
+ .filter((row) => !consumedNothing(row.window.usedPercent));
163
+ // The name column gives way first: it is the only part that can be clipped
164
+ // without losing the number the row exists to show.
165
+ const widest = rows.reduce((width, row) => Math.max(width, row.name.length), 0);
166
+ const nameWidth = Math.min(widest, Math.max(1, available - SIDEBAR_USAGE_ROW_FIXED));
167
+ return rows.map((row) => {
168
+ const name = row.name.length > nameWidth ? clipText(row.name, nameWidth) : row.name.padEnd(nameWidth);
169
+ const percent = `${Math.round(row.window.usedPercent)}%`.padStart(4);
170
+ return `${theme.fg(ROLE.LABEL, name)} ${paintGauge(row.window.usedPercent, theme, SIDEBAR_USAGE_METER_CELLS)} ${theme.fg(ROLE.VALUE, percent)}`;
171
+ });
172
+ }
173
+
122
174
  // Sidebar groups use structured fields, never positional compact-bar segments
123
175
  // or inferred meanings from opaque extension status strings.
124
176
  export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
@@ -128,7 +180,11 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
128
180
  const branch = model.branch ? `${label("Branch")} ${value(model.branch)}` : "";
129
181
  const percent = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
130
182
  const capacity = label(`${formatTokens(model.contextWindow)} tokens`);
131
- const usage = model.usage ? renderUsageBar(model.usage, theme) : undefined;
183
+ // Pre-wrap values before indenting so Unicode/ANSI continuation lines keep
184
+ // the same inset without consuming the card's right border.
185
+ const innerWidth = cardInnerWidth(width);
186
+ const inset = Math.min(1, innerWidth - 1);
187
+ const usageLines = model.usage ? sidebarUsageLines(model.usage, model.modelId, theme, innerWidth - inset) : [];
132
188
  const groups: Array<{ title: string; lines: string[] }> = [
133
189
  {
134
190
  title: "Project",
@@ -157,17 +213,15 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
157
213
  `${label("Context")} ${paintGauge(model.contextPercent, theme)} ${value(percent)}`,
158
214
  capacity,
159
215
  `${label("Cost")} ${value(formatCost(model.costTotal, model.subscription))}`,
160
- ...(usage ? [usage] : []),
216
+ ...usageLines,
161
217
  ],
162
218
  },
163
219
  { title: "Integrations", lines: model.statuses.length
164
220
  ? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)))
165
221
  : [label("No status reported")] },
166
222
  ];
167
- // Pre-wrap values before indenting so Unicode/ANSI continuation lines keep
168
- // the same inset without consuming the card's right border.
169
- const innerWidth = cardInnerWidth(width);
170
- const inset = Math.min(1, innerWidth - 1);
223
+ // Wrap and indent every group line before it reaches the card, so Unicode and
224
+ // ANSI continuation lines keep the same inset without consuming the right border.
171
225
  const body = groups.flatMap((group, index) => [
172
226
  ...(index ? [""] : []),
173
227
  label(group.title),