bermudis-pi-goodies 0.27.7 → 0.27.8

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
@@ -8,7 +8,7 @@ extensions. One entry point, thirteen independent features.
8
8
  | `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
9
9
  | `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. |
10
10
  | `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
11
- | `prefer-tools` | hook (no command) | Blocks legacy tools: `rm` → `trash <path>` (and `trash-rm`/`trash-empty`, which delete permanently), bare `python`/`pip`/`pytest`/`mypy` → `uv`. On `glm-5.*` models it also blocks bash `rg` (they invent ripgrep flags) — the built-in grep tool still works. |
11
+ | `prefer-tools` | hook (no command) | Blocks legacy tools: `rm` → `trash <path>` (and `trash-rm`/`trash-empty`, which delete permanently), bare `python`/`pip`/`pytest`/`mypy` → `uv`. Bash `rg` is allowed, except the replace flag: `-r` (including clusters like `-rn`) is blocked because it is `--replace` and silently replaces matched text — use `--replace` explicitly if you mean it. The built-in grep tool still works. |
12
12
  | `keep-model-on-new` | hook (no command) | Keep the active model when `/new` starts a fresh session instead of reverting to pi's saved default model. |
13
13
  | `model-thinking` | `/model-thinking` | Per-model default thinking levels: save the current level as this model's default and get it back on every switch to that model, instead of pi's global default. |
14
14
  | `clean-tui` | tool overrides (no command) | Collapse built-in tool output for a cleaner TUI: back-to-back same-tool calls share one block (e.g. `read ×2`) until a boundary — visible text (assistant prose or a typed user message) or a thinking block (even an empty one, as OpenAI emits between tool calls) — so reasoning-per-call models render one block per call. Images stay visible without expanding, expand a row with ctrl+o to see the full command and results/diffs. Long bash commands get an AI-generated summary once you pick a model with `/goodies summary-model <provider/model>` (see "Smart summaries" below) — expanding a row swaps the summary back out for the raw command. While enabled, also flips `@bermudi/pi-codex`'s `apply_patch`/`web_search` into the same burst style. Long thinking runs can show a live plain-English line above the editor (`/goodies thinking-summaries on`) instead of a static `Thinking...` — see "Smart summaries". |
@@ -25,7 +25,7 @@ extensions. One entry point, thirteen independent features.
25
25
  First install, pinned to a published version:
26
26
 
27
27
  ```bash
28
- pi install npm:bermudis-pi-goodies@0.27.7
28
+ pi install npm:bermudis-pi-goodies@0.27.8
29
29
  ```
30
30
 
31
31
  Picking up a new release on an existing install (`pi install` is not the
package/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * - copy-with-model /copy-with-model copy last reply tagged with the model
7
7
  * - copy-trajectory /copy-trajectory copy the whole conversation (text only) to the clipboard
8
8
  * - name-with-ai /name-with-ai generate a session name via the model
9
- * - prefer-tools hook block legacy tools (trash/uv; blocks rg on glm-5.*)
9
+ * - prefer-tools hook block legacy tools (trash/uv; blocks rg's -r replace flag)
10
10
  * - keep-model hook preserve the active model across /new
11
11
  * - model-thinking /model-thinking per-model default thinking levels, applied on switch
12
12
  * - clean-tui tool overrides collapse built-in tool output; keep a one-line call header, hide results/diffs until expanded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.27.7",
3
+ "version": "0.27.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
package/prefer-tools.ts CHANGED
@@ -16,9 +16,11 @@
16
16
  * python/pip/pytest/ -> uv
17
17
  * mypy
18
18
  *
19
- * Model-conditional: on glm-5.* models, `rg` is also blocked — they invent
20
- * ripgrep flags. The built-in grep tool (structured args, no raw flags) and
21
- * bash `grep -rn` still work.
19
+ * `rg` is allowed, but its replace flag is not: `-r` is ripgrep's
20
+ * `--replace`, so `rg -rn` silently replaces matches with "n" instead of
21
+ * showing line numbers. Any short-option cluster containing `r` is blocked;
22
+ * spell out `--replace` if you actually mean it. The built-in grep tool
23
+ * (structured args, no raw flags) and bash `grep -rn` still work.
22
24
  */
23
25
  import {
24
26
  isToolCallEventType,
@@ -49,24 +51,13 @@ const RULES: Rule[] = [
49
51
  ];
50
52
 
51
53
  /**
52
- * glm-5.* model ids hallucinate ripgrep flags; bash `rg` is blocked for them.
53
- * Matches "glm-5" as a family token: not preceded by an alphanumeric (so
54
- * Meta's xglm-564M doesn't match) and not followed by a digit (so glm-50,
55
- * glm-500 don't match). glm-5, glm-5.5, glm-5-turbo, glm-5:free,
56
- * provider-prefixed "kilo/glm-5" all match. glm-4.x is unaffected.
54
+ * `rg -r` is ripgrep's `--replace`, not "recursive": it replaces matched text
55
+ * in the output, so `rg -rn` replaces with "n" and shows no line numbers
56
+ * (machine lore: it once spawned a phantom `[ln]` log tag). Block the short
57
+ * flag in any cluster, but allow the explicit long form as the escape hatch.
57
58
  */
58
- export function isRipgrepBlockedModel(modelId: string | undefined): boolean {
59
- return (
60
- typeof modelId === "string" && /(^|[^a-z0-9])glm-5(?![0-9])/i.test(modelId)
61
- );
62
- }
63
-
64
- /** Extra rule appended when the active model is glm-5.*. */
65
- export const RG_RULE: Rule = {
66
- names: ["rg"],
67
- reason:
68
- 'rg is blocked for this model — it invents ripgrep flags. Use `grep -rn "pattern" path` instead (or pi\'s built-in grep tool).',
69
- };
59
+ export const RG_REPLACE_REASON =
60
+ '`rg -r` is `--replace` — it silently replaces matched text in the output (`rg -rn` replaces with "n" instead of showing line numbers). If you really mean it, use `--replace` explicitly.';
70
61
 
71
62
  const COMMAND_PREFIX_KEYWORDS = new Set([
72
63
  "if",
@@ -97,19 +88,34 @@ function isAssignmentWord(word: string): boolean {
97
88
  return /^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(word);
98
89
  }
99
90
 
100
- function matchCommand(
101
- name: string,
102
- rules: readonly Rule[],
103
- ): string | undefined {
104
- const base = name.includes("/")
105
- ? name.slice(name.lastIndexOf("/") + 1)
106
- : name;
107
- for (const rule of rules) {
91
+ function commandBase(name: string): string {
92
+ return name.includes("/") ? name.slice(name.lastIndexOf("/") + 1) : name;
93
+ }
94
+
95
+ function matchCommand(name: string): string | undefined {
96
+ const base = commandBase(name);
97
+ for (const rule of RULES) {
108
98
  if (rule.names.includes(base)) return rule.reason;
109
99
  }
110
100
  return undefined;
111
101
  }
112
102
 
103
+ /**
104
+ * Match a command word, plus the one argument-sensitive rule: an `rg`
105
+ * invocation that uses the replace flag. `argStart` is the offset just past
106
+ * the command word, where its arguments begin.
107
+ */
108
+ function matchCommandAt(
109
+ command: string,
110
+ word: string,
111
+ argStart: number,
112
+ ): string | undefined {
113
+ if (commandBase(word) === "rg" && rgUsesReplaceFlag(command, argStart)) {
114
+ return RG_REPLACE_REASON;
115
+ }
116
+ return matchCommand(word);
117
+ }
118
+
113
119
  function readWord(s: string, i: number): { word: string; next: number } {
114
120
  // Build the word char by char so backslash escapes strip the backslash:
115
121
  // `\rm` → `rm`, `foo\ bar` → `foo bar`. The old slice-based version kept
@@ -253,7 +259,6 @@ function scanExecutedSubs(
253
259
  s: string,
254
260
  start: number,
255
261
  end: number,
256
- rules: readonly Rule[],
257
262
  mode: "plain" | "double",
258
263
  ): string | undefined {
259
264
  let i = start;
@@ -273,7 +278,7 @@ function scanExecutedSubs(
273
278
  if (c === '"') {
274
279
  const stop = readQuote(s, i, '"', true);
275
280
  const innerEnd = s[stop - 1] === '"' ? stop - 1 : stop;
276
- const reason = scanExecutedSubs(s, i + 1, innerEnd, rules, "double");
281
+ const reason = scanExecutedSubs(s, i + 1, innerEnd, "double");
277
282
  if (reason) return reason;
278
283
  i = stop;
279
284
  continue;
@@ -293,7 +298,7 @@ function scanExecutedSubs(
293
298
  if (c === "`") {
294
299
  const stop = readQuote(s, i, "`", true);
295
300
  const innerEnd = s[stop - 1] === "`" ? stop - 1 : stop;
296
- const reason = detectLegacyTool(s.slice(i + 1, innerEnd), rules);
301
+ const reason = detectLegacyTool(s.slice(i + 1, innerEnd));
297
302
  if (reason) return reason;
298
303
  i = stop;
299
304
  continue;
@@ -306,7 +311,7 @@ function scanExecutedSubs(
306
311
  const stop = skipBalancedParens(s, i, 3);
307
312
  const innerEnd =
308
313
  s[stop - 1] === ")" && s[stop - 2] === ")" ? stop - 2 : stop;
309
- const reason = scanExecutedSubs(s, i + 3, innerEnd, rules, mode);
314
+ const reason = scanExecutedSubs(s, i + 3, innerEnd, mode);
310
315
  if (reason) return reason;
311
316
  i = stop;
312
317
  continue;
@@ -314,7 +319,7 @@ function scanExecutedSubs(
314
319
  if (c === "$" && s.startsWith("$(", i)) {
315
320
  const stop = skipBalancedParens(s, i, 2);
316
321
  const innerEnd = s[stop - 1] === ")" ? stop - 1 : stop;
317
- const reason = detectLegacyTool(s.slice(i + 2, innerEnd), rules);
322
+ const reason = detectLegacyTool(s.slice(i + 2, innerEnd));
318
323
  if (reason) return reason;
319
324
  i = stop;
320
325
  continue;
@@ -327,7 +332,7 @@ function scanExecutedSubs(
327
332
  // substitution runs (`"${X:-'$(rm)'}"` executes).
328
333
  const stop = skipBalancedBraces(s, i);
329
334
  const innerEnd = s[stop - 1] === "}" ? stop - 1 : stop;
330
- const reason = scanExecutedSubs(s, i + 2, innerEnd, rules, mode);
335
+ const reason = scanExecutedSubs(s, i + 2, innerEnd, mode);
331
336
  if (reason) return reason;
332
337
  i = stop;
333
338
  continue;
@@ -340,7 +345,6 @@ function scanExecutedSubs(
340
345
  function readDollar(
341
346
  s: string,
342
347
  i: number,
343
- rules: readonly Rule[],
344
348
  ): { next: number; reason?: string } | null {
345
349
  if (i >= s.length || s[i] !== "$") return null;
346
350
 
@@ -354,14 +358,14 @@ function readDollar(
354
358
  const innerEnd = s[end - 1] === ")" && s[end - 2] === ")" ? end - 2 : end;
355
359
  return {
356
360
  next: end,
357
- reason: scanExecutedSubs(s, i + 3, innerEnd, rules, "plain"),
361
+ reason: scanExecutedSubs(s, i + 3, innerEnd, "plain"),
358
362
  };
359
363
  }
360
364
  if (s.startsWith("(", i + 1)) {
361
365
  const end = skipBalancedParens(s, i, 2);
362
366
  const closeParen = end > 0 && s[end - 1] === ")" ? 1 : 0;
363
367
  const inner = s.slice(i + 2, end - closeParen);
364
- const reason = detectLegacyTool(inner, rules);
368
+ const reason = detectLegacyTool(inner);
365
369
  return { next: end, reason };
366
370
  }
367
371
  if (s.startsWith("{", i + 1)) {
@@ -370,7 +374,7 @@ function readDollar(
370
374
  const innerEnd = s[end - 1] === "}" ? end - 1 : end;
371
375
  return {
372
376
  next: end,
373
- reason: scanExecutedSubs(s, i + 2, innerEnd, rules, "plain"),
377
+ reason: scanExecutedSubs(s, i + 2, innerEnd, "plain"),
374
378
  };
375
379
  }
376
380
  if (i + 1 < s.length && /[0-9?@*#\-!$]/.test(s[i + 1])) {
@@ -447,10 +451,7 @@ function readHeredocDelimiter(
447
451
  * backslash suppresses expansion (`\$(rm)` is literal). Returns the matched
448
452
  * rule's reason if a blocked tool is found, undefined otherwise.
449
453
  */
450
- function scanHeredocLineForCommandSubs(
451
- line: string,
452
- rules: readonly Rule[],
453
- ): string | undefined {
454
+ function scanHeredocLineForCommandSubs(line: string): string | undefined {
454
455
  let i = 0;
455
456
  while (i < line.length) {
456
457
  const c = line[i];
@@ -461,7 +462,7 @@ function scanHeredocLineForCommandSubs(
461
462
  if (line.startsWith("$(", i)) {
462
463
  const end = skipBalancedParens(line, i, 2);
463
464
  const inner = line.slice(i + 2, end - 1);
464
- const reason = detectLegacyTool(inner, rules);
465
+ const reason = detectLegacyTool(inner);
465
466
  if (reason) return reason;
466
467
  i = end;
467
468
  continue;
@@ -469,7 +470,7 @@ function scanHeredocLineForCommandSubs(
469
470
  if (c === "`") {
470
471
  const end = readQuote(line, i, "`", true);
471
472
  const inner = line.slice(i + 1, end - 1);
472
- const reason = detectLegacyTool(inner, rules);
473
+ const reason = detectLegacyTool(inner);
473
474
  if (reason) return reason;
474
475
  i = end;
475
476
  continue;
@@ -485,7 +486,6 @@ function skipHeredocBody(
485
486
  delimiter: string,
486
487
  indented: boolean,
487
488
  quoted: boolean,
488
- rules: readonly Rule[],
489
489
  ): { next: number; reason?: string } {
490
490
  let pos = i;
491
491
  while (pos <= s.length) {
@@ -499,7 +499,7 @@ function skipHeredocBody(
499
499
  // In unquoted heredocs, command substitutions execute — scan for
500
500
  // blocked tools inside $(...) and backticks.
501
501
  if (!quoted) {
502
- const reason = scanHeredocLineForCommandSubs(line, rules);
502
+ const reason = scanHeredocLineForCommandSubs(line);
503
503
  if (reason) return { next: end, reason };
504
504
  }
505
505
  if (nl === -1) break;
@@ -530,11 +530,157 @@ const SUDO_OPTS_WITH_ARG = new Set([
530
530
  "--user",
531
531
  ]);
532
532
 
533
- export function detectLegacyTool(
534
- command: string,
535
- extraRules: readonly Rule[] = [],
536
- ): string | undefined {
537
- const rules = extraRules.length > 0 ? [...RULES, ...extraRules] : RULES;
533
+ /**
534
+ * Short rg options that consume a value: the rest of the cluster is their
535
+ * value, or — at cluster end — the next word is. `r` is handled separately
536
+ * (and blocked) before this set is consulted.
537
+ */
538
+ const RG_SHORT_VALUE_OPTS = new Set([
539
+ "A",
540
+ "B",
541
+ "C",
542
+ "d",
543
+ "E",
544
+ "e",
545
+ "f",
546
+ "g",
547
+ "j",
548
+ "M",
549
+ "m",
550
+ "t",
551
+ "T",
552
+ ]);
553
+
554
+ /**
555
+ * Long rg options that consume the next word as a value. Values are skipped
556
+ * before flag inspection, so `--field-match-separator -r` (ripgrep accepts
557
+ * `-r` there as literal output text) is not mistaken for the replace flag.
558
+ */
559
+ const RG_LONG_VALUE_OPTS = new Set([
560
+ "--after-context",
561
+ "--before-context",
562
+ "--context",
563
+ "--encoding",
564
+ "--field-context-separator",
565
+ "--field-match-separator",
566
+ "--file",
567
+ "--glob",
568
+ "--iglob",
569
+ "--max-columns",
570
+ "--max-count",
571
+ "--max-depth",
572
+ "--max-filesize",
573
+ "--path-separator",
574
+ "--pre",
575
+ "--regexp",
576
+ "--replace",
577
+ "--sort",
578
+ "--sortr",
579
+ "--threads",
580
+ "--type",
581
+ "--type-add",
582
+ "--type-clear",
583
+ "--type-not",
584
+ ]);
585
+
586
+ /**
587
+ * Read one shell word after an `rg` command, keeping quoted content as part
588
+ * of the token so `rg "-r"` and `rg '-rn'` are caught like their unquoted
589
+ * twins. Stops at whitespace or an unquoted operator.
590
+ */
591
+ function readArgWord(s: string, i: number): { word: string; next: number } {
592
+ let word = "";
593
+ while (i < s.length) {
594
+ const c = s[i];
595
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") break;
596
+ if (readOperator(s, i)) break;
597
+ if (c === "'" || c === '"') {
598
+ const end = readQuote(s, i, c, c === '"');
599
+ const innerEnd = s[end - 1] === c ? end - 1 : end;
600
+ word += s.slice(i + 1, innerEnd);
601
+ i = end;
602
+ continue;
603
+ }
604
+ if (c === "\\" && i + 1 < s.length) {
605
+ word += s[i + 1];
606
+ i += 2;
607
+ continue;
608
+ }
609
+ if (c === "$") {
610
+ const d = readDollar(s, i);
611
+ if (d) {
612
+ word += s.slice(i, d.next);
613
+ i = d.next;
614
+ continue;
615
+ }
616
+ }
617
+ word += c;
618
+ i++;
619
+ }
620
+ return { word, next: i };
621
+ }
622
+
623
+ /**
624
+ * True when the `rg` invocation starting at `start` uses the replace flag in
625
+ * short form, including clusters (`-rn`, `-rln`, `-nr`). The explicit
626
+ * `--replace` long form stays allowed. Value-taking options are skipped so a
627
+ * pattern/replacement that merely looks like `-r` is not a false hit.
628
+ */
629
+ function rgUsesReplaceFlag(s: string, start: number): boolean {
630
+ let i = start;
631
+ let skipValue = false;
632
+ while (i < s.length) {
633
+ const c = s[i];
634
+ if (c === " " || c === "\t" || c === "\r") {
635
+ i++;
636
+ continue;
637
+ }
638
+ if (c === "\n") break;
639
+ // Unquoted `#` at the start of a word begins a shell comment: bash never
640
+ // passes it (or anything after it) to rg, so `rg -n foo . # avoid -rn`
641
+ // must not be blocked for what its comment merely mentions. Mid-word `#`
642
+ // (`rg foo#bar`) stays inside the word read below, matching bash.
643
+ if (c === "#") break;
644
+ const op = readOperator(s, i);
645
+ if (op) {
646
+ if (op.type !== "redirect") break;
647
+ // Redirections may appear among a command's arguments; consume the
648
+ // target (`>out`) and keep looking at the real rg options after it.
649
+ i = op.next;
650
+ while (i < s.length && (s[i] === " " || s[i] === "\t")) i++;
651
+ if (i < s.length) i = readArgWord(s, i).next;
652
+ continue;
653
+ }
654
+ const { word, next } = readArgWord(s, i);
655
+ if (next === i) {
656
+ i++;
657
+ continue;
658
+ }
659
+ i = next;
660
+ if (skipValue) {
661
+ skipValue = false;
662
+ continue;
663
+ }
664
+ if (word === "--") break;
665
+ if (word.startsWith("--")) {
666
+ if (!word.includes("=") && RG_LONG_VALUE_OPTS.has(word)) skipValue = true;
667
+ continue;
668
+ }
669
+ if (!word.startsWith("-") || word === "-") continue;
670
+ for (let k = 1; k < word.length; k++) {
671
+ const flag = word[k];
672
+ if (flag === "r") return true;
673
+ if (RG_SHORT_VALUE_OPTS.has(flag)) {
674
+ // The rest of the cluster (or the next word) is this option's value.
675
+ if (k === word.length - 1) skipValue = true;
676
+ break;
677
+ }
678
+ }
679
+ }
680
+ return false;
681
+ }
682
+
683
+ export function detectLegacyTool(command: string): string | undefined {
538
684
  let i = 0;
539
685
  let commandPos = true;
540
686
  // Set once an env-var assignment word is seen in the current simple
@@ -560,7 +706,6 @@ export function detectLegacyTool(
560
706
  heredoc.delimiter,
561
707
  heredoc.indented,
562
708
  heredoc.quoted,
563
- rules,
564
709
  );
565
710
  if (result.reason) return result.reason;
566
711
  i = result.next;
@@ -671,17 +816,11 @@ export function detectLegacyTool(
671
816
  // their unquoted twin.
672
817
  if (quote === '"') {
673
818
  const innerEnd = command[end - 1] === '"' ? end - 1 : end;
674
- const reason = scanExecutedSubs(
675
- command,
676
- i + 1,
677
- innerEnd,
678
- rules,
679
- "double",
680
- );
819
+ const reason = scanExecutedSubs(command, i + 1, innerEnd, "double");
681
820
  if (reason) return reason;
682
821
  } else if (quote === "`") {
683
822
  const innerEnd = command[end - 1] === "`" ? end - 1 : end;
684
- const reason = detectLegacyTool(command.slice(i + 1, innerEnd), rules);
823
+ const reason = detectLegacyTool(command.slice(i + 1, innerEnd));
685
824
  if (reason) return reason;
686
825
  }
687
826
  i = end;
@@ -694,7 +833,7 @@ export function detectLegacyTool(
694
833
  }
695
834
 
696
835
  if (c === "$") {
697
- const d = readDollar(command, i, rules);
836
+ const d = readDollar(command, i);
698
837
  if (d) {
699
838
  if (d.reason) return d.reason;
700
839
  i = d.next;
@@ -746,7 +885,7 @@ export function detectLegacyTool(
746
885
  skipNextWord = false;
747
886
  continue;
748
887
  }
749
- const reason = matchCommand(word, rules);
888
+ const reason = matchCommandAt(command, word, i);
750
889
  if (reason) return reason;
751
890
  sudoNext = false;
752
891
  } else {
@@ -754,7 +893,7 @@ export function detectLegacyTool(
754
893
  skipNextWord = false;
755
894
  continue;
756
895
  }
757
- const reason = matchCommand(word, rules);
896
+ const reason = matchCommandAt(command, word, i);
758
897
  if (reason) return reason;
759
898
  }
760
899
  if (!COMMAND_PREFIX_KEYWORDS.has(word)) {
@@ -769,13 +908,10 @@ export function detectLegacyTool(
769
908
  }
770
909
 
771
910
  export default function preferTools(pi: ExtensionAPI) {
772
- pi.on("tool_call", async (event, ctx) => {
911
+ pi.on("tool_call", async (event) => {
773
912
  if (!isToolCallEventType("bash", event)) return;
774
913
 
775
- // Model-conditional rule: glm-5.* gets bash `rg` blocked (live model,
776
- // read per call — model switches mid-session take effect immediately).
777
- const extraRules = isRipgrepBlockedModel(ctx.model?.id) ? [RG_RULE] : [];
778
- const reason = detectLegacyTool(event.input.command ?? "", extraRules);
914
+ const reason = detectLegacyTool(event.input.command ?? "");
779
915
  if (reason) {
780
916
  return { block: true, reason };
781
917
  }
@@ -1733,10 +1733,19 @@ export default function providerBalance(
1733
1733
  return;
1734
1734
  }
1735
1735
 
1736
- // Do not render a cached value while credentials are being resolved. The
1737
- // same provider can represent a different account after login/logout.
1738
- identityPending = true;
1739
- requestRender?.();
1736
+ // Hide the balance while credentials are being resolved — but only when
1737
+ // nothing is displayed yet (cold start, provider switch, post-logout).
1738
+ // A same-account refresh keeps its last value on screen: blanking on
1739
+ // every refresh would flicker the footer for the full duration of
1740
+ // keychain reads and OAuth token refreshes (seconds under lock
1741
+ // contention). If resolution reveals a different account, the
1742
+ // displayedCacheKey mismatch below still clears it, so a foreign
1743
+ // reading is never painted. Same principle as the post-fetch re-check
1744
+ // below: never blank a displayed same-account value.
1745
+ if (!balance) {
1746
+ identityPending = true;
1747
+ requestRender?.();
1748
+ }
1740
1749
 
1741
1750
  // Preserve a known same-account value during routine credential refresh,
1742
1751
  // but clear it if identity resolution fails or reveals another account.
@@ -1831,6 +1840,14 @@ export default function providerBalance(
1831
1840
  );
1832
1841
  const observedAtMs = now();
1833
1842
  if (generation !== refreshGeneration) return;
1843
+ if (nextBalance.length === 0) {
1844
+ // An empty reading would render as an empty string — visually
1845
+ // identical to the balance vanishing — and parseCachedBalance already
1846
+ // refuses to serve empty entries from the cache. Treat it as a failed
1847
+ // observation: the catch below keeps the previous display and records
1848
+ // the failure.
1849
+ throw new Error("provider returned no balance segments");
1850
+ }
1834
1851
 
1835
1852
  // Login/logout does not emit model_select. Re-resolve identity before
1836
1853
  // committing so an account switch during the request cannot paint the
@@ -1910,7 +1927,14 @@ export default function providerBalance(
1910
1927
  companionTokenAfter,
1911
1928
  );
1912
1929
  }
1913
- // Recovery detected: clear the marker so sibling idle polls resume.
1930
+ // Recovery detected: tell the log once, then clear the marker so
1931
+ // sibling idle polls resume.
1932
+ if (readBalanceFailureAt(cacheKey, now(), cacheDir) !== null) {
1933
+ logGoodiesEvent({
1934
+ type: "provider_balance_refresh_recovered",
1935
+ provider: providerId,
1936
+ });
1937
+ }
1914
1938
  clearBalanceFailureMarker(cacheKey, cacheDir);
1915
1939
  const persisted = writeCachedBalance(
1916
1940
  cacheKey,
@@ -1929,12 +1953,28 @@ export default function providerBalance(
1929
1953
  return;
1930
1954
  }
1931
1955
  identityPending = false;
1932
- clearBalance();
1956
+ // A failed fetch does not make the displayed value wrong — it stays
1957
+ // TTL-gated at render time. Keep a same-account reading on screen
1958
+ // instead of blanking the footer for the whole backoff window; clear
1959
+ // only when the failure landed before any account was identified.
1960
+ if (cacheKey === undefined || cacheKey !== displayedCacheKey) {
1961
+ clearBalance();
1962
+ }
1933
1963
  // Record the failure machine-wide so sibling sessions' idle polls back
1934
1964
  // off instead of stacking their own timeouts onto the same outage.
1965
+ const firstFailureOfStreak =
1966
+ cacheKey === undefined ||
1967
+ readBalanceFailureAt(cacheKey, now(), cacheDir) === null;
1935
1968
  if (cacheKey !== undefined) {
1936
1969
  writeBalanceFailureMarker(cacheKey, now(), cacheDir);
1937
1970
  }
1971
+ if (firstFailureOfStreak) {
1972
+ logGoodiesEvent({
1973
+ type: "provider_balance_refresh_failed",
1974
+ provider: providerId,
1975
+ message: describeError(error),
1976
+ });
1977
+ }
1938
1978
  // This is a best-effort background refresh. Writing to stdout/stderr while
1939
1979
  // Pi owns the terminal corrupts the TUI (the text appears in the editor),
1940
1980
  // so expose failures to other extensions without producing terminal output.