@oh-my-pi/pi-coding-agent 16.1.6 → 16.1.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/cli.js +3160 -3139
  3. package/dist/types/cli/session-picker.d.ts +0 -1
  4. package/dist/types/cli/tiny-models-cli.d.ts +2 -0
  5. package/dist/types/collab/protocol.d.ts +20 -1
  6. package/dist/types/config/models-config-schema.d.ts +10 -0
  7. package/dist/types/config/models-config.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +57 -6
  9. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +5 -1
  10. package/dist/types/internal-urls/filesystem-resource.d.ts +9 -0
  11. package/dist/types/mcp/loader.d.ts +3 -2
  12. package/dist/types/mcp/manager.d.ts +4 -3
  13. package/dist/types/mcp/startup-events.d.ts +21 -4
  14. package/dist/types/mnemopi/config.d.ts +1 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +1 -0
  16. package/dist/types/modes/components/agent-transcript-viewer.d.ts +1 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  18. package/dist/types/modes/components/btw-panel.d.ts +2 -0
  19. package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -0
  20. package/dist/types/modes/components/session-selector.d.ts +0 -2
  21. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/controllers/streaming-reveal.d.ts +3 -2
  24. package/dist/types/modes/interactive-mode.d.ts +3 -0
  25. package/dist/types/modes/types.d.ts +3 -0
  26. package/dist/types/session/agent-session.d.ts +5 -3
  27. package/dist/types/session/session-history-format.d.ts +25 -0
  28. package/dist/types/session/snapcompact-inline.d.ts +1 -1
  29. package/dist/types/slash-commands/builtin-registry.d.ts +7 -4
  30. package/dist/types/slash-commands/types.d.ts +2 -0
  31. package/dist/types/system-prompt.d.ts +1 -0
  32. package/dist/types/tiny/models.d.ts +11 -7
  33. package/dist/types/tools/todo.d.ts +1 -0
  34. package/dist/types/utils/thinking-display.d.ts +2 -0
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +104 -0
  37. package/src/advisor/runtime.ts +38 -2
  38. package/src/cli/session-picker.ts +1 -2
  39. package/src/cli/tiny-models-cli.ts +7 -2
  40. package/src/collab/guest.ts +172 -20
  41. package/src/collab/host.ts +47 -5
  42. package/src/collab/protocol.ts +16 -1
  43. package/src/config/models-config-schema.ts +1 -0
  44. package/src/config/settings-schema.ts +59 -5
  45. package/src/edit/renderer.ts +8 -12
  46. package/src/extensibility/legacy-pi-ai-shim.ts +16 -4
  47. package/src/internal-urls/docs-index.generated.txt +1 -1
  48. package/src/internal-urls/filesystem-resource.ts +34 -0
  49. package/src/internal-urls/local-protocol.ts +7 -1
  50. package/src/internal-urls/memory-protocol.ts +5 -1
  51. package/src/internal-urls/skill-protocol.ts +20 -4
  52. package/src/internal-urls/vault-protocol.ts +5 -2
  53. package/src/main.ts +8 -8
  54. package/src/mcp/loader.ts +4 -3
  55. package/src/mcp/manager.ts +35 -15
  56. package/src/mcp/startup-events.ts +106 -11
  57. package/src/mnemopi/config.ts +2 -0
  58. package/src/mnemopi/state.ts +3 -1
  59. package/src/modes/components/agent-hub.ts +4 -0
  60. package/src/modes/components/agent-transcript-viewer.ts +2 -0
  61. package/src/modes/components/assistant-message.ts +217 -18
  62. package/src/modes/components/btw-panel.ts +15 -3
  63. package/src/modes/components/chat-transcript-builder.ts +8 -2
  64. package/src/modes/components/model-selector.ts +72 -9
  65. package/src/modes/components/omfg-panel.ts +1 -1
  66. package/src/modes/components/session-selector.ts +4 -9
  67. package/src/modes/components/snapcompact-shape-preview.ts +1 -1
  68. package/src/modes/components/tool-execution.ts +10 -2
  69. package/src/modes/controllers/btw-controller.ts +32 -7
  70. package/src/modes/controllers/event-controller.ts +24 -4
  71. package/src/modes/controllers/input-controller.ts +43 -21
  72. package/src/modes/controllers/selector-controller.ts +23 -13
  73. package/src/modes/controllers/streaming-reveal.ts +78 -20
  74. package/src/modes/controllers/todo-command-controller.ts +9 -10
  75. package/src/modes/interactive-mode.ts +83 -8
  76. package/src/modes/types.ts +3 -0
  77. package/src/modes/utils/ui-helpers.ts +1 -0
  78. package/src/prompts/advisor/system.md +1 -1
  79. package/src/prompts/system/side-channel-no-tools.md +3 -0
  80. package/src/prompts/system/system-prompt.md +164 -156
  81. package/src/sdk.ts +12 -8
  82. package/src/session/agent-session.ts +349 -84
  83. package/src/session/history-storage.ts +15 -16
  84. package/src/session/session-history-format.ts +41 -1
  85. package/src/session/snapcompact-inline.ts +9 -6
  86. package/src/slash-commands/builtin-registry.ts +147 -21
  87. package/src/slash-commands/helpers/todo.ts +12 -6
  88. package/src/slash-commands/types.ts +2 -0
  89. package/src/system-prompt.ts +6 -11
  90. package/src/tiny/models.ts +7 -3
  91. package/src/tiny/title-client.ts +8 -2
  92. package/src/tiny/worker.ts +1 -0
  93. package/src/tools/search.ts +10 -1
  94. package/src/tools/sqlite-reader.ts +59 -3
  95. package/src/tools/todo.ts +6 -0
  96. package/src/tools/write.ts +4 -6
  97. package/src/utils/thinking-display.ts +78 -0
@@ -1,10 +1,12 @@
1
1
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
2
2
  import { Container, Image, type ImageBudget, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
3
+ import { formatNumber } from "@oh-my-pi/pi-utils";
4
+ import chalk from "chalk";
3
5
  import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
4
6
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
5
7
  import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages";
6
8
  import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
7
- import { canonicalizeMessage } from "../../utils/thinking-display";
9
+ import { canonicalizeMessage, formatThinkingForDisplay, hasDisplayableThinking } from "../../utils/thinking-display";
8
10
  import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker";
9
11
 
10
12
  /**
@@ -28,6 +30,18 @@ const MARKDOWN_TABLE_DELIMITER = /^ {0,3}\|?(?:[ \t]*:?-+:?[ \t]*\|)+[ \t]*:?-*:
28
30
  /** Opening or closing fence of a code block: ≥3 backticks/tildes plus info string. */
29
31
  const CODE_FENCE_LINE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
30
32
 
33
+ type ThinkingContentBlock = Extract<AssistantMessage["content"][number], { type: "thinking" }>;
34
+ type DisplayThinkingContentBlock = ThinkingContentBlock & { rawThinking?: string };
35
+
36
+ function resolveThinkingDisplay(block: ThinkingContentBlock, proseOnly: boolean): { text: string; visible: boolean } {
37
+ const rawThinking = (block as DisplayThinkingContentBlock).rawThinking ?? block.thinking;
38
+ const formatted = formatThinkingForDisplay(block.thinking, proseOnly);
39
+ return {
40
+ text: formatted.trim(),
41
+ visible: hasDisplayableThinking(rawThinking, formatted),
42
+ };
43
+ }
44
+
31
45
  /**
32
46
  * Whether `text` currently contains reflowing Markdown whose layout is not yet
33
47
  * permanent: an open ` ```mermaid ` fence (the diagram reshapes as source
@@ -72,11 +86,96 @@ function detectLiveReflowingMarkdown(text: string): boolean {
72
86
  /**
73
87
  * Frames for the streaming "thinking" pulse rendered in place of a hidden
74
88
  * thinking block while the model is still producing it. A single fixed-width
75
- * glyph that rises ▁▃▄▃ so the indicator animates without shifting the line.
76
- * Advanced every {@link THINKING_DOTS_FRAME_MS}.
89
+ * starburst cycles through facets (✻ ✸ ✶) so the indicator animates
90
+ * in place without shifting the line or the trailing speed badge. The dwell per
91
+ * frame eases between {@link THINKING_DOTS_FRAME_MS_MIN} and
92
+ * {@link THINKING_DOTS_FRAME_MS_MAX} across each revolution (see
93
+ * {@link AssistantMessageComponent.thinkingDotsFrameDelay}).
77
94
  */
78
- const THINKING_DOTS_FRAMES = ["", "", "", ""] as const;
79
- const THINKING_DOTS_FRAME_MS = 320;
95
+ const THINKING_DOTS_FRAMES = ["", "", "", "", "✺", "✹", "✸", "✶"] as const;
96
+ /**
97
+ * Pulse cadence bounds (ms). Each frame's dwell eases between these on a
98
+ * raised-cosine "breath" — quickest at the cycle start, slowest at its midpoint —
99
+ * so the starburst accelerates and slows instead of ticking at one fixed rate.
100
+ * Mean ≈ 150ms, snappier than the previous flat 320ms.
101
+ */
102
+ const THINKING_DOTS_FRAME_MS_MIN = 70;
103
+ const THINKING_DOTS_FRAME_MS_MAX = 230;
104
+
105
+ /** Rolling window (ms) over which streaming-rate observations are averaged. */
106
+ const SPEED_WINDOW_MS = 3000;
107
+ /** Color/clamp ceiling: a rate at or above this maps to the full accent color. */
108
+ const SPEED_MAX = 200;
109
+
110
+ /**
111
+ * Session-wide streaming-speed gauge. Only one thinking indicator animates at a
112
+ * time, so a single shared instance accumulates instantaneous tok/s observations
113
+ * and reports their windowed average — smoothing the jumpy per-delta numbers.
114
+ * Each thinking block resets the gauge on its first live sample (see
115
+ * {@link AssistantMessageComponent.updateContent}) so the average reflects only
116
+ * the active block, never a previous turn's trailing rate. Components feed it
117
+ * deltas (not cumulative totals), so a fresh turn restarting its token count at
118
+ * zero never produces a spike.
119
+ */
120
+ class SpeedTracker {
121
+ #observations: Array<{ time: number; rate: number }> = [];
122
+
123
+ #prune(now: number): void {
124
+ const threshold = now - SPEED_WINDOW_MS;
125
+ while (this.#observations.length > 0 && this.#observations[0]!.time < threshold) {
126
+ this.#observations.shift();
127
+ }
128
+ }
129
+
130
+ /** Record one instantaneous tok/s reading, clamped to {@link SPEED_MAX} so a
131
+ * single oversized delta (e.g. a buffered reflow tick) can't poison the
132
+ * windowed average. Non-finite/negative rates ignored. */
133
+ observe(rate: number, now = performance.now()): void {
134
+ if (!Number.isFinite(rate) || rate < 0) return;
135
+ this.#observations.push({ time: now, rate: Math.min(rate, SPEED_MAX) });
136
+ this.#prune(now);
137
+ }
138
+
139
+ /** Windowed-average tok/s; 0 once observations age out of the window. */
140
+ getSpeed(now = performance.now()): number {
141
+ this.#prune(now);
142
+ if (this.#observations.length === 0) return 0;
143
+ let sum = 0;
144
+ for (const o of this.#observations) sum += o.rate;
145
+ return sum / this.#observations.length;
146
+ }
147
+
148
+ reset(): void {
149
+ this.#observations = [];
150
+ }
151
+ }
152
+
153
+ /** One gauge for the whole session — see {@link SpeedTracker}. */
154
+ const sharedSpeedTracker = new SpeedTracker();
155
+
156
+ /** Test-only: clear the shared gauge so observations don't leak across cases. */
157
+ export function resetThinkingSpeedTracker(): void {
158
+ sharedSpeedTracker.reset();
159
+ }
160
+
161
+ /**
162
+ * Linear-interpolate two `#rrggbb` colors in sRGB space. `t` clamps to [0,1]:
163
+ * `t = 0` → `from`, `t = 1` → `to`. Drives the streaming speed badge, fading
164
+ * from a dim gray toward the theme accent as tok/s rises.
165
+ */
166
+ function lerpHex(from: string, to: string, t: number): string {
167
+ const k = t < 0 ? 0 : t > 1 ? 1 : t;
168
+ const fr = Number.parseInt(from.slice(1, 3), 16);
169
+ const fg = Number.parseInt(from.slice(3, 5), 16);
170
+ const fb = Number.parseInt(from.slice(5, 7), 16);
171
+ const tr = Number.parseInt(to.slice(1, 3), 16);
172
+ const tg = Number.parseInt(to.slice(3, 5), 16);
173
+ const tb = Number.parseInt(to.slice(5, 7), 16);
174
+ const r = Math.round(fr + (tr - fr) * k);
175
+ const g = Math.round(fg + (tg - fg) * k);
176
+ const b = Math.round(fb + (tb - fb) * k);
177
+ return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
178
+ }
80
179
 
81
180
  /**
82
181
  * Component that renders a complete assistant message
@@ -128,6 +227,20 @@ export class AssistantMessageComponent extends Container {
128
227
  #thinkingDots: Text | undefined;
129
228
  #thinkingDotsTimer: NodeJS.Timeout | undefined;
130
229
  #thinkingDotsFrame = 0;
230
+ /** Previous cumulative provider token count + timestamp, for deriving this
231
+ * block's instantaneous streaming rate fed into {@link sharedSpeedTracker}.
232
+ * Undefined until the first thinking update of this block. */
233
+ #lastTokenCount: number | undefined;
234
+ #lastTokenTime = 0;
235
+ /** Provider-reported tokens in the live thinking block — reasoning tokens when
236
+ * the provider streams them, else total output — shown dimmed beside the
237
+ * speed badge. 0 when no thinking is streaming. */
238
+ #thinkingTokens = 0;
239
+ /** Whether this block has observed a positive provider-token delta — i.e. it is
240
+ * genuinely streaming tokens right now. Gates the numeric speed badge so the
241
+ * session-wide {@link sharedSpeedTracker} can't surface a previous turn's rate
242
+ * on a fresh block that has no live token throughput of its own. */
243
+ #thinkingRateLive = false;
131
244
 
132
245
  constructor(
133
246
  message?: AssistantMessage,
@@ -135,6 +248,7 @@ export class AssistantMessageComponent extends Container {
135
248
  private readonly onImageUpdate?: () => void,
136
249
  private readonly thinkingRenderers: readonly AssistantThinkingRenderer[] = [],
137
250
  private readonly imageBudget?: ImageBudget,
251
+ private proseOnlyThinking = true,
138
252
  ) {
139
253
  super();
140
254
  this.#transcriptBlockFinalized = message !== undefined;
@@ -184,6 +298,10 @@ export class AssistantMessageComponent extends Container {
184
298
  this.hideThinkingBlock = hide;
185
299
  }
186
300
 
301
+ setProseOnlyThinking(proseOnly: boolean): void {
302
+ this.proseOnlyThinking = proseOnly;
303
+ }
304
+
187
305
  override dispose(): void {
188
306
  this.#stopThinkingAnimation();
189
307
  super.dispose();
@@ -210,16 +328,51 @@ export class AssistantMessageComponent extends Container {
210
328
 
211
329
  #thinkingDotsLabel(): string {
212
330
  const glyph = THINKING_DOTS_FRAMES[this.#thinkingDotsFrame % THINKING_DOTS_FRAMES.length] ?? "…";
213
- return theme.fg("thinkingText", glyph);
331
+ const coloredGlyph = theme.fg("thinkingText", glyph);
332
+ const rate = Math.min(SPEED_MAX, sharedSpeedTracker.getSpeed());
333
+ // The numeric badge ("<total> · <rate> toks/s") only renders while this block
334
+ // is genuinely streaming provider tokens. A block that has observed no token
335
+ // delta (e.g. a provider that reports usage only at turn end) or whose rate
336
+ // has decayed to zero (a streaming lull) drops it entirely — the bare pulse
337
+ // keeps signalling that the model is thinking. The liveness flag also stops
338
+ // the session-wide gauge from leaking a previous turn's rate onto a fresh
339
+ // token-less block.
340
+ if (!this.#thinkingRateLive || rate < 0.05) return coloredGlyph;
341
+ // Total provider tokens, dimmed, sit next to the pulse.
342
+ const totalSpan = this.#thinkingTokens > 0 ? theme.fg("dim", ` ${formatNumber(this.#thinkingTokens)}`) : "";
343
+ // Speed badge color: dim gray at rest, brightening toward the theme accent as
344
+ // streaming speed climbs (gray → bright accent). Ease (sqrt) so typical
345
+ // mid-stream rates already read as clearly accent-tinted instead of staying
346
+ // gray until the rarely-hit SPEED_MAX ceiling.
347
+ const ratio = Math.sqrt(rate / SPEED_MAX);
348
+ const hex = lerpHex(theme.getColorHex("dim"), theme.getAccentColorHex(), ratio);
349
+ const rateText = ` · ${rate.toFixed(1)} toks/s`;
350
+ const rateSpan = theme.getColorMode() === "truecolor" ? chalk.hex(hex)(rateText) : theme.fg("muted", rateText);
351
+ return coloredGlyph + totalSpan + rateSpan;
214
352
  }
215
353
 
216
354
  #startThinkingAnimation(): void {
217
355
  if (this.#thinkingDotsTimer) return;
218
- this.#thinkingDotsTimer = setInterval(() => this.#advanceThinkingDots(), THINKING_DOTS_FRAME_MS);
356
+ this.#scheduleThinkingFrame();
357
+ }
358
+
359
+ /** Eased dwell (ms) for the current pulse frame: a raised cosine over the
360
+ * 8-frame cycle, continuous across the wrap, so the rotation breathes rather
361
+ * than advancing at a fixed interval. */
362
+ #thinkingDotsFrameDelay(): number {
363
+ const phase = (1 - Math.cos((2 * Math.PI * this.#thinkingDotsFrame) / THINKING_DOTS_FRAMES.length)) / 2;
364
+ return THINKING_DOTS_FRAME_MS_MIN + (THINKING_DOTS_FRAME_MS_MAX - THINKING_DOTS_FRAME_MS_MIN) * phase;
365
+ }
366
+
367
+ /** Self-rescheduling timeout (not a fixed interval) so each frame can pick its
368
+ * own eased dwell. */
369
+ #scheduleThinkingFrame(): void {
370
+ this.#thinkingDotsTimer = setTimeout(() => this.#advanceThinkingDots(), this.#thinkingDotsFrameDelay());
219
371
  this.#thinkingDotsTimer.unref?.();
220
372
  }
221
373
 
222
374
  #advanceThinkingDots(): void {
375
+ this.#thinkingDotsTimer = undefined;
223
376
  if (!this.#thinkingDots) {
224
377
  this.#stopThinkingAnimation();
225
378
  return;
@@ -228,11 +381,12 @@ export class AssistantMessageComponent extends Container {
228
381
  if (this.#thinkingDots.setText(this.#thinkingDotsLabel())) {
229
382
  this.onImageUpdate?.();
230
383
  }
384
+ this.#scheduleThinkingFrame();
231
385
  }
232
386
 
233
387
  #stopThinkingAnimation(): void {
234
388
  if (this.#thinkingDotsTimer) {
235
- clearInterval(this.#thinkingDotsTimer);
389
+ clearTimeout(this.#thinkingDotsTimer);
236
390
  this.#thinkingDotsTimer = undefined;
237
391
  }
238
392
  this.#thinkingDotsFrame = 0;
@@ -403,13 +557,13 @@ export class AssistantMessageComponent extends Container {
403
557
  }
404
558
 
405
559
  #computeShapeKey(message: AssistantMessage): string {
406
- const parts: string[] = [`htb:${this.hideThinkingBlock ? 1 : 0}`];
560
+ const parts: string[] = [`htb:${this.hideThinkingBlock ? 1 : 0}|pot:${this.proseOnlyThinking ? 1 : 0}`];
407
561
  for (const content of message.content) {
408
562
  if (content.type === "text") {
409
563
  parts.push(canonicalizeMessage(content.text) ? "T1" : "T0");
410
564
  } else if (content.type === "thinking") {
411
- const canon = canonicalizeMessage(content.thinking);
412
- if (!canon) parts.push("K0");
565
+ const display = resolveThinkingDisplay(content, this.proseOnlyThinking);
566
+ if (!display.visible) parts.push("K0");
413
567
  else if (this.hideThinkingBlock) parts.push("KH");
414
568
  else parts.push("KV");
415
569
  } else {
@@ -442,8 +596,10 @@ export class AssistantMessageComponent extends Container {
442
596
  for (const item of this.#fastPathItems) {
443
597
  if (item.blockType === "thinking") {
444
598
  const content = message.content[item.contentIndex];
445
- if (content?.type === "thinking" && canonicalizeMessage(content.thinking) !== item.lastText)
446
- return false;
599
+ if (content?.type === "thinking") {
600
+ const display = resolveThinkingDisplay(content, this.proseOnlyThinking);
601
+ if (display.text !== item.lastText) return false;
602
+ }
447
603
  }
448
604
  }
449
605
  }
@@ -476,7 +632,7 @@ export class AssistantMessageComponent extends Container {
476
632
  if (item.blockType === "text" && content.type === "text") {
477
633
  newText = content.text.trim();
478
634
  } else if (item.blockType === "thinking" && content.type === "thinking") {
479
- newText = canonicalizeMessage(content.thinking);
635
+ newText = resolveThinkingDisplay(content, this.proseOnlyThinking).text;
480
636
  } else {
481
637
  this.#fastPathKey = undefined;
482
638
  this.#fastPathItems = undefined;
@@ -487,6 +643,11 @@ export class AssistantMessageComponent extends Container {
487
643
  item.lastText = newText;
488
644
  }
489
645
  }
646
+ if (this.#thinkingDots) {
647
+ if (this.#thinkingDots.setText(this.#thinkingDotsLabel())) {
648
+ this.onImageUpdate?.();
649
+ }
650
+ }
490
651
  return true;
491
652
  }
492
653
 
@@ -495,6 +656,42 @@ export class AssistantMessageComponent extends Container {
495
656
  this.#lastMessage = message;
496
657
  this.#lastUpdateTransient = opts?.transient === true;
497
658
 
659
+ // Streaming-speed gauge: only a live, in-flight render of the single
660
+ // animating hidden-thinking block feeds the shared session tracker. The
661
+ // token count is the provider's own cumulative output — reasoning tokens when
662
+ // reported (Gemini's thoughtsTokenCount, OpenAI's reasoning_tokens), else
663
+ // total output tokens — never a character estimate, which undercounts when
664
+ // the provider streams a summarized reasoning trace. An instantaneous tok/s
665
+ // is derived from this block's delta and handed to the windowed averager.
666
+ // Only transient renders count: the final non-transient render at
667
+ // message_end carries the turn's end-of-stream usage, whose jump would spike
668
+ // the gauge and pollute the next block. Providers that report usage only at
669
+ // turn end leave the live count flat, so the rate stays 0 and the badge
670
+ // self-suppresses (see #thinkingDotsLabel).
671
+ const isThinkingNow = this.#lastUpdateTransient && this.#shouldAnimateThinking(message);
672
+ if (isThinkingNow) {
673
+ const currentTokens = message.usage.reasoningTokens ?? message.usage.output;
674
+ this.#thinkingTokens = currentTokens;
675
+ const now = performance.now();
676
+ if (this.#lastTokenCount !== undefined) {
677
+ const tokenDelta = currentTokens - this.#lastTokenCount;
678
+ const elapsedMs = now - this.#lastTokenTime;
679
+ if (tokenDelta > 0 && elapsedMs > 0) {
680
+ // First live sample of this block: drop the session gauge's prior-turn
681
+ // observations so the windowed average reflects only this block.
682
+ if (!this.#thinkingRateLive) sharedSpeedTracker.reset();
683
+ sharedSpeedTracker.observe((tokenDelta / elapsedMs) * 1000, now);
684
+ this.#thinkingRateLive = true;
685
+ }
686
+ }
687
+ this.#lastTokenCount = currentTokens;
688
+ this.#lastTokenTime = now;
689
+ } else {
690
+ this.#lastTokenCount = undefined;
691
+ this.#thinkingTokens = 0;
692
+ this.#thinkingRateLive = false;
693
+ }
694
+
498
695
  // Streaming reflowing Markdown (a mermaid diagram reshaping, a GFM table
499
696
  // re-aligning columns) re-lays-out its body each frame; see
500
697
  // isTranscriptBlockCommitStable. Detect it from raw text — a Markdown
@@ -520,7 +717,9 @@ export class AssistantMessageComponent extends Container {
520
717
  const hasVisibleContent = message.content.some(
521
718
  c =>
522
719
  (c.type === "text" && canonicalizeMessage(c.text)) ||
523
- (!this.hideThinkingBlock && c.type === "thinking" && canonicalizeMessage(c.thinking)),
720
+ (!this.hideThinkingBlock &&
721
+ c.type === "thinking" &&
722
+ resolveThinkingDisplay(c, this.proseOnlyThinking).visible),
524
723
  );
525
724
 
526
725
  // Render content in order
@@ -534,8 +733,8 @@ export class AssistantMessageComponent extends Container {
534
733
  md.transientRenderCache = this.#lastUpdateTransient;
535
734
  this.#contentContainer.addChild(md);
536
735
  captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
537
- } else if (content.type === "thinking" && canonicalizeMessage(content.thinking)) {
538
- const thinkingText = canonicalizeMessage(content.thinking);
736
+ } else if (content.type === "thinking" && resolveThinkingDisplay(content, this.proseOnlyThinking).visible) {
737
+ const thinkingText = resolveThinkingDisplay(content, this.proseOnlyThinking).text;
539
738
  if (this.hideThinkingBlock) {
540
739
  thinkingIndex += 1;
541
740
  continue;
@@ -547,7 +746,7 @@ export class AssistantMessageComponent extends Container {
547
746
  .some(
548
747
  c =>
549
748
  (c.type === "text" && canonicalizeMessage(c.text)) ||
550
- (c.type === "thinking" && canonicalizeMessage(c.thinking)),
749
+ (c.type === "thinking" && resolveThinkingDisplay(c, this.proseOnlyThinking).visible),
551
750
  );
552
751
 
553
752
  // Thinking traces in thinkingText color, italic
@@ -16,6 +16,7 @@ export class BtwPanelComponent extends Container {
16
16
  #state: BtwPanelState = "running";
17
17
  #answer = "";
18
18
  #errorMessage: string | undefined;
19
+ #visibleAnswer = "";
19
20
  #closed = false;
20
21
 
21
22
  constructor(options: BtwPanelComponentOptions) {
@@ -28,12 +29,14 @@ export class BtwPanelComponent extends Container {
28
29
  appendText(delta: string): void {
29
30
  if (!delta || this.#closed) return;
30
31
  this.#answer += delta;
32
+ this.#visibleAnswer = replaceTabs(this.#answer).trim();
31
33
  this.#rebuild();
32
34
  }
33
35
 
34
36
  setAnswer(text: string): void {
35
37
  if (this.#closed) return;
36
38
  this.#answer = text;
39
+ this.#visibleAnswer = replaceTabs(text).trim();
37
40
  this.#rebuild();
38
41
  }
39
42
 
@@ -59,7 +62,16 @@ export class BtwPanelComponent extends Container {
59
62
  }
60
63
 
61
64
  isBranchable(): boolean {
62
- return this.#state === "complete" && this.#answer.trim().length > 0;
65
+ return this.isCopyable();
66
+ }
67
+
68
+ isCopyable(): boolean {
69
+ return this.#state === "complete" && this.#visibleAnswer.length > 0;
70
+ }
71
+
72
+ getCopyText(): string | undefined {
73
+ if (!this.isCopyable()) return undefined;
74
+ return this.#visibleAnswer;
63
75
  }
64
76
 
65
77
  close(): void {
@@ -89,7 +101,7 @@ export class BtwPanelComponent extends Container {
89
101
  case "running":
90
102
  return theme.fg("muted", "Esc cancel /btw");
91
103
  case "complete":
92
- return theme.fg("muted", this.isBranchable() ? "b branch · Esc dismiss" : "Esc dismiss");
104
+ return theme.fg("muted", this.isCopyable() ? "c copy · b branch to chat · Esc dismiss" : "Esc dismiss");
93
105
  case "aborted":
94
106
  return theme.fg("warning", `${theme.status.warning} Cancelled · Esc dismiss`);
95
107
  case "error":
@@ -101,7 +113,7 @@ export class BtwPanelComponent extends Container {
101
113
  if (this.#state === "error") {
102
114
  return new Text(theme.fg("error", replaceTabs(this.#errorMessage ?? "Unknown error")), 1, 0);
103
115
  }
104
- const text = replaceTabs(this.#answer).trim();
116
+ const text = this.#visibleAnswer;
105
117
  if (!text) {
106
118
  const waiting =
107
119
  this.#state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned.";
@@ -59,6 +59,7 @@ export interface ChatTranscriptBuilderDeps {
59
59
  getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
60
60
  cwd: string;
61
61
  hideThinkingBlock?: () => boolean;
62
+ proseOnlyThinking?: () => boolean;
62
63
  requestRender: () => void;
63
64
  }
64
65
 
@@ -247,8 +248,13 @@ export class ChatTranscriptBuilder {
247
248
  }
248
249
 
249
250
  #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
250
- const assistantComponent = new AssistantMessageComponent(message, this.deps.hideThinkingBlock?.() ?? false, () =>
251
- this.deps.requestRender(),
251
+ const assistantComponent = new AssistantMessageComponent(
252
+ message,
253
+ this.deps.hideThinkingBlock?.() ?? false,
254
+ () => this.deps.requestRender(),
255
+ this.deps.getMessageRenderer ? undefined : [], // placeholder for thinkingRenderers
256
+ undefined, // placeholder for imageBudget
257
+ this.deps.proseOnlyThinking ? this.deps.proseOnlyThinking() : true,
252
258
  );
253
259
  this.container.addChild(assistantComponent);
254
260
 
@@ -144,6 +144,9 @@ function formatProviderTabLabel(providerId: string): string {
144
144
  function createProviderTab(providerId: string): ProviderTabState {
145
145
  return { id: providerId, label: formatProviderTabLabel(providerId), providerId };
146
146
  }
147
+ const TEMPORARY_MODEL_PICKER_HINT =
148
+ "Temporary model selection is session-only. Use Alt+M or /model for role models (default/smol/plan/task/slow/custom roles).";
149
+
147
150
  /**
148
151
  * Component that renders a model selector with provider tabs and context menu.
149
152
  * - Tab/Arrow Left/Right: Switch between provider tabs
@@ -230,6 +233,10 @@ export class ModelSelectorComponent extends Container {
230
233
  : "Only showing models with configured API keys (see README for details)";
231
234
  this.addChild(new Text(theme.fg("warning", hintText), 0, 0));
232
235
  this.addChild(new Spacer(1));
236
+ if (this.#temporaryOnly) {
237
+ this.addChild(new Text(theme.fg("muted", TEMPORARY_MODEL_PICKER_HINT), 0, 0));
238
+ this.addChild(new Spacer(1));
239
+ }
233
240
 
234
241
  // Create header container for tab bar
235
242
  this.#headerContainer = new Container();
@@ -698,11 +705,23 @@ export class ModelSelectorComponent extends Container {
698
705
  return this.#getActiveTabId() === CANONICAL_TAB;
699
706
  }
700
707
 
701
- #isModelOverContextLimit(model: Model): boolean {
708
+ #isModelOverCurrentContext(model: Model): boolean {
702
709
  const contextWindow = model.contextWindow ?? 0;
703
710
  return this.#currentContextTokens > 0 && contextWindow > 0 && this.#currentContextTokens > contextWindow;
704
711
  }
705
712
 
713
+ #isModelOverContextLimit(model: Model): boolean {
714
+ return this.#temporaryOnly && this.#isModelOverCurrentContext(model);
715
+ }
716
+
717
+ #isDefaultRoleActionOverContextLimit(action: MenuRoleAction, model: Model): boolean {
718
+ return action.role === "default" && this.#isModelOverCurrentContext(model);
719
+ }
720
+
721
+ #formatCurrentContextLimitSuffix(model: Model): string {
722
+ return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow ?? 0).toLowerCase()}`;
723
+ }
724
+
706
725
  #isItemDisabled(item: ModelItem | CanonicalModelItem): boolean {
707
726
  return this.#isModelOverContextLimit(item.model);
708
727
  }
@@ -711,7 +730,7 @@ export class ModelSelectorComponent extends Container {
711
730
  if (!this.#isModelOverContextLimit(model)) {
712
731
  return "";
713
732
  }
714
- return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow ?? 0).toLowerCase()}`;
733
+ return this.#formatCurrentContextLimitSuffix(model);
715
734
  }
716
735
 
717
736
  #getVisibleItems(): ReadonlyArray<ModelItem | CanonicalModelItem> {
@@ -1046,6 +1065,50 @@ export class ModelSelectorComponent extends Container {
1046
1065
  : this.#filteredModels[this.#selectedIndex];
1047
1066
  }
1048
1067
 
1068
+ #isMenuRoleIndexDisabled(index: number, selectedItem: ModelItem | CanonicalModelItem): boolean {
1069
+ const action = this.#menuRoleActions[index];
1070
+ return action ? this.#isDefaultRoleActionOverContextLimit(action, selectedItem.model) : false;
1071
+ }
1072
+
1073
+ #coerceMenuSelectedIndex(index: number, selectedItem: ModelItem | CanonicalModelItem): number {
1074
+ const maxIndex = this.#menuRoleActions.length - 1;
1075
+ if (maxIndex < 0) {
1076
+ return 0;
1077
+ }
1078
+ const clamped = Math.max(0, Math.min(index, maxIndex));
1079
+ if (!this.#isMenuRoleIndexDisabled(clamped, selectedItem)) {
1080
+ return clamped;
1081
+ }
1082
+ for (let i = clamped + 1; i <= maxIndex; i++) {
1083
+ if (!this.#isMenuRoleIndexDisabled(i, selectedItem)) {
1084
+ return i;
1085
+ }
1086
+ }
1087
+ for (let i = clamped - 1; i >= 0; i--) {
1088
+ if (!this.#isMenuRoleIndexDisabled(i, selectedItem)) {
1089
+ return i;
1090
+ }
1091
+ }
1092
+ return clamped;
1093
+ }
1094
+
1095
+ #moveMenuSelection(delta: number, selectedItem: ModelItem | CanonicalModelItem, optionCount: number): void {
1096
+ let index = this.#menuSelectedIndex;
1097
+ for (let step = 0; step < optionCount; step++) {
1098
+ index = (index + delta + optionCount) % optionCount;
1099
+ if (this.#menuStep !== "role" || !this.#isMenuRoleIndexDisabled(index, selectedItem)) {
1100
+ this.#menuSelectedIndex = index;
1101
+ this.#updateMenu();
1102
+ return;
1103
+ }
1104
+ }
1105
+ this.#menuSelectedIndex =
1106
+ this.#menuStep === "role"
1107
+ ? this.#coerceMenuSelectedIndex(this.#menuSelectedIndex, selectedItem)
1108
+ : this.#menuSelectedIndex;
1109
+ this.#updateMenu();
1110
+ }
1111
+
1049
1112
  #openMenu(): void {
1050
1113
  const selectedItem = this.#getSelectedItem();
1051
1114
  if (!selectedItem || this.#isItemDisabled(selectedItem)) return;
@@ -1053,7 +1116,7 @@ export class ModelSelectorComponent extends Container {
1053
1116
  this.#isMenuOpen = true;
1054
1117
  this.#menuStep = "role";
1055
1118
  this.#menuSelectedRole = null;
1056
- this.#menuSelectedIndex = 0;
1119
+ this.#menuSelectedIndex = this.#coerceMenuSelectedIndex(0, selectedItem);
1057
1120
  // Collapse the model list while the action/thinking menu is open so the
1058
1121
  // menu owns the full viewport instead of stacking below a now-irrelevant
1059
1122
  // (and often off-screen) list.
@@ -1086,7 +1149,9 @@ export class ModelSelectorComponent extends Container {
1086
1149
  })
1087
1150
  : this.#menuRoleActions.map((action, index) => {
1088
1151
  const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " ";
1089
- return `${prefix}${action.label}`;
1152
+ const disabled = this.#isDefaultRoleActionOverContextLimit(action, selectedItem.model);
1153
+ const suffix = disabled ? this.#formatCurrentContextLimitSuffix(selectedItem.model) : "";
1154
+ return `${prefix}${action.label}${suffix}`;
1090
1155
  });
1091
1156
 
1092
1157
  const selectedRoleName = this.#menuSelectedRole ? getRoleInfo(this.#menuSelectedRole, this.#settings).name : "";
@@ -1224,21 +1289,19 @@ export class ModelSelectorComponent extends Container {
1224
1289
  if (optionCount === 0) return;
1225
1290
 
1226
1291
  if (matchesSelectUp(keyData)) {
1227
- this.#menuSelectedIndex = (this.#menuSelectedIndex - 1 + optionCount) % optionCount;
1228
- this.#updateMenu();
1292
+ this.#moveMenuSelection(-1, selectedItem, optionCount);
1229
1293
  return;
1230
1294
  }
1231
1295
 
1232
1296
  if (matchesSelectDown(keyData)) {
1233
- this.#menuSelectedIndex = (this.#menuSelectedIndex + 1) % optionCount;
1234
- this.#updateMenu();
1297
+ this.#moveMenuSelection(1, selectedItem, optionCount);
1235
1298
  return;
1236
1299
  }
1237
1300
 
1238
1301
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1239
1302
  if (this.#menuStep === "role") {
1240
1303
  const action = this.#menuRoleActions[this.#menuSelectedIndex];
1241
- if (!action) return;
1304
+ if (!action || this.#isDefaultRoleActionOverContextLimit(action, selectedItem.model)) return;
1242
1305
  this.#menuSelectedRole = action.role;
1243
1306
  this.#menuStep = "thinking";
1244
1307
  this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model);
@@ -117,7 +117,7 @@ export class OmfgPanelComponent extends Container {
117
117
  case "saved":
118
118
  return theme.fg(
119
119
  "success",
120
- `${theme.status.success} Registered live · ${replaceTabs(this.#savedPath ?? "saved")}`,
120
+ `${theme.status.success} Registered live · ${replaceTabs(this.#savedPath ?? "saved")} · Esc dismiss`,
121
121
  );
122
122
  case "rejected":
123
123
  return theme.fg("warning", `${theme.status.warning} Not saved · Esc dismiss`);
@@ -454,8 +454,6 @@ export interface SessionSelectorOptions {
454
454
  loadAllSessions?: () => Promise<SessionInfo[]>;
455
455
  /** Preloaded all-projects list; cached so the first Tab toggle is instant. */
456
456
  allSessions?: SessionInfo[];
457
- /** Open directly in all-projects scope (e.g. the current folder has no sessions). */
458
- startInAllScope?: boolean;
459
457
  /**
460
458
  * Reads the live terminal height so the visible window fits the viewport.
461
459
  * Omitted only in tests; defaults to a conservative 24 rows.
@@ -493,11 +491,6 @@ export class SessionSelectorComponent extends Container {
493
491
  this.#loadAllSessions = options.loadAllSessions;
494
492
  this.#folderSessions = sessions;
495
493
  this.#globalSessions = options.allSessions ?? null;
496
- // Open in all-projects scope when asked and we already have that list
497
- // (e.g. the current folder has no sessions to show).
498
- const startAll = options.startInAllScope === true && this.#globalSessions !== null;
499
- this.#scope = startAll ? "all" : "folder";
500
- const initialSessions = startAll ? this.#globalSessions! : sessions;
501
494
  // Add header
502
495
  this.addChild(new Spacer(1));
503
496
  this.#headerText = new Text(this.#headerLabel(), 1, 0);
@@ -506,8 +499,10 @@ export class SessionSelectorComponent extends Container {
506
499
  this.addChild(new DynamicBorder());
507
500
  this.addChild(new Spacer(1));
508
501
  this.addChild(this.#messageContainer);
509
- // Create session list
510
- this.#sessionList = new SessionList(initialSessions, startAll, options.historyMatcher, options.getTerminalRows);
502
+ // Create session list in folder scope; the empty-state hint invites the
503
+ // user to Tab into all-projects rather than silently surfacing other
504
+ // projects' history (issue #3099).
505
+ this.#sessionList = new SessionList(sessions, false, options.historyMatcher, options.getTerminalRows);
511
506
  this.#sessionList.onSelect = onSelect;
512
507
  this.#sessionList.onCancel = onCancel;
513
508
  this.#sessionList.onExit = onExit;
@@ -148,7 +148,7 @@ export class SnapcompactShapePreview implements Component {
148
148
  while (normalize(text).length < capacity) {
149
149
  text += ` ${PREVIEW_TEXT}`;
150
150
  }
151
- const frame = renderMany(text, { shape, frameSize: SRC_FRAME_PX, maxFrames: 1 })[0];
151
+ const frame = (await renderMany(text, { shape, frameSize: SRC_FRAME_PX, maxFrames: 1 }))[0];
152
152
  if (!frame) throw new Error("empty sample frame");
153
153
  const edgePx = SRC_FRAME_PX * ZOOM_SCALE;
154
154
  const zoomed = await new Bun.Image(Buffer.from(frame.data, "base64"))