@oh-my-pi/pi-coding-agent 15.11.6 → 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 (56) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/dist/cli.js +114 -71
  3. package/dist/types/cli/bench-cli.d.ts +78 -0
  4. package/dist/types/commands/bench.d.ts +29 -0
  5. package/dist/types/config/model-resolver.d.ts +3 -2
  6. package/dist/types/config/settings-schema.d.ts +72 -0
  7. package/dist/types/edit/renderer.d.ts +1 -0
  8. package/dist/types/modes/components/oauth-selector.d.ts +10 -1
  9. package/dist/types/modes/components/settings-selector.d.ts +8 -1
  10. package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
  11. package/dist/types/modes/components/tool-execution.d.ts +13 -9
  12. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
  13. package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
  14. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
  15. package/dist/types/session/snapcompact-inline.d.ts +2 -0
  16. package/dist/types/tools/bash.d.ts +2 -0
  17. package/dist/types/tools/eval-render.d.ts +1 -0
  18. package/dist/types/tools/renderers.d.ts +13 -0
  19. package/dist/types/tools/ssh.d.ts +1 -0
  20. package/package.json +11 -11
  21. package/src/cli/bench-cli.ts +437 -0
  22. package/src/cli-commands.ts +1 -0
  23. package/src/commands/bench.ts +42 -0
  24. package/src/config/model-registry.ts +52 -5
  25. package/src/config/model-resolver.ts +36 -5
  26. package/src/config/settings-schema.ts +92 -0
  27. package/src/edit/renderer.ts +5 -0
  28. package/src/hindsight/client.ts +26 -1
  29. package/src/hindsight/state.ts +6 -2
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/mcp/transports/stdio.ts +81 -7
  32. package/src/modes/components/oauth-selector.ts +67 -7
  33. package/src/modes/components/settings-selector.ts +27 -0
  34. package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
  35. package/src/modes/components/snapcompact-shape-preview.ts +192 -0
  36. package/src/modes/components/tool-execution.ts +18 -10
  37. package/src/modes/controllers/input-controller.ts +8 -6
  38. package/src/modes/controllers/selector-controller.ts +4 -2
  39. package/src/modes/interactive-mode.ts +24 -0
  40. package/src/modes/setup-wizard/index.ts +1 -0
  41. package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
  42. package/src/modes/setup-wizard/scenes/providers.ts +36 -2
  43. package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
  44. package/src/modes/setup-wizard/scenes/theme.ts +28 -1
  45. package/src/modes/setup-wizard/scenes/types.ts +10 -1
  46. package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
  47. package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
  48. package/src/modes/utils/context-usage.ts +1 -1
  49. package/src/prompts/bench.md +7 -0
  50. package/src/sdk.ts +1 -0
  51. package/src/session/agent-session.ts +5 -0
  52. package/src/session/snapcompact-inline.ts +11 -19
  53. package/src/tools/bash.ts +3 -0
  54. package/src/tools/eval-render.ts +4 -0
  55. package/src/tools/renderers.ts +13 -0
  56. package/src/tools/ssh.ts +3 -0
@@ -6,6 +6,7 @@ import {
6
6
  fuzzyFilter,
7
7
  matchesKey,
8
8
  ScrollView,
9
+ type SgrMouseEvent,
9
10
  Spacer,
10
11
  TruncatedText,
11
12
  } from "@oh-my-pi/pi-tui";
@@ -16,6 +17,12 @@ import { DynamicBorder } from "./dynamic-border";
16
17
 
17
18
  const OAUTH_SELECTOR_MAX_VISIBLE = 10;
18
19
 
20
+ /**
21
+ * Rendered lines before the provider rows: top border, spacer, title, spacer
22
+ * (must mirror the constructor's addChild order).
23
+ */
24
+ const LIST_ROW_OFFSET = 4;
25
+
19
26
  /** Compact, human-readable tag for each credential-origin leg. */
20
27
  const ORIGIN_LABELS: Record<CredentialOriginKind, string> = {
21
28
  runtime: "--api-key",
@@ -34,6 +41,10 @@ export class OAuthSelectorComponent extends Container {
34
41
  #filteredProviders: OAuthProviderInfo[] = [];
35
42
  #searchQuery = "";
36
43
  #selectedIndex: number = 0;
44
+ #hoveredIndex: number | null = null;
45
+ /** First provider index of the visible ScrollView window (last #updateList). */
46
+ #scrollStart = 0;
47
+ #visibleCount = 0;
37
48
  #mode: "login" | "logout";
38
49
  #authStorage: AuthStorage;
39
50
  #onSelectCallback: (providerId: string) => void;
@@ -252,6 +263,8 @@ export class OAuthSelectorComponent extends Container {
252
263
  ? 0
253
264
  : Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
254
265
  const endIndex = Math.min(startIndex + maxVisible, total);
266
+ this.#scrollStart = startIndex;
267
+ this.#visibleCount = endIndex - startIndex;
255
268
 
256
269
  const rows: string[] = [];
257
270
  for (let i = startIndex; i < endIndex; i++) {
@@ -270,6 +283,9 @@ export class OAuthSelectorComponent extends Container {
270
283
  const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
271
284
  line = text + statusIndicator;
272
285
  }
286
+ if (!isSelected && i === this.#hoveredIndex) {
287
+ line = theme.bg("selectedBg", line);
288
+ }
273
289
  rows.push(line);
274
290
  }
275
291
 
@@ -354,15 +370,59 @@ export class OAuthSelectorComponent extends Container {
354
370
  }
355
371
  // Enter
356
372
  else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
357
- const selectedProvider = this.#filteredProviders[this.#selectedIndex];
358
- if (selectedProvider?.available) {
359
- this.#statusMessage = undefined;
360
- this.stopValidation();
361
- this.#onSelectCallback(selectedProvider.id);
362
- } else if (selectedProvider) {
363
- this.#statusMessage = "Provider unavailable in this environment.";
373
+ this.#confirmSelection();
374
+ }
375
+ }
376
+
377
+ /** Confirm the selected provider (Enter or mouse click). */
378
+ #confirmSelection(): void {
379
+ const selectedProvider = this.#filteredProviders[this.#selectedIndex];
380
+ if (selectedProvider?.available) {
381
+ this.#statusMessage = undefined;
382
+ this.stopValidation();
383
+ this.#onSelectCallback(selectedProvider.id);
384
+ } else if (selectedProvider) {
385
+ this.#statusMessage = "Provider unavailable in this environment.";
386
+ this.#updateList();
387
+ }
388
+ }
389
+
390
+ /** Move the selection one step for a wheel notch (clamped, no wrap). */
391
+ handleWheel(delta: -1 | 1): void {
392
+ if (this.#filteredProviders.length === 0) return;
393
+ const next = Math.max(0, Math.min(this.#selectedIndex + delta, this.#filteredProviders.length - 1));
394
+ if (next === this.#selectedIndex) return;
395
+ this.#selectedIndex = next;
396
+ this.#statusMessage = undefined;
397
+ this.#updateList();
398
+ }
399
+
400
+ /**
401
+ * Route an SGR mouse report at component-local coordinates. Provider rows
402
+ * start LIST_ROW_OFFSET lines into the render; the ScrollView window shows
403
+ * #visibleCount rows from #scrollStart. Wheel moves the selection, motion
404
+ * drives the hover band, and a left click selects and confirms like Enter.
405
+ */
406
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
407
+ if (event.wheel !== null) {
408
+ this.handleWheel(event.wheel);
409
+ return;
410
+ }
411
+ const localRow = line - LIST_ROW_OFFSET;
412
+ const index = localRow >= 0 && localRow < this.#visibleCount ? this.#scrollStart + localRow : undefined;
413
+ const target = index !== undefined && index < this.#filteredProviders.length ? index : null;
414
+ if (event.motion) {
415
+ if (target !== this.#hoveredIndex) {
416
+ this.#hoveredIndex = target;
364
417
  this.#updateList();
365
418
  }
419
+ return;
420
+ }
421
+ if (!event.leftClick || target === null) return;
422
+ if (target !== this.#selectedIndex) {
423
+ this.#selectedIndex = target;
424
+ this.#statusMessage = undefined;
366
425
  }
426
+ this.#confirmSelection();
367
427
  }
368
428
  }
@@ -7,6 +7,7 @@ import {
7
7
  fuzzyRank,
8
8
  getKeybindings,
9
9
  getSettingItemFilterText,
10
+ type ImageBudget,
10
11
  Input,
11
12
  matchesKey,
12
13
  parseSgrMouse,
@@ -22,6 +23,7 @@ import {
22
23
  truncateToWidth,
23
24
  visibleWidth,
24
25
  } from "@oh-my-pi/pi-tui";
26
+ import type { ShapeTarget } from "@oh-my-pi/snapcompact";
25
27
  import { getDefault, type SettingPath, settings } from "../../config/settings";
26
28
  import type {
27
29
  SettingTab,
@@ -36,6 +38,7 @@ import { getTabBarTheme } from "../shared";
36
38
  import { bottomBorder, divider, row, topBorder } from "./overlay-box";
37
39
  import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings";
38
40
  import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs";
41
+ import { SnapcompactShapePreview } from "./snapcompact-shape-preview";
39
42
  import { getPreset } from "./status-line/presets";
40
43
 
41
44
  /**
@@ -97,6 +100,7 @@ class SelectSubmenu extends Container {
97
100
  onCancel: () => void,
98
101
  onSelectionChange?: (value: string) => void | Promise<void>,
99
102
  private readonly getPreview?: () => string,
103
+ footer?: Component,
100
104
  ) {
101
105
  super();
102
106
 
@@ -158,6 +162,13 @@ class SelectSubmenu extends Container {
158
162
  // Hint
159
163
  this.addChild(new Spacer(1));
160
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
+ }
161
172
  }
162
173
 
163
174
  #updatePreview(): void {
@@ -249,6 +260,12 @@ export interface SettingsRuntimeContext {
249
260
  availableThemes: string[];
250
261
  /** Working directory for plugins tab */
251
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;
252
269
  }
253
270
 
254
271
  /** Status line settings subset for preview */
@@ -758,6 +775,7 @@ export class SettingsSelectorComponent implements Component {
758
775
  // Preview handlers
759
776
  let onPreview: ((value: string) => void | Promise<void>) | undefined;
760
777
  let onPreviewCancel: (() => void) | undefined;
778
+ let footer: Component | undefined;
761
779
 
762
780
  const activeThemeBeforePreview = getCurrentThemeName() ?? currentValue;
763
781
  if (def.path === "theme.dark" || def.path === "theme.light") {
@@ -797,6 +815,14 @@ export class SettingsSelectorComponent implements Component {
797
815
  const separator = settings.get("statusLine.separator");
798
816
  this.callbacks.onStatusLinePreview?.({ separator });
799
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;
800
826
  }
801
827
 
802
828
  // Provide status line preview for theme selection
@@ -819,6 +845,7 @@ export class SettingsSelectorComponent implements Component {
819
845
  },
820
846
  onPreview,
821
847
  getPreview,
848
+ footer,
822
849
  );
823
850
  }
824
851
 
@@ -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
+ }
@@ -591,20 +591,28 @@ export class ToolExecutionComponent extends Container {
591
591
 
592
592
  /**
593
593
  * Whether this still-live block's settled rows may enter native scrollback
594
- * (see `FinalizableBlock.isTranscriptBlockCommitStable`). A pending
595
- * collapsed preview is provisional: the tail-window streaming views
596
- * (edit/bash/eval caps) are re-anchored top-first by the result render, so
597
- * promoting their visually static head e.g. an edit preview idling on
598
- * its last frame while the apply + LSP pass runs would strand a stale
599
- * copy of the call box above the final block the moment the result lands.
600
- * Expanded pending blocks stream top-anchored append-shaped content whose
601
- * rows the result render preserves byte-stable (the over-tall write/eval
602
- * scrollback contract), so they stay commit-eligible. Displaceable waiting
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
603
607
  * polls are removed wholesale by the next poll and must never commit.
604
608
  */
605
609
  isTranscriptBlockCommitStable(): boolean {
606
610
  if (this.#displaceable) return false;
607
- return this.#expanded || this.isTranscriptBlockFinalized();
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;
608
616
  }
609
617
 
610
618
  /**
@@ -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"}`);
@@ -114,6 +114,9 @@ export class SelectorController {
114
114
  thinkingLevel: this.ctx.session.thinkingLevel,
115
115
  availableThemes,
116
116
  cwd: getProjectDir(),
117
+ model: this.ctx.session.model,
118
+ imageBudget: this.ctx.ui.imageBudget,
119
+ requestRender: () => this.ctx.ui.requestRender(),
117
120
  },
118
121
  {
119
122
  onChange: (id, value) => this.handleSettingChange(id, value),
@@ -313,10 +316,9 @@ export class SelectorController {
313
316
  for (const child of this.ctx.chatContainer.children) {
314
317
  if (child instanceof AssistantMessageComponent) {
315
318
  child.setHideThinkingBlock(value as boolean);
319
+ child.invalidate();
316
320
  }
317
321
  }
318
- this.ctx.chatContainer.clear();
319
- this.ctx.rebuildChatFromMessages();
320
322
  break;
321
323
  case "theme": {
322
324
  setTheme(value as string, true).then(result => {
@@ -1162,6 +1162,30 @@ export class InteractiveMode implements InteractiveModeContext {
1162
1162
  // of restarting the visible conversation (the LLM context still resets).
1163
1163
  const context = this.session.buildTranscriptSessionContext();
1164
1164
  this.renderSessionContext(context);
1165
+ // During the pre-streaming window — after `startPendingSubmission` has
1166
+ // optimistically rendered the user's message but before the user
1167
+ // `message_start` event lands it in `session` entries — any rebuild
1168
+ // (e.g. Ctrl+T toggleThinkingBlockVisibility, theme selector) would
1169
+ // otherwise erase the user's just-submitted message until the first
1170
+ // assistant token arrived (#2372). Once `message_start` fires the
1171
+ // signature is cleared by `EventController`, so this replay is a no-op
1172
+ // post-streaming and cannot duplicate.
1173
+ this.#replayOptimisticUserMessage();
1174
+ }
1175
+
1176
+ #replayOptimisticUserMessage(): void {
1177
+ if (!this.optimisticUserMessageSignature) return;
1178
+ const submission = this.#pendingSubmittedInput;
1179
+ if (!submission || submission.cancelled || submission.customType) return;
1180
+ this.addMessageToChat(
1181
+ {
1182
+ role: "user",
1183
+ content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1184
+ attribution: "user",
1185
+ timestamp: Date.now(),
1186
+ },
1187
+ { imageLinks: submission.imageLinks },
1188
+ );
1165
1189
  }
1166
1190
 
1167
1191
  #formatTodoLine(todo: TodoItem, prefix: string, matched: boolean): string {
@@ -82,6 +82,7 @@ export async function runSetupWizard(
82
82
  maxHeight: "100%",
83
83
  anchor: "top-left",
84
84
  margin: 0,
85
+ fullscreen: true,
85
86
  });
86
87
  try {
87
88
  await component.run();
@@ -1,4 +1,4 @@
1
- import { type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
1
+ import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
2
2
  import { getSelectListTheme, type SymbolPreset, setSymbolPreset, theme } from "../../theme/theme";
3
3
  import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
4
4
 
@@ -29,6 +29,8 @@ class GlyphSceneController implements SetupSceneController {
29
29
  #selectList: SelectList;
30
30
  #previewRequest = 0;
31
31
  #committing = false;
32
+ /** Render line where the select list begins. */
33
+ #listRowStart = 0;
32
34
 
33
35
  constructor(private readonly host: SetupSceneHost) {
34
36
  this.#selectList = new SelectList(GLYPH_ITEMS, GLYPH_ITEMS.length, getSelectListTheme());
@@ -60,12 +62,28 @@ class GlyphSceneController implements SetupSceneController {
60
62
  this.#selectList.handleInput(data);
61
63
  }
62
64
 
65
+ /** Wheel moves the highlight (live preview); hover lights the row under the pointer; click confirms it. */
66
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
67
+ if (this.#committing) return;
68
+ if (event.wheel !== null) {
69
+ this.#selectList.handleWheel(event.wheel);
70
+ return;
71
+ }
72
+ const index = this.#selectList.hitTest(line - this.#listRowStart);
73
+ if (event.motion) {
74
+ this.#selectList.setHoverIndex(index ?? null);
75
+ return;
76
+ }
77
+ if (event.leftClick && index !== undefined) {
78
+ this.#selectList.clickItem(index);
79
+ }
80
+ }
81
+
63
82
  render(width: number): readonly string[] {
64
- return [
65
- theme.fg("muted", "If a row shows boxes, tofu, or misaligned icons, pick another."),
66
- "",
67
- ...this.#selectList.render(width),
68
- ];
83
+ const lines = [theme.fg("muted", "If a row shows boxes, tofu, or misaligned icons, pick another."), ""];
84
+ this.#listRowStart = lines.length;
85
+ lines.push(...this.#selectList.render(width));
86
+ return lines;
69
87
  }
70
88
 
71
89
  async #commit(preset: SymbolPreset): Promise<void> {
@@ -1,4 +1,4 @@
1
- import { TabBar } from "@oh-my-pi/pi-tui";
1
+ import { type SgrMouseEvent, TabBar } from "@oh-my-pi/pi-tui";
2
2
  import { getTabBarTheme } from "../../shared";
3
3
  import { SignInTab } from "./sign-in";
4
4
  import type { SetupScene, SetupSceneController, SetupSceneHost, SetupTab } from "./types";
@@ -16,6 +16,8 @@ class ProvidersSceneController implements SetupSceneController {
16
16
 
17
17
  #tabs: SetupTab[];
18
18
  #tabBar: TabBar;
19
+ /** Lines the tab bar occupied in the last render (body starts one blank line below). */
20
+ #tabRowCount = 1;
19
21
 
20
22
  constructor(host: SetupSceneHost) {
21
23
  this.#tabs = [new SignInTab(host), new WebSearchTab(host)];
@@ -52,8 +54,40 @@ class ProvidersSceneController implements SetupSceneController {
52
54
  tab.handleInput(data);
53
55
  }
54
56
 
57
+ /**
58
+ * Hit-test mouse reports against the last render: rows inside the tab bar
59
+ * hover/switch tabs (suppressed while the active panel is modal, matching
60
+ * keyboard tab cycling); everything else forwards to the active panel at
61
+ * panel-local coordinates. Wheel always goes to the panel so scrolling
62
+ * works regardless of pointer position.
63
+ */
64
+ routeMouse(event: SgrMouseEvent, line: number, col: number): void {
65
+ const tab = this.#activeTab();
66
+ if (event.wheel === null && line >= 0 && line < this.#tabRowCount) {
67
+ if (tab.modal) return;
68
+ const hit = this.#tabBar.tabAt(line, col);
69
+ if (event.motion) {
70
+ this.#tabBar.setHoverTab(hit && !hit.muted ? hit.id : null);
71
+ } else if (event.leftClick && hit) {
72
+ this.#tabBar.selectTab(hit.id);
73
+ }
74
+ return;
75
+ }
76
+ if (event.motion) this.#tabBar.setHoverTab(null);
77
+ const bodyLine = line - this.#tabRowCount - 1;
78
+ if (tab.routeMouse) {
79
+ tab.routeMouse(event, bodyLine, col);
80
+ return;
81
+ }
82
+ if (event.wheel !== null && !tab.modal) {
83
+ tab.handleInput(event.wheel === -1 ? "\x1b[A" : "\x1b[B");
84
+ }
85
+ }
86
+
55
87
  render(width: number): readonly string[] {
56
- return [...this.#tabBar.render(width), "", ...this.#activeTab().render(width)];
88
+ const tabLines = this.#tabBar.render(width);
89
+ this.#tabRowCount = tabLines.length;
90
+ return [...tabLines, "", ...this.#activeTab().render(width)];
57
91
  }
58
92
 
59
93
  dispose(): void {