@oh-my-pi/pi-coding-agent 15.10.9 → 15.10.10

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.
@@ -263,21 +263,26 @@ export class ModelSelectorComponent extends Container {
263
263
  // Add bottom border
264
264
  this.addChild(new DynamicBorder());
265
265
 
266
- // Load models and do initial render
267
- this.#loadModels().then(() => {
268
- this.#buildProviderTabs();
269
- this.#updateTabBar();
270
- // Always apply the current search query — the user may have typed
271
- // while models were loading asynchronously.
272
- const currentQuery = this.#searchInput.getValue();
273
- if (currentQuery) {
274
- this.#filterModels(currentQuery);
275
- } else {
276
- this.#updateList();
277
- }
278
- // Request re-render after models are loaded
279
- this.#tui.requestRender();
280
- });
266
+ // Hydrate synchronously from the current registry snapshot so the first
267
+ // Enter after opening the selector acts on cached models instead of being
268
+ // dropped while the offline refresh promise is still pending. This stays
269
+ // on the open path, so it must remain cheap — heavy lifting lives in the
270
+ // registry's one-pass getCanonicalModelSelections.
271
+ this.#syncFromRegistryState();
272
+
273
+ // Reconcile with cached discovery state in the background. A --models
274
+ // scope is registry-independent, so the offline reload would only repeat
275
+ // the synchronous hydration above.
276
+ if (this.#scopedModels.length === 0) {
277
+ this.#modelRegistry
278
+ .refresh("offline")
279
+ .then(() => this.#syncFromRegistryState())
280
+ .catch(error => {
281
+ this.#errorMessage = error instanceof Error ? error.message : String(error);
282
+ this.#updateList();
283
+ })
284
+ .finally(() => this.#tui.requestRender());
285
+ }
281
286
  }
282
287
 
283
288
  #buildMenuRoleActions(): void {
@@ -477,37 +482,30 @@ export class ModelSelectorComponent extends Container {
477
482
 
478
483
  const candidates = models.map(item => item.model);
479
484
  this.#loadRoleModels(candidates);
480
- const canonicalRecords = this.#modelRegistry.getCanonicalModels({
485
+ const canonicalSelections = this.#modelRegistry.getCanonicalModelSelections({
481
486
  availableOnly: this.#scopedModels.length === 0,
482
487
  candidates,
483
488
  });
484
- const canonicalModels = canonicalRecords
485
- .map(record => {
486
- const selectedModel = this.#modelRegistry.resolveCanonicalModel(record.id, {
487
- availableOnly: this.#scopedModels.length === 0,
488
- candidates,
489
- });
490
- if (!selectedModel) return undefined;
491
- const searchText = [
492
- record.id,
493
- record.name,
494
- selectedModel.provider,
495
- selectedModel.id,
496
- selectedModel.name,
497
- ...record.variants.flatMap(variant => [variant.selector, variant.model.name]),
498
- ].join(" ");
499
- return {
500
- kind: "canonical" as const,
501
- id: record.id,
502
- model: selectedModel,
503
- selector: record.id,
504
- variantCount: record.variants.length,
505
- searchText,
506
- normalizedSearchText: normalizeSearchText(searchText),
507
- compactSearchText: compactSearchText(searchText),
508
- };
509
- })
510
- .filter((item): item is CanonicalModelItem => item !== undefined);
489
+ const canonicalModels = canonicalSelections.map(({ record, model: selectedModel }): CanonicalModelItem => {
490
+ const searchText = [
491
+ record.id,
492
+ record.name,
493
+ selectedModel.provider,
494
+ selectedModel.id,
495
+ selectedModel.name,
496
+ ...record.variants.flatMap(variant => [variant.selector, variant.model.name]),
497
+ ].join(" ");
498
+ return {
499
+ kind: "canonical",
500
+ id: record.id,
501
+ model: selectedModel,
502
+ selector: record.id,
503
+ variantCount: record.variants.length,
504
+ searchText,
505
+ normalizedSearchText: normalizeSearchText(searchText),
506
+ compactSearchText: compactSearchText(searchText),
507
+ };
508
+ });
511
509
 
512
510
  this.#sortModels(models);
513
511
  this.#sortCanonicalModels(canonicalModels);
@@ -523,12 +521,27 @@ export class ModelSelectorComponent extends Container {
523
521
  );
524
522
  }
525
523
 
526
- async #loadModels(): Promise<void> {
527
- if (this.#scopedModels.length === 0) {
528
- // Reload config and cached discovery state without blocking on live provider refresh
529
- await this.#modelRegistry.refresh("offline");
530
- }
524
+ /**
525
+ * Rebuild the visible model lists from the registry's in-memory state.
526
+ * Re-entrant: runs once synchronously at construction and again whenever a
527
+ * background refresh lands, so it re-applies the live search query and pins
528
+ * the highlighted item by selector — a refresh that reorders or inserts
529
+ * models must not yank the user's selection out from under a pending Enter.
530
+ */
531
+ #syncFromRegistryState(): void {
532
+ const selectedKey = this.#getSelectedItem()?.selector;
531
533
  this.#loadModelsFromCurrentRegistryState();
534
+ this.#buildProviderTabs();
535
+ this.#updateTabBar();
536
+ this.#applyTabFilter();
537
+ if (selectedKey) {
538
+ const visibleItems = this.#getVisibleItems();
539
+ const restoredIndex = visibleItems.findIndex(item => item.selector === selectedKey);
540
+ if (restoredIndex >= 0 && restoredIndex !== this.#selectedIndex) {
541
+ this.#selectedIndex = this.#coerceSelectedIndex(restoredIndex, visibleItems);
542
+ this.#updateList();
543
+ }
544
+ }
532
545
  }
533
546
 
534
547
  #buildProviderTabs(): void {
@@ -631,10 +644,7 @@ export class ModelSelectorComponent extends Container {
631
644
  // here must stay purely in-memory — do not call modelRegistry.refresh()
632
645
  // again or tab switches will pay an extra whole-registry reload after the
633
646
  // network round-trip completes.
634
- this.#loadModelsFromCurrentRegistryState();
635
- this.#buildProviderTabs();
636
- this.#updateTabBar();
637
- this.#applyTabFilter();
647
+ this.#syncFromRegistryState();
638
648
  } catch (error) {
639
649
  this.#errorMessage = error instanceof Error ? error.message : String(error);
640
650
  this.#updateList();
@@ -1,8 +1,14 @@
1
- import { type Component, Container, type NativeScrollbackLiveRegion, TERMINAL } from "@oh-my-pi/pi-tui";
1
+ import { type Component, Container, type NativeScrollbackLiveRegion } from "@oh-my-pi/pi-tui";
2
2
 
3
- const kSnapshot = Symbol("transcript.frozenRender");
3
+ const kSnapshot = Symbol("transcript.liveDiffSnapshot");
4
4
 
5
- interface FrozenRender {
5
+ /**
6
+ * Per-block diff cache: the block's previous stripped contribution plus the
7
+ * derived append-only state. Purely an input to {@link deriveLiveCommitState}
8
+ * for still-live blocks — it is never replayed as render output. Every block
9
+ * renders its current content on every frame.
10
+ */
11
+ interface LiveDiffSnapshot {
6
12
  width: number;
7
13
  lines: string[];
8
14
  generation: number;
@@ -12,10 +18,19 @@ interface FrozenRender {
12
18
  * append-only status. `0` means the block is not under rewrite suspicion.
13
19
  */
14
20
  volatileCooldown: number;
21
+ /**
22
+ * Stable-prefix ratchet (see {@link deriveLiveCommitState}): leading rows
23
+ * promoted as commit-safe because they stayed visibly identical for
24
+ * {@link STABLE_PREFIX_COMMIT_FRAMES} consecutive frames, plus the in-flight
25
+ * candidate run and its age.
26
+ */
27
+ stablePrefixLength: number;
28
+ candidatePrefixLength: number;
29
+ candidatePrefixAge: number;
15
30
  }
16
31
 
17
32
  interface SnapshotCarrier {
18
- [kSnapshot]?: FrozenRender;
33
+ [kSnapshot]?: LiveDiffSnapshot;
19
34
  }
20
35
 
21
36
  /**
@@ -56,6 +71,9 @@ function stripPlainBlankEdges(lines: string[]): string[] {
56
71
  interface LiveCommitState {
57
72
  appendOnly: boolean;
58
73
  volatileCooldown: number;
74
+ stablePrefixLength: number;
75
+ candidatePrefixLength: number;
76
+ candidatePrefixAge: number;
59
77
  safeLength: number;
60
78
  }
61
79
 
@@ -72,6 +90,22 @@ interface LiveCommitState {
72
90
  */
73
91
  const VOLATILE_REARM_FRAMES = 30;
74
92
 
93
+ /**
94
+ * Consecutive frames a leading row run must stay visibly identical before it
95
+ * is promoted as commit-safe even though the block's tail keeps rewriting.
96
+ * Append-only detection alone is all-or-nothing per block: one perpetually
97
+ * ticking row (a task tool's progress tree, per-agent cost/tool counters, a
98
+ * log line spinner) suspends commits for the WHOLE block forever, so once the
99
+ * block outgrows the viewport its static head — e.g. a task's prompt/context
100
+ * markdown — is neither committed to native scrollback nor on screen: the
101
+ * transcript reads as cut off for the entire (possibly minutes-long) run.
102
+ * The ratchet commits the settled head while only the genuinely volatile tail
103
+ * stays deferred. If a promoted row is later rewritten (a collapsing
104
+ * preview), the engine's committed-prefix audit re-anchors and recommits —
105
+ * duplication, never loss — and the ratchet retreats to the divergence.
106
+ */
107
+ const STABLE_PREFIX_COMMIT_FRAMES = 30;
108
+
75
109
  /**
76
110
  * Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
77
111
  * write framing, not content. A styled line's closing escape moves when the
@@ -91,10 +125,10 @@ function rowsVisiblyEqual(prev: string, cur: string): boolean {
91
125
  }
92
126
 
93
127
  function hasValidSnapshot(
94
- snapshot: FrozenRender | undefined,
128
+ snapshot: LiveDiffSnapshot | undefined,
95
129
  width: number,
96
130
  generation: number,
97
- ): snapshot is FrozenRender {
131
+ ): snapshot is LiveDiffSnapshot {
98
132
  return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
99
133
  }
100
134
 
@@ -113,16 +147,22 @@ function commonSuffixLength(prev: string[], cur: string[], prefixLength: number)
113
147
  }
114
148
 
115
149
  function deriveLiveCommitState(
116
- previous: FrozenRender | undefined,
150
+ previous: LiveDiffSnapshot | undefined,
117
151
  current: string[],
118
152
  width: number,
119
153
  generation: number,
120
154
  ): LiveCommitState {
121
155
  let appendOnly = false;
122
156
  let volatileCooldown = 0;
157
+ let stablePrefixLength = 0;
158
+ let candidatePrefixLength = 0;
159
+ let candidatePrefixAge = 0;
123
160
  if (hasValidSnapshot(previous, width, generation)) {
124
161
  appendOnly = previous.appendOnly;
125
162
  volatileCooldown = previous.volatileCooldown;
163
+ stablePrefixLength = previous.stablePrefixLength;
164
+ candidatePrefixLength = previous.candidatePrefixLength;
165
+ candidatePrefixAge = previous.candidatePrefixAge;
126
166
 
127
167
  const prefixLength = commonPrefixLength(previous.lines, current);
128
168
  const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
@@ -163,65 +203,79 @@ function deriveLiveCommitState(
163
203
  }
164
204
  }
165
205
  if (cleanFrame && volatileCooldown > 0) volatileCooldown--;
206
+
207
+ // Stable-prefix ratchet, independent of append-only. `prefixLength` is
208
+ // this frame's visibly-unchanged leading run; the candidate accumulates
209
+ // the MINIMUM prefix across a STABLE_PREFIX_COMMIT_FRAMES window, so
210
+ // promotion means every promoted row stayed identical for the whole
211
+ // window (row r is inside frame i's common prefix iff r < p_i, so
212
+ // r < min(p) holds for every frame of the window). A row settling
213
+ // mid-window promotes at most two windows later. A change above the
214
+ // already-promoted run retreats it to the divergence — the engine
215
+ // audit owns any rows that already committed (recommit, never loss).
216
+ if (prefixLength < stablePrefixLength) {
217
+ stablePrefixLength = prefixLength;
218
+ candidatePrefixLength = prefixLength;
219
+ candidatePrefixAge = 0;
220
+ } else {
221
+ candidatePrefixLength =
222
+ candidatePrefixAge === 0 ? prefixLength : Math.min(candidatePrefixLength, prefixLength);
223
+ candidatePrefixAge++;
224
+ if (candidatePrefixAge >= STABLE_PREFIX_COMMIT_FRAMES) {
225
+ stablePrefixLength = candidatePrefixLength;
226
+ candidatePrefixLength = prefixLength;
227
+ candidatePrefixAge = 0;
228
+ }
229
+ }
166
230
  }
167
231
 
168
232
  return {
169
233
  appendOnly,
170
234
  volatileCooldown,
171
- safeLength: appendOnly ? current.length : 0,
235
+ stablePrefixLength,
236
+ candidatePrefixLength,
237
+ candidatePrefixAge,
238
+ // An append-only block's whole body is committable; otherwise the
239
+ // settled head still is — only the volatile tail stays deferred.
240
+ safeLength: appendOnly ? current.length : stablePrefixLength,
172
241
  };
173
242
  }
174
243
 
175
244
  /**
176
- * Transcript container that freezes the rendered output of every block except
177
- * the bottom-most (live) one on terminals where committed native scrollback is
178
- * immutable.
245
+ * Transcript container that always renders every block's current content and
246
+ * reports the live-region seam (`NativeScrollbackLiveRegion`) that gates the
247
+ * engine's append-only scrollback commits.
179
248
  *
180
- * On ED3-risk terminals with an unobservable viewport (ghostty/kitty/iTerm2/…)
181
- * the renderer cannot clear saved lines (`\x1b[3J` may yank a reader) or query
182
- * whether the user has scrolled, so any block that re-lays-out *after* it has
183
- * scrolled past the viewport leaves a stale duplicate above the live region
184
- * (a finalized assistant message re-wrapping, a tool preview collapsing to its
185
- * compact result, a late async tool completion). The renderer's only safe move
186
- * for such an offscreen edit is to not repaint which is correct only if the
187
- * committed region never changes underneath it.
188
- *
189
- * This container provides that guarantee: a block's render is snapshotted while
190
- * it is the live (bottom-most) block, and once a newer block is appended it
191
- * replays the snapshot instead of recomputing. Mutations after a block leaves
192
- * live are intentionally deferred until the next checkpoint {@link thaw} (prompt
193
- * submit → native-scrollback rebuild), where the whole transcript is replayed
194
- * and any drift reconciles safely. On terminals that can rebuild history this
195
- * freezing is unnecessary, so it renders every block live for full fidelity.
249
+ * The engine never rewrites committed history: rows above the seam that have
250
+ * entered the tape keep whatever bytes they were committed with ("let the
251
+ * history be"), while the visible window always repaints from each block's
252
+ * latest render a late tool result, a post-finalize error pin, or an expand
253
+ * toggle is always reflected on screen. Blocks that are still mutating (an
254
+ * unfinalized tool, a streaming assistant message) stay below the seam so
255
+ * their rows do not enter history while they can still change; a streaming
256
+ * block whose render grows append-only deepens the seam through its settled
257
+ * head so a long reply's scrolled-off rows still reach scrollback mid-stream.
196
258
  */
197
259
  export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
198
- // Bumped to invalidate every block's snapshot at once; a snapshot is only
199
- // honored when its stored generation still matches.
260
+ // Bumped to retire every block's diff snapshot at once (theme change /
261
+ // clear); a snapshot is only honored when its stored generation matches.
200
262
  #generation = 0;
201
- // Line index where the live (repaintable) region began on the previous
202
- // render — the start of the earliest still-mutating block, or the bottom
203
- // block when everything is finalized. A block leaves the live region only
204
- // once it has finalized AND a finalized block sits below it; the frame it
205
- // crosses out is recomputed so it freezes at its true final content, not the
206
- // mid-stream snapshot it last rendered while live (TUI render coalescing can
207
- // advance a block's content in the very frame it stops being live).
208
- #prevLiveStartIndex = 0;
209
263
  // Local line index where the current live region begins in the most recent
210
- // render. TUI extends the native-scrollback pinned region from this point
211
- // through the live blocks and the root chrome rendered below them.
264
+ // render. TUI commits rows to native scrollback only above this seam (or
265
+ // the deeper commit-safe end below).
212
266
  #nativeScrollbackLiveRegionStart: number | undefined;
213
267
  // Local line index up to which the leading run of live blocks is safe to
214
- // commit. Finalized blocks contribute their full frozen body; still-live
215
- // blocks contribute only while their render has been observed growing
216
- // without visibly rewriting a previously rendered interior row (escape
217
- // placement and pad drift are ignored). A rewrite suspends the block's
218
- // contribution until it re-earns append-only via VOLATILE_REARM_FRAMES
219
- // clean frames; the pinned emitter then backfills the stalled gap.
268
+ // commit. Finalized blocks contribute their full body; still-live blocks
269
+ // contribute only while their render has been observed growing without
270
+ // visibly rewriting a previously rendered interior row (escape placement
271
+ // and pad drift are ignored). A rewrite suspends the block's contribution
272
+ // until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames;
273
+ // the engine then backfills the stalled gap.
220
274
  #nativeScrollbackCommitSafeEnd: number | undefined;
221
275
 
222
276
  override invalidate(): void {
223
- // A theme/global invalidation forces a full recompute on the rebuild that
224
- // follows; retire every snapshot.
277
+ // Theme/global invalidation: retire every diff snapshot so stale styling
278
+ // is not diffed against the recolored render.
225
279
  this.#generation++;
226
280
  super.invalidate();
227
281
  }
@@ -239,32 +293,19 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
239
293
  return this.#nativeScrollbackCommitSafeEnd;
240
294
  }
241
295
 
242
- /**
243
- * Retire all frozen snapshots so the next render reflects each block's current
244
- * state. Call at reconciliation checkpoints (prompt submit) where the whole
245
- * transcript is replayed into native scrollback and any drift a frozen block
246
- * accumulated is reconciled.
247
- */
248
- thaw(): void {
249
- this.#generation++;
250
- }
251
-
252
296
  override render(width: number): string[] {
253
297
  width = Math.max(1, width);
254
298
  this.#nativeScrollbackLiveRegionStart = undefined;
255
299
  this.#nativeScrollbackCommitSafeEnd = undefined;
256
300
 
257
- // Freezing/snapshotting only applies on ED3-risk terminals; elsewhere every
258
- // block renders live. Inter-block spacing applies on BOTH paths so the gap
259
- // between blocks is identical regardless of terminal.
260
- const risk = TERMINAL.eagerEraseScrollbackRisk;
261
301
  const count = this.children.length;
262
302
 
263
303
  // The live region spans from the earliest still-mutating block through the
264
- // bottom. A block that has not finalized must stay repaintable: out-of-band
265
- // inserts (TTSR/todo cards) can append a finalized block *below* a tool that
266
- // is still awaiting its result, and freezing the tool there would strand its
267
- // committed rows on the mid-stream preview the late result never reaches.
304
+ // bottom. A block that has not finalized must stay below the seam: out-of-
305
+ // band inserts (TTSR/todo cards) can append a finalized block *below* a
306
+ // tool that is still awaiting its result, and committing the tool there
307
+ // would strand its history rows on the mid-stream preview the late result
308
+ // never reaches.
268
309
  let liveStartIndex = count - 1;
269
310
  for (let i = 0; i < count; i++) {
270
311
  if (!isBlockFinalized(this.children[i]!)) {
@@ -272,62 +313,48 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
272
313
  break;
273
314
  }
274
315
  }
275
- // Blocks at [prevLiveStart, liveStart) just crossed out of the live region;
276
- // recompute them so they freeze at their final content. Everything below
277
- // the lower of the two cutoffs was already frozen last frame and replays.
278
- const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
279
- if (risk) this.#prevLiveStartIndex = liveStartIndex;
280
316
 
281
317
  const lines: string[] = [];
282
318
  // Tracks whether we are still inside the leading run of commit-safe live
283
319
  // blocks. The first still-live volatile block closes it, but rendering
284
320
  // continues so lower blocks remain visible.
285
321
  let commitSafeOpen = true;
286
- // The live-region start is recorded at the first visible row at/after the
287
- // cutoff; empty leading blocks (or a separator) must not claim it early.
322
+ // The live-region start is recorded at the first visible row at/after
323
+ // liveStartIndex; empty leading blocks (or a separator) must not claim it
324
+ // early.
288
325
  let liveRecorded = false;
289
326
  for (let i = 0; i < count; i++) {
290
327
  const child = this.children[i]! as Component & SnapshotCarrier;
291
328
 
292
- // Resolve this child's contribution its visible body with plain-blank
293
- // top/bottom edges stripped (the container owns inter-block gaps). On
294
- // ED3-risk terminals a frozen, scrolled-off block replays its snapshot
295
- // instead of recomputing; a stale generation (post-thaw) or width
296
- // mismatch (resize) recomputes, as does a block still live last frame.
297
- let contribution: string[] | undefined;
298
- const previousSnapshot = risk ? child[kSnapshot] : undefined;
299
- if (risk && i < liveStartIndex && i < replayCutoff) {
300
- if (hasValidSnapshot(previousSnapshot, width, this.#generation)) {
301
- contribution = previousSnapshot.lines;
302
- }
303
- }
329
+ // This child's contribution: its current render with plain-blank
330
+ // top/bottom edges stripped (the container owns inter-block gaps).
331
+ // Always the latest content committed history keeps whatever bytes
332
+ // it was written with, but the window must reflect the present state
333
+ // (late tool results, post-finalize re-layouts, expand toggles).
334
+ const previousSnapshot = child[kSnapshot];
335
+ const contribution = stripPlainBlankEdges(child.render(width));
304
336
  let liveCommitState: LiveCommitState | undefined;
305
- if (contribution === undefined) {
306
- const rendered = child.render(width);
307
- contribution = stripPlainBlankEdges(rendered);
308
- if (risk && i >= liveStartIndex && !isBlockFinalized(child)) {
309
- liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
310
- }
311
- // Cache every block's latest contribution. While a block is in the
312
- // live region this keeps its snapshot current; on the frame it crosses
313
- // out, the recompute above refreshes it before it freezes.
314
- if (risk) {
315
- child[kSnapshot] = {
316
- width,
317
- lines: contribution,
318
- generation: this.#generation,
319
- appendOnly: liveCommitState?.appendOnly ?? false,
320
- volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
321
- };
322
- }
337
+ if (i >= liveStartIndex && !isBlockFinalized(child)) {
338
+ liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
323
339
  }
340
+ // Cache the latest contribution as the next frame's diff input.
341
+ child[kSnapshot] = {
342
+ width,
343
+ lines: contribution,
344
+ generation: this.#generation,
345
+ appendOnly: liveCommitState?.appendOnly ?? false,
346
+ volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
347
+ stablePrefixLength: liveCommitState?.stablePrefixLength ?? 0,
348
+ candidatePrefixLength: liveCommitState?.candidatePrefixLength ?? 0,
349
+ candidatePrefixAge: liveCommitState?.candidatePrefixAge ?? 0,
350
+ };
324
351
 
325
352
  // Empty (or stripped-to-nothing) children contribute nothing and never
326
353
  // affect spacing or the live-region offsets. An empty still-live child
327
354
  // still closes the commit-safe run: if it later gains rows, it pushes
328
355
  // everything below it.
329
356
  if (contribution.length === 0) {
330
- if (risk && i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
357
+ if (i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
331
358
  continue;
332
359
  }
333
360
 
@@ -336,10 +363,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
336
363
  // already a plain blank (a fragment's own trailing pad), never doubling.
337
364
  const sep = lines.length > 0 && !isPlainBlank(lines[lines.length - 1]!) ? 1 : 0;
338
365
 
339
- // The separator before the first live block stays in the committed prefix
340
- // (it is deterministic and never changes once the prior block is frozen),
366
+ // The separator before the first live block stays in the committed
367
+ // prefix (it is deterministic once the prior block's body is settled),
341
368
  // so the live region begins at the block's first content row.
342
- if (risk && !liveRecorded && i >= liveStartIndex) {
369
+ if (!liveRecorded && i >= liveStartIndex) {
343
370
  this.#nativeScrollbackLiveRegionStart = lines.length + sep;
344
371
  liveRecorded = true;
345
372
  }
@@ -348,7 +375,7 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
348
375
  const blockStart = lines.length;
349
376
  for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
350
377
 
351
- if (risk && i >= liveStartIndex && commitSafeOpen) {
378
+ if (i >= liveStartIndex && commitSafeOpen) {
352
379
  const finalized = isBlockFinalized(child);
353
380
  const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
354
381
  if (safeLength > 0) {
@@ -36,19 +36,6 @@ const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
36
36
  */
37
37
  export const INTERRUPTING_WORKING_MESSAGE = "Interrupting…";
38
38
 
39
- // Events that change foreground streaming state, or that reset a turn. The TUI
40
- // eager native-scrollback rebuild mode is recomputed only on these so unrelated
41
- // IRC/notices/status refreshes do not toggle scrollback replay policy.
42
- const STREAM_RENDER_MODE_EVENTS: Record<string, true> = {
43
- agent_start: true,
44
- agent_end: true,
45
- message_start: true,
46
- message_end: true,
47
- tool_execution_start: true,
48
- tool_execution_update: true,
49
- tool_execution_end: true,
50
- };
51
-
52
39
  type AgentSessionEventHandlers = {
53
40
  [E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
54
41
  };
@@ -65,7 +52,6 @@ export class EventController {
65
52
  #renderedCustomMessages = new Set<string>();
66
53
  #lastIntent: string | undefined = undefined;
67
54
  #backgroundToolCallIds = new Set<string>();
68
- #assistantMessageStreaming = false;
69
55
  #agentTurnActive = false;
70
56
  #interrupting = false;
71
57
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
@@ -217,30 +203,6 @@ export class EventController {
217
203
 
218
204
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
219
205
  await run(event);
220
- // While an assistant turn is active, visible status chrome and foreground
221
- // transcript blocks can re-render after rows have entered native scrollback
222
- // (idle Working loader, Markdown fences, wrapping, tool previews). Let the
223
- // TUI use its foreground live-region path instead of idle deferral, which
224
- // can otherwise leave the loader/status frame frozen until the next input.
225
- // Background-running tools after the turn ends are excluded so late async
226
- // updates keep the no-yank deferral; agent_start/agent_end bracket the
227
- // foreground turn.
228
- if (STREAM_RENDER_MODE_EVENTS[event.type]) {
229
- this.#refreshToolRenderMode();
230
- }
231
- }
232
-
233
- #refreshToolRenderMode(): void {
234
- let foregroundToolActive = this.#agentTurnActive || this.#assistantMessageStreaming;
235
- if (!foregroundToolActive) {
236
- for (const toolCallId of this.ctx.pendingTools.keys()) {
237
- if (!this.#backgroundToolCallIds.has(toolCallId)) {
238
- foregroundToolActive = true;
239
- break;
240
- }
241
- }
242
- }
243
- this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive);
244
206
  }
245
207
 
246
208
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
@@ -250,7 +212,6 @@ export class EventController {
250
212
  this.#readToolCallArgs.clear();
251
213
  this.#readToolCallAssistantComponents.clear();
252
214
  this.#resetReadGroup();
253
- this.#assistantMessageStreaming = false;
254
215
  this.#lastAssistantComponent = undefined;
255
216
  // Restore the previous turn's inline error in the transcript before dropping
256
217
  // the banner, so the error stays in history once the banner is gone.
@@ -267,7 +228,6 @@ export class EventController {
267
228
  this.ctx.statusContainer.clear();
268
229
  }
269
230
  this.#cancelIdleCompaction();
270
- this.#refreshToolRenderMode();
271
231
  this.ctx.ensureLoadingAnimation();
272
232
  this.ctx.ui.requestRender();
273
233
  }
@@ -340,7 +300,6 @@ export class EventController {
340
300
  this.ctx.addMessageToChat(event.message);
341
301
  this.ctx.ui.requestRender();
342
302
  } else if (event.message.role === "assistant") {
343
- this.#assistantMessageStreaming = true;
344
303
  this.#lastVisibleBlockCount = 0;
345
304
  this.ctx.streamingComponent = new AssistantMessageComponent(
346
305
  undefined,
@@ -491,9 +450,6 @@ export class EventController {
491
450
 
492
451
  async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
493
452
  if (event.message.role === "user") return;
494
- if (event.message.role === "assistant") {
495
- this.#assistantMessageStreaming = false;
496
- }
497
453
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
498
454
  this.ctx.streamingMessage = event.message;
499
455
  this.#streamingReveal.stop();
@@ -701,7 +657,6 @@ export class EventController {
701
657
  }
702
658
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
703
659
  this.#agentTurnActive = false;
704
- this.#assistantMessageStreaming = false;
705
660
  this.#streamingReveal.stop();
706
661
  if (this.ctx.loadingAnimation) {
707
662
  this.ctx.loadingAnimation.stop();
@@ -267,7 +267,7 @@ export class InputController {
267
267
  const focused = this.ctx.ui.getFocused();
268
268
  const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
269
269
  target.pasteText(text);
270
- this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
270
+ this.ctx.ui.requestRender();
271
271
  },
272
272
  pasteImage: async image => {
273
273
  // Images can only land in the main editor — when a modal Input is
@@ -755,7 +755,7 @@ export class InputController {
755
755
  const dims = await this.#imageDimensions(imageData);
756
756
  const label = dims ? `[Image #${imageNum}, ${dims.width}x${dims.height}]` : `[Image #${imageNum}]`;
757
757
  this.ctx.editor.insertText(`${label} `);
758
- this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
758
+ this.ctx.ui.requestRender();
759
759
  }
760
760
 
761
761
  /** Probe pixel dimensions for the marker label (`[Image #N, WxH]`). Returns undefined when the
@@ -801,7 +801,7 @@ export class InputController {
801
801
  });
802
802
  if (!image) {
803
803
  this.ctx.editor.pasteText(path);
804
- this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
804
+ this.ctx.ui.requestRender();
805
805
  this.ctx.showStatus("Pasted path is not a supported image");
806
806
  return;
807
807
  }
@@ -811,7 +811,7 @@ export class InputController {
811
811
  );
812
812
  } catch (error) {
813
813
  this.ctx.editor.pasteText(path);
814
- this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
814
+ this.ctx.ui.requestRender();
815
815
  this.ctx.showStatus(
816
816
  error instanceof ImageInputTooLargeError ? error.message : "Failed to read pasted image path",
817
817
  );