@oh-my-pi/pi-coding-agent 15.11.4 → 15.11.7

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 (98) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/dist/cli.js +520 -451
  3. package/dist/types/cli/bench-cli.d.ts +78 -0
  4. package/dist/types/cli/usage-cli.d.ts +10 -1
  5. package/dist/types/commands/bench.d.ts +29 -0
  6. package/dist/types/commands/usage.d.ts +9 -0
  7. package/dist/types/config/model-resolver.d.ts +3 -2
  8. package/dist/types/config/settings-schema.d.ts +125 -3
  9. package/dist/types/edit/renderer.d.ts +1 -0
  10. package/dist/types/modes/components/oauth-selector.d.ts +10 -1
  11. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  12. package/dist/types/modes/components/session-selector.d.ts +1 -1
  13. package/dist/types/modes/components/settings-selector.d.ts +8 -1
  14. package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +10 -0
  18. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  19. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
  20. package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
  21. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
  22. package/dist/types/modes/types.d.ts +2 -0
  23. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  24. package/dist/types/session/agent-session.d.ts +14 -1
  25. package/dist/types/session/auth-storage.d.ts +1 -1
  26. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  27. package/dist/types/session/snapcompact-inline.d.ts +107 -4
  28. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  29. package/dist/types/task/render.d.ts +1 -0
  30. package/dist/types/tools/bash.d.ts +2 -0
  31. package/dist/types/tools/eval-render.d.ts +1 -0
  32. package/dist/types/tools/renderers.d.ts +13 -0
  33. package/dist/types/tools/ssh.d.ts +1 -0
  34. package/dist/types/tools/todo.d.ts +0 -11
  35. package/package.json +11 -11
  36. package/src/cli/bench-cli.ts +437 -0
  37. package/src/cli/usage-cli.ts +187 -16
  38. package/src/cli-commands.ts +1 -0
  39. package/src/commands/bench.ts +42 -0
  40. package/src/commands/usage.ts +8 -0
  41. package/src/config/model-registry.ts +52 -5
  42. package/src/config/model-resolver.ts +36 -5
  43. package/src/config/settings-schema.ts +148 -3
  44. package/src/config/settings.ts +9 -0
  45. package/src/edit/renderer.ts +5 -0
  46. package/src/hindsight/client.ts +26 -1
  47. package/src/hindsight/state.ts +6 -2
  48. package/src/internal-urls/docs-index.generated.ts +2 -2
  49. package/src/mcp/transports/stdio.ts +81 -7
  50. package/src/modes/components/oauth-selector.ts +67 -7
  51. package/src/modes/components/reset-usage-selector.ts +161 -0
  52. package/src/modes/components/session-selector.ts +8 -2
  53. package/src/modes/components/settings-selector.ts +89 -47
  54. package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
  55. package/src/modes/components/snapcompact-shape-preview.ts +192 -0
  56. package/src/modes/components/tool-execution.ts +26 -0
  57. package/src/modes/components/transcript-container.ts +23 -1
  58. package/src/modes/controllers/command-controller.ts +24 -1
  59. package/src/modes/controllers/input-controller.ts +8 -6
  60. package/src/modes/controllers/selector-controller.ts +72 -2
  61. package/src/modes/interactive-mode.ts +83 -0
  62. package/src/modes/session-observer-registry.ts +61 -3
  63. package/src/modes/setup-wizard/index.ts +1 -0
  64. package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
  65. package/src/modes/setup-wizard/scenes/providers.ts +36 -2
  66. package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
  67. package/src/modes/setup-wizard/scenes/theme.ts +28 -1
  68. package/src/modes/setup-wizard/scenes/types.ts +10 -1
  69. package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
  70. package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
  71. package/src/modes/theme/theme.ts +2 -2
  72. package/src/modes/types.ts +2 -0
  73. package/src/modes/utils/context-usage.ts +75 -1
  74. package/src/prompts/bench.md +7 -0
  75. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  76. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  77. package/src/prompts/system/snapcompact-toolresult-note.md +1 -1
  78. package/src/prompts/tools/browser.md +33 -43
  79. package/src/prompts/tools/eval.md +27 -50
  80. package/src/prompts/tools/irc.md +29 -31
  81. package/src/prompts/tools/read.md +31 -37
  82. package/src/prompts/tools/todo.md +1 -2
  83. package/src/sdk.ts +4 -2
  84. package/src/session/agent-session.ts +136 -6
  85. package/src/session/auth-storage.ts +3 -0
  86. package/src/session/codex-auto-reset.ts +190 -0
  87. package/src/session/snapcompact-inline.ts +404 -75
  88. package/src/slash-commands/builtin-registry.ts +145 -8
  89. package/src/slash-commands/helpers/context-report.ts +28 -1
  90. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  91. package/src/slash-commands/helpers/usage-report.ts +12 -0
  92. package/src/task/index.ts +30 -7
  93. package/src/task/render.ts +34 -19
  94. package/src/tools/bash.ts +3 -0
  95. package/src/tools/eval-render.ts +4 -0
  96. package/src/tools/renderers.ts +13 -0
  97. package/src/tools/ssh.ts +3 -0
  98. package/src/tools/todo.ts +8 -128
@@ -4,12 +4,12 @@ import {
4
4
  type Component,
5
5
  Container,
6
6
  extractPrintableText,
7
- fuzzyFilter,
7
+ fuzzyRank,
8
8
  getKeybindings,
9
9
  getSettingItemFilterText,
10
+ type ImageBudget,
10
11
  Input,
11
12
  matchesKey,
12
- padding,
13
13
  parseSgrMouse,
14
14
  type SelectItem,
15
15
  SelectList,
@@ -23,6 +23,7 @@ import {
23
23
  truncateToWidth,
24
24
  visibleWidth,
25
25
  } from "@oh-my-pi/pi-tui";
26
+ import type { ShapeTarget } from "@oh-my-pi/snapcompact";
26
27
  import { getDefault, type SettingPath, settings } from "../../config/settings";
27
28
  import type {
28
29
  SettingTab,
@@ -37,6 +38,7 @@ import { getTabBarTheme } from "../shared";
37
38
  import { bottomBorder, divider, row, topBorder } from "./overlay-box";
38
39
  import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings";
39
40
  import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs";
41
+ import { SnapcompactShapePreview } from "./snapcompact-shape-preview";
40
42
  import { getPreset } from "./status-line/presets";
41
43
 
42
44
  /**
@@ -68,8 +70,6 @@ class TextInputSubmenu extends Container {
68
70
  this.#input = new Input();
69
71
  if (currentValue) {
70
72
  this.#input.setValue(currentValue);
71
- // Move cursor to end of pre-filled value (ctrl+e = cursorLineEnd).
72
- this.#input.handleInput("\x05");
73
73
  }
74
74
  this.#input.onSubmit = value => {
75
75
  this.onSubmit(value); // empty string clears the setting
@@ -100,6 +100,7 @@ class SelectSubmenu extends Container {
100
100
  onCancel: () => void,
101
101
  onSelectionChange?: (value: string) => void | Promise<void>,
102
102
  private readonly getPreview?: () => string,
103
+ footer?: Component,
103
104
  ) {
104
105
  super();
105
106
 
@@ -161,6 +162,13 @@ class SelectSubmenu extends Container {
161
162
  // Hint
162
163
  this.addChild(new Spacer(1));
163
164
  this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0));
165
+
166
+ // Footer (e.g. the snapcompact shape preview) below the interactive rows,
167
+ // so the list never shifts while browsing.
168
+ if (footer) {
169
+ this.addChild(new Spacer(1));
170
+ this.addChild(footer);
171
+ }
164
172
  }
165
173
 
166
174
  #updatePreview(): void {
@@ -252,6 +260,12 @@ export interface SettingsRuntimeContext {
252
260
  availableThemes: string[];
253
261
  /** Working directory for plugins tab */
254
262
  cwd: string;
263
+ /** Active model (api + id); resolves what the snapcompact `auto` shape maps to. */
264
+ model?: ShapeTarget;
265
+ /** Shared TUI image budget (graphics ids + transmit-once) for image previews. */
266
+ imageBudget?: ImageBudget;
267
+ /** Schedules a re-render after async preview work completes. */
268
+ requestRender?: () => void;
255
269
  }
256
270
 
257
271
  /** Status line settings subset for preview */
@@ -291,6 +305,8 @@ export class SettingsSelectorComponent implements Component {
291
305
  #currentTabId: SettingTab | "plugins" = "appearance";
292
306
  #preSearchTabId: SettingTab | "plugins" = "appearance";
293
307
  #searchQuery = "";
308
+ /** Single-line editor backing the search banner (cursor, word ops, paste). */
309
+ #searchInput = new Input();
294
310
  #searchMatchCount = 0;
295
311
  /** First matching item id per tab id, for Tab-key jumps while searching. */
296
312
  #searchFirstMatch = new Map<string, string>();
@@ -354,7 +370,7 @@ export class SettingsSelectorComponent implements Component {
354
370
 
355
371
  #footerHintText(): string {
356
372
  if (this.#searchList) {
357
- return "Enter/Space to change · Tab to jump tabs · Backspace to edit · Esc to exit search";
373
+ return "Enter to change · Tab to jump tabs · Esc to exit search";
358
374
  }
359
375
  if (this.#currentTabId === "plugins") {
360
376
  return "Tab to switch tabs · Esc to close";
@@ -366,28 +382,17 @@ export class SettingsSelectorComponent implements Component {
366
382
  return `Enter/Space to change · ${nav} · Type to search · Esc to close`;
367
383
  }
368
384
 
369
- /** Single-line search banner: accent icon, bold query + caret, right-aligned match count. */
385
+ /** Single-line search banner: accent icon, editable query with live cursor, right-aligned match count. */
370
386
  #renderSearchBanner(width: number): string {
371
387
  const icon = theme.symbol("icon.search");
372
388
  const countText = this.#searchMatchCount === 1 ? "1 match" : `${this.#searchMatchCount} matches`;
373
389
  const rightWidth = visibleWidth(countText) + 1; // trailing margin
374
- // Fixed chrome: " <icon> " prefix plus the "" cursor cell.
375
- const queryBudget = Math.max(4, width - visibleWidth(icon) - 4 - rightWidth - 1);
376
-
377
- // Keep the tail visible (where the cursor is) when the query overflows.
378
- let display = this.#searchQuery;
379
- if (visibleWidth(display) > queryBudget) {
380
- const chars = [...display];
381
- while (chars.length > 1 && visibleWidth(chars.join("")) > queryBudget - 1) {
382
- chars.shift();
383
- }
384
- display = `…${chars.join("")}`;
385
- }
386
-
387
- const left = ` ${theme.fg("accent", icon)} ${theme.bold(display)}${theme.fg("accent", "▌")}`;
390
+ const prefix = ` ${theme.fg("accent", icon)} `;
391
+ // The input pads itself to exactly this width and keeps the cursor in view.
392
+ const inputWidth = Math.max(4, width - visibleWidth(prefix) - rightWidth - 1);
393
+ const inputLine = this.#searchInput.render(inputWidth)[0] ?? "";
388
394
  const count = theme.fg(this.#searchMatchCount > 0 ? "dim" : "warning", countText);
389
- const gap = Math.max(1, width - visibleWidth(left) - rightWidth);
390
- return truncateToWidth(`${left}${padding(gap)}${count} `, width);
395
+ return truncateToWidth(`${prefix}${theme.bold(inputLine)} ${count} `, width);
391
396
  }
392
397
 
393
398
  /**
@@ -511,6 +516,9 @@ export class SettingsSelectorComponent implements Component {
511
516
  /** Swap the tab content for the global search result list. */
512
517
  #startSearch(initialQuery: string): void {
513
518
  this.#preSearchTabId = this.#currentTabId;
519
+ this.#searchInput = new Input();
520
+ this.#searchInput.prompt = "";
521
+ this.#searchInput.setValue(initialQuery);
514
522
  const list = new SettingsList(
515
523
  [],
516
524
  10,
@@ -547,6 +555,7 @@ export class SettingsSelectorComponent implements Component {
547
555
 
548
556
  const counts = new Map<SettingTab, number>();
549
557
  const items: SettingItem[] = [];
558
+ const tabResults: { tab: SettingTab; matched: SettingItem[]; bestScore: number; order: number }[] = [];
550
559
  this.#searchFirstMatch.clear();
551
560
  let total = 0;
552
561
  for (const tab of SETTING_TABS) {
@@ -555,24 +564,40 @@ export class SettingsSelectorComponent implements Component {
555
564
  const item = this.#defToItem(def);
556
565
  if (item) candidates.push(item);
557
566
  }
558
- const matched = fuzzyFilter(candidates, query, getSettingItemFilterText);
567
+ const ranked = fuzzyRank(candidates, query, getSettingItemFilterText);
568
+ const matched = ranked.map(result => result.item);
559
569
  counts.set(tab, matched.length);
560
570
  if (matched.length === 0) continue;
561
571
  total += matched.length;
562
- const meta = TAB_METADATA[tab];
572
+ tabResults.push({
573
+ tab,
574
+ matched,
575
+ bestScore: ranked[0]?.score ?? 0,
576
+ order: SETTING_TABS.indexOf(tab),
577
+ });
578
+ }
579
+
580
+ tabResults.sort((a, b) => a.bestScore - b.bestScore || a.order - b.order);
581
+ for (const result of tabResults) {
582
+ const meta = TAB_METADATA[result.tab];
563
583
  items.push({
564
- id: `__tab:${tab}`,
584
+ id: `__tab:${result.tab}`,
565
585
  label: `${theme.symbol(meta.icon as Parameters<typeof theme.symbol>[0])} ${meta.label}`,
566
586
  currentValue: "",
567
587
  heading: true,
568
588
  });
569
- this.#searchFirstMatch.set(tab, matched[0].id);
570
- items.push(...matched);
589
+ this.#searchFirstMatch.set(result.tab, result.matched[0]?.id ?? "");
590
+ items.push(...result.matched);
571
591
  }
572
592
 
573
593
  this.#searchList.setItems(items);
574
594
  this.#searchMatchCount = total;
575
- this.#tabBar.setTabs(this.#buildSearchTabs(counts));
595
+ this.#tabBar.setTabs(
596
+ this.#buildSearchTabs(
597
+ counts,
598
+ tabResults.map(result => result.tab),
599
+ ),
600
+ );
576
601
  this.#syncTabBarToSelection(this.#searchList.getSelectedItem());
577
602
  }
578
603
 
@@ -597,20 +622,25 @@ export class SettingsSelectorComponent implements Component {
597
622
  }
598
623
  }
599
624
 
600
- /** Matching tabs first (counts attached), the rest muted at the end. */
601
- #buildSearchTabs(counts: Map<SettingTab, number>): Tab[] {
625
+ /** Matching tabs first (counts attached), ordered by best result score; the rest stay muted at the end. */
626
+ #buildSearchTabs(counts: Map<SettingTab, number>, matchedTabOrder: readonly SettingTab[]): Tab[] {
602
627
  const matched: Tab[] = [];
603
628
  const empty: Tab[] = [];
604
- for (const id of SETTING_TABS) {
629
+ const matchedIds = new Set<SettingTab>(matchedTabOrder);
630
+ for (const id of matchedTabOrder) {
605
631
  const meta = TAB_METADATA[id];
606
632
  const icon = theme.symbol(meta.icon as Parameters<typeof theme.symbol>[0]);
607
633
  const count = counts.get(id) ?? 0;
608
634
  if (count > 0) {
609
635
  matched.push({ id, label: `${icon} ${meta.label} (${count})`, short: `${icon} ${count}` });
610
- } else {
611
- empty.push({ id, label: `${icon} ${meta.label}`, short: icon, muted: true });
612
636
  }
613
637
  }
638
+ for (const id of SETTING_TABS) {
639
+ if (matchedIds.has(id)) continue;
640
+ const meta = TAB_METADATA[id];
641
+ const icon = theme.symbol(meta.icon as Parameters<typeof theme.symbol>[0]);
642
+ empty.push({ id, label: `${icon} ${meta.label}`, short: icon, muted: true });
643
+ }
614
644
  // Plugins hosts its own UI; it is not part of the schema-backed search.
615
645
  empty.push({ id: "plugins", label: `${theme.icon.package} Plugins`, short: theme.icon.package, muted: true });
616
646
  return [...matched, ...empty];
@@ -745,6 +775,7 @@ export class SettingsSelectorComponent implements Component {
745
775
  // Preview handlers
746
776
  let onPreview: ((value: string) => void | Promise<void>) | undefined;
747
777
  let onPreviewCancel: (() => void) | undefined;
778
+ let footer: Component | undefined;
748
779
 
749
780
  const activeThemeBeforePreview = getCurrentThemeName() ?? currentValue;
750
781
  if (def.path === "theme.dark" || def.path === "theme.light") {
@@ -784,6 +815,14 @@ export class SettingsSelectorComponent implements Component {
784
815
  const separator = settings.get("statusLine.separator");
785
816
  this.callbacks.onStatusLinePreview?.({ separator });
786
817
  };
818
+ } else if (def.path === "snapcompact.shape") {
819
+ const shapePreview = new SnapcompactShapePreview(currentValue, {
820
+ model: this.context.model,
821
+ imageBudget: this.context.imageBudget,
822
+ requestRender: this.context.requestRender,
823
+ });
824
+ onPreview = value => shapePreview.setValue(value);
825
+ footer = shapePreview;
787
826
  }
788
827
 
789
828
  // Provide status line preview for theme selection
@@ -806,6 +845,7 @@ export class SettingsSelectorComponent implements Component {
806
845
  },
807
846
  onPreview,
808
847
  getPreview,
848
+ footer,
809
849
  );
810
850
  }
811
851
 
@@ -1029,25 +1069,27 @@ export class SettingsSelectorComponent implements Component {
1029
1069
  this.#endSearch(true);
1030
1070
  return;
1031
1071
  }
1032
- if (kb.matches(data, "tui.editor.deleteCharBackward")) {
1033
- this.#setSearchQuery([...this.#searchQuery].slice(0, -1).join(""));
1034
- return;
1035
- }
1036
- if (
1037
- matchesKey(data, "tab") ||
1038
- matchesKey(data, "shift+tab") ||
1039
- matchesKey(data, "left") ||
1040
- matchesKey(data, "right")
1041
- ) {
1072
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
1042
1073
  // Jump between tabs that have matches (muted tabs are skipped).
1043
1074
  this.#tabBar.handleInput(data);
1044
1075
  return;
1045
1076
  }
1046
- const printable = extractPrintableText(data);
1047
- if (printable !== undefined) {
1048
- this.#setSearchQuery(this.#searchQuery + printable);
1077
+ // Selection, paging, and activation stay with the result list.
1078
+ if (
1079
+ kb.matches(data, "tui.select.up") ||
1080
+ kb.matches(data, "tui.select.down") ||
1081
+ kb.matches(data, "tui.select.pageUp") ||
1082
+ kb.matches(data, "tui.select.pageDown") ||
1083
+ kb.matches(data, "tui.select.confirm") ||
1084
+ data === "\n"
1085
+ ) {
1086
+ list.handleInput(data);
1049
1087
  return;
1050
1088
  }
1051
- list.handleInput(data);
1089
+ // Everything else edits the query like a regular single-line editor:
1090
+ // cursor movement, word ops, kill ring, undo, paste.
1091
+ this.#searchInput.handleInput(data);
1092
+ const value = this.#searchInput.getValue();
1093
+ if (value !== this.#searchQuery) this.#setSearchQuery(value);
1052
1094
  }
1053
1095
  }
@@ -0,0 +1,11 @@
1
+ [User]: Fix the settings overlay crash. Wheeling past the last row throws.
2
+
3
+ [Assistant tool calls]: read(path="src/select-list.ts:140-180")
4
+
5
+ [Tool result]: 162: const index = Math.floor(line / rowHeight); index is never checked against bounds.
6
+
7
+ [Assistant]: Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash.
8
+
9
+ [User]: Does the fix survive filtering?
10
+
11
+ [Assistant]: Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Live preview for the `snapcompact.shape` setting: renders a sample session
3
+ * transcript through the real snapcompact rasterizer as a miniature page and
4
+ * shows it zoomed, so cell size, ink hues, highlight bands, and dim tool-result
5
+ * spans are legible at terminal scale.
6
+ *
7
+ * The mini-frame (a {@link SRC_FRAME_PX}px page) is upscaled with
8
+ * nearest-neighbor so the glyph pixels stay crisp when the terminal scales the
9
+ * placement box. Graphics display requires the Kitty unicode-placeholder path —
10
+ * `renderImage` returning `lines` is the gate — because the bordered settings
11
+ * frame re-fits every row, which direct cursor-positioned placements (iTerm2,
12
+ * Sixel, Kitty `a=p`) do not survive. Everything else falls back to the stats
13
+ * line plus a dim notice.
14
+ */
15
+ import { type Component, type ImageBudget, renderImage, TERMINAL } from "@oh-my-pi/pi-tui";
16
+ import {
17
+ DIM_OFF,
18
+ DIM_ON,
19
+ geometry,
20
+ isShapeVariantName,
21
+ normalize,
22
+ renderMany,
23
+ resolveShape,
24
+ SHAPE_VARIANT_NAMES,
25
+ SHAPE_VARIANTS,
26
+ type Shape,
27
+ type ShapeTarget,
28
+ type ShapeVariantName,
29
+ } from "@oh-my-pi/snapcompact";
30
+ import { theme } from "../theme/theme";
31
+ import sampleDoc from "./snapcompact-shape-preview-doc.md" with { type: "text" };
32
+
33
+ /** Mini-frame edge in px — a small page from the real rasterizer ≈ a zoomed crop. */
34
+ const SRC_FRAME_PX = 128;
35
+ /** Nearest-neighbor upscale factor; keeps glyph pixels crisp on HiDPI cell boxes. */
36
+ const ZOOM_SCALE = 4;
37
+ /** Display box in terminal cells (square-ish at the typical 1:2 cell aspect). */
38
+ const MAX_IMAGE_COLS = 28;
39
+ const MAX_IMAGE_ROWS = 14;
40
+
41
+ /** Sample transcript with `[Tool result]:` bodies wrapped in dim-ink toggles. */
42
+ const PREVIEW_TEXT = sampleDoc
43
+ .trim()
44
+ .replace(/\[Tool result\]: ([^[]*)/g, (_match, body: string) => `[Tool result]: ${DIM_ON}${body}${DIM_OFF}`);
45
+
46
+ type PreviewEntry =
47
+ | { state: "rendering" }
48
+ | { state: "failed" }
49
+ | { state: "ready"; data: string; edgePx: number; imageId: number; transmitted: boolean };
50
+
51
+ export interface SnapcompactShapePreviewOptions {
52
+ /** Active model (api + id); resolves what `auto` maps to for this reader. */
53
+ model?: ShapeTarget;
54
+ /** Shared TUI image budget: stable graphics ids, transmit-once, exit cleanup. */
55
+ imageBudget?: ImageBudget;
56
+ /** Schedules a re-render once an async sample render completes. */
57
+ requestRender?: () => void;
58
+ }
59
+
60
+ export class SnapcompactShapePreview implements Component {
61
+ #model: ShapeTarget | undefined;
62
+ #budget: ImageBudget | undefined;
63
+ #requestRender: () => void;
64
+ #variant: ShapeVariantName | "auto" = "auto";
65
+ #entries = new Map<ShapeVariantName, PreviewEntry>();
66
+
67
+ constructor(currentValue: string, options: SnapcompactShapePreviewOptions = {}) {
68
+ this.#model = options.model;
69
+ this.#budget = options.imageBudget;
70
+ this.#requestRender = options.requestRender ?? (() => {});
71
+ this.setValue(currentValue);
72
+ }
73
+
74
+ /** Track the highlighted option; the next render reflects it. */
75
+ setValue(value: string): void {
76
+ this.#variant = isShapeVariantName(value) ? value : "auto";
77
+ }
78
+
79
+ render(width: number): readonly string[] {
80
+ const shape = resolveShape(this.#model, this.#variant);
81
+ const name = resolvedVariantName(shape);
82
+ const geo = geometry(shape);
83
+ const label = this.#variant === "auto" ? `auto → ${name}` : name;
84
+ const chars = geo.capacity >= 1000 ? `${(geo.capacity / 1000).toFixed(1)}k` : String(geo.capacity);
85
+ const tokens =
86
+ shape.frameTokenEstimate >= 1000
87
+ ? `${(shape.frameTokenEstimate / 1000).toFixed(1)}k`
88
+ : String(shape.frameTokenEstimate);
89
+ const stats = `full frame ${geo.cols}×${geo.rows} cells ≈ ${chars} chars ≈ ${tokens} tokens`;
90
+ const lines: string[] = [theme.fg("muted", ` Sample (zoomed) · ${label} · ${stats}`), ""];
91
+
92
+ if (!this.#budget || !TERMINAL.imageProtocol) {
93
+ lines.push(theme.fg("dim", " (graphic sample needs a Kitty-graphics terminal)"));
94
+ return lines;
95
+ }
96
+
97
+ const entry = this.#ensureEntry(name, shape);
98
+ if (entry.state === "rendering") {
99
+ lines.push(theme.fg("dim", " rendering sample…"));
100
+ return lines;
101
+ }
102
+ if (entry.state === "failed") {
103
+ lines.push(theme.fg("dim", " (sample render failed)"));
104
+ return lines;
105
+ }
106
+
107
+ const result = renderImage(
108
+ entry.data,
109
+ { widthPx: entry.edgePx, heightPx: entry.edgePx },
110
+ {
111
+ maxWidthCells: Math.max(8, Math.min(MAX_IMAGE_COLS, width - 4)),
112
+ maxHeightCells: MAX_IMAGE_ROWS,
113
+ imageId: entry.imageId,
114
+ includeTransmit: !entry.transmitted,
115
+ },
116
+ );
117
+ // Only the unicode-placeholder path returns text-cell `lines`; cursor-moving
118
+ // placements would corrupt the bordered settings frame, so skip them.
119
+ if (!result?.lines) {
120
+ lines.push(theme.fg("dim", " (graphic sample needs Kitty unicode-placeholder graphics)"));
121
+ return lines;
122
+ }
123
+ if (result.transmit) {
124
+ this.#budget.enqueueTransmit(entry.imageId, result.transmit);
125
+ entry.transmitted = true;
126
+ }
127
+ for (const line of result.lines) {
128
+ lines.push(` ${line}`);
129
+ }
130
+ return lines;
131
+ }
132
+
133
+ #ensureEntry(name: ShapeVariantName, shape: Shape): PreviewEntry {
134
+ let entry = this.#entries.get(name);
135
+ if (!entry) {
136
+ entry = { state: "rendering" };
137
+ this.#entries.set(name, entry);
138
+ void this.#buildEntry(name, shape);
139
+ }
140
+ return entry;
141
+ }
142
+
143
+ async #buildEntry(name: ShapeVariantName, shape: Shape): Promise<void> {
144
+ try {
145
+ // Fill the mini-page so every variant shows a fully inked window.
146
+ const capacity = geometry(shape, SRC_FRAME_PX).capacity;
147
+ let text = PREVIEW_TEXT;
148
+ while (normalize(text).length < capacity) {
149
+ text += ` ${PREVIEW_TEXT}`;
150
+ }
151
+ const frame = renderMany(text, { shape, frameSize: SRC_FRAME_PX, maxFrames: 1 })[0];
152
+ if (!frame) throw new Error("empty sample frame");
153
+ const edgePx = SRC_FRAME_PX * ZOOM_SCALE;
154
+ const zoomed = await new Bun.Image(Buffer.from(frame.data, "base64"))
155
+ .resize(edgePx, edgePx, { filter: "nearest" })
156
+ .png()
157
+ .bytes();
158
+ this.#entries.set(name, {
159
+ state: "ready",
160
+ data: zoomed.toBase64(),
161
+ edgePx,
162
+ // Keyed id: reopening settings reuses the id, so data already in the
163
+ // terminal store is never re-transmitted (enqueueTransmit no-ops).
164
+ imageId: this.#budget?.acquireId(`snapshape:${name}:${edgePx}`) ?? 0,
165
+ transmitted: false,
166
+ });
167
+ } catch {
168
+ this.#entries.set(name, { state: "failed" });
169
+ }
170
+ this.#requestRender();
171
+ }
172
+ }
173
+
174
+ /** Research name of the concrete geometry `resolveShape` picked (for `auto`). */
175
+ function resolvedVariantName(shape: Shape): ShapeVariantName {
176
+ for (const name of SHAPE_VARIANT_NAMES) {
177
+ const candidate = SHAPE_VARIANTS[name];
178
+ if (
179
+ candidate.font === shape.font &&
180
+ candidate.cellWidth === shape.cellWidth &&
181
+ candidate.cellHeight === shape.cellHeight &&
182
+ candidate.variant === shape.variant &&
183
+ candidate.lineRepeat === shape.lineRepeat &&
184
+ candidate.frameSize === shape.frameSize
185
+ ) {
186
+ return name;
187
+ }
188
+ }
189
+ // resolveShape only hands out table geometries; the legacy shape is the
190
+ // conservative label if that invariant ever changes.
191
+ return "5x8-sent";
192
+ }
@@ -589,6 +589,32 @@ export class ToolExecutionComponent extends Container {
589
589
  return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
590
590
  }
591
591
 
592
+ /**
593
+ * Whether this still-live block's settled rows may enter native scrollback
594
+ * (see `FinalizableBlock.isTranscriptBlockCommitStable`). Classification is
595
+ * per renderer (`ToolRenderer.provisionalPendingPreview`): tail-window
596
+ * streaming views (edit's streamed-diff tail, bash/ssh command caps, eval
597
+ * cells) are re-anchored top-first by the result render, so promoting
598
+ * their visually static head — e.g. an edit preview idling on its last
599
+ * frame while the apply + LSP pass runs — would strand a stale copy of
600
+ * the call box above the final block the moment the result lands. Every
601
+ * other pending preview streams top-anchored append-shaped rows the
602
+ * result render preserves (a task call's context/assignment markdown, a
603
+ * write's content), so it stays commit-eligible — a call taller than the
604
+ * viewport scrolls into native history mid-stream instead of reading as
605
+ * cut off until the result. Expanded blocks always stream top-anchored
606
+ * (the over-tall write/eval scrollback contract). Displaceable waiting
607
+ * polls are removed wholesale by the next poll and must never commit.
608
+ */
609
+ isTranscriptBlockCommitStable(): boolean {
610
+ if (this.#displaceable) return false;
611
+ if (this.#expanded || this.isTranscriptBlockFinalized()) return true;
612
+ if ((this.#tool as { provisionalPendingPreview?: boolean } | undefined)?.provisionalPendingPreview) {
613
+ return false;
614
+ }
615
+ return !toolRenderers[this.#toolName]?.provisionalPendingPreview;
616
+ }
617
+
592
618
  /**
593
619
  * Mark the tool terminal even though no result arrived (the turn aborted or
594
620
  * abandoned it) and stop animating, so it can freeze and stops pinning the
@@ -63,6 +63,19 @@ interface FinalizableBlock {
63
63
  * never mutate post-finalize simply omit the method.
64
64
  */
65
65
  getTranscriptBlockVersion?(): number;
66
+ /**
67
+ * Whether a still-live block's visually settled leading rows are durable —
68
+ * guaranteed to survive the block's remaining transitions (finalize,
69
+ * displacement) byte-stable — and may therefore be promoted as commit-safe
70
+ * by {@link deriveLiveCommitState}. Blocks whose pending render is
71
+ * provisional (a tool call's tail-window streaming preview, replaced
72
+ * wholesale by the result render) return `false`: committing such rows
73
+ * strands a stale copy in immutable terminal history the moment the real
74
+ * content re-lays-out the block (the engine audit recommits below it —
75
+ * "duplication, never loss"). Absent = `true`, the default for blocks
76
+ * whose live rows persist (a streaming assistant message).
77
+ */
78
+ isTranscriptBlockCommitStable?(): boolean;
66
79
  }
67
80
 
68
81
  function isBlockFinalized(child: Component): boolean {
@@ -75,6 +88,11 @@ function getBlockVersion(child: Component): number | undefined {
75
88
  return fn ? fn.call(child) : undefined;
76
89
  }
77
90
 
91
+ function isBlockCommitStable(child: Component): boolean {
92
+ const fn = (child as Component & FinalizableBlock).isTranscriptBlockCommitStable;
93
+ return fn ? fn.call(child) : true;
94
+ }
95
+
78
96
  // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
79
97
  // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
80
98
  // to a background-colored padding row, whose escape sequences contain `\S` and
@@ -598,7 +616,11 @@ export class TranscriptContainer
598
616
  previous.generation === this.#generation);
599
617
  const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
600
618
  let liveCommitState: LiveCommitState | undefined;
601
- if (i >= liveStartIndex && !finalized) {
619
+ // Provisional live renders (commit-unstable blocks) never feed the
620
+ // promotion machinery: their settled-looking rows are replaced
621
+ // wholesale on finalize, so offering them would commit a stale
622
+ // preview the result render can only duplicate, never erase.
623
+ if (i >= liveStartIndex && !finalized && isBlockCommitStable(child)) {
602
624
  liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
603
625
  }
604
626
  // Cache the latest contribution as the next frame's diff input.
@@ -456,7 +456,7 @@ export class CommandController {
456
456
  }
457
457
 
458
458
  handleContextCommand(): void {
459
- const breakdown = computeContextBreakdown(this.ctx.session);
459
+ const breakdown = computeContextBreakdown(this.ctx.session, { snapcompactSavings: true });
460
460
  if (breakdown.contextWindow <= 0) {
461
461
  this.ctx.showWarning("Context usage is unavailable: no model is selected for this session.");
462
462
  return;
@@ -1528,6 +1528,29 @@ function renderUsageReports(
1528
1528
  lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`);
1529
1529
  }
1530
1530
 
1531
+ const resetAccountLines: string[] = [];
1532
+ for (const report of providerReports) {
1533
+ const count = report.resetCredits?.availableCount ?? 0;
1534
+ if (count <= 0) continue;
1535
+ const label =
1536
+ (report.metadata?.email as string | undefined) ??
1537
+ (report.metadata?.accountId as string | undefined) ??
1538
+ "account";
1539
+ const isActive =
1540
+ !!activeAccount &&
1541
+ ((!!activeAccount.accountId && activeAccount.accountId === report.metadata?.accountId) ||
1542
+ (!!activeAccount.email && activeAccount.email === report.metadata?.email));
1543
+ resetAccountLines.push(
1544
+ ` • ${label}: ${count} saved reset${count === 1 ? "" : "s"}${isActive ? " (active)" : ""}`,
1545
+ );
1546
+ }
1547
+ if (resetAccountLines.length > 0) {
1548
+ lines.push(
1549
+ ` ${uiTheme.fg("accent", "Saved rate-limit resets")} ${uiTheme.fg("dim", "(/usage reset to spend)")}`,
1550
+ );
1551
+ for (const line of resetAccountLines) lines.push(uiTheme.fg("dim", line));
1552
+ }
1553
+
1531
1554
  const renderableGroups = Array.from(limitGroups.values()).map(group => {
1532
1555
  const entries = group.limits.map((limit, index) => ({
1533
1556
  limit,
@@ -4,6 +4,7 @@ import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
4
4
  import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils";
5
5
  import { getRoleInfo } from "../../config/model-roles";
6
6
  import { isSettingsInitialized, settings } from "../../config/settings";
7
+ import { AssistantMessageComponent } from "../../modes/components/assistant-message";
7
8
  import { renderSegmentTrack } from "../../modes/components/segment-track";
8
9
  import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
9
10
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
@@ -1039,18 +1040,19 @@ export class InputController {
1039
1040
 
1040
1041
  toggleThinkingBlockVisibility(): void {
1041
1042
  this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock;
1042
- settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock);
1043
+ this.ctx.settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock);
1043
1044
  this.ctx.session.agent.hideThinkingSummary = this.ctx.hideThinkingBlock;
1044
1045
 
1045
- // Rebuild chat from session messages
1046
- this.ctx.chatContainer.clear();
1047
- this.ctx.rebuildChatFromMessages();
1046
+ for (const child of this.ctx.chatContainer.children) {
1047
+ if (child instanceof AssistantMessageComponent) {
1048
+ child.setHideThinkingBlock(this.ctx.hideThinkingBlock);
1049
+ child.invalidate();
1050
+ }
1051
+ }
1048
1052
 
1049
- // If streaming, re-add the streaming component with updated visibility and re-render
1050
1053
  if (this.ctx.streamingComponent && this.ctx.streamingMessage) {
1051
1054
  this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.hideThinkingBlock);
1052
1055
  this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
1053
- this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
1054
1056
  }
1055
1057
 
1056
1058
  this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`);