@oh-my-pi/pi-coding-agent 15.10.8 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/types/config/model-registry.d.ts +13 -0
  3. package/dist/types/config/settings-schema.d.ts +0 -9
  4. package/dist/types/debug/terminal-info.d.ts +0 -1
  5. package/dist/types/extensibility/custom-tools/loader.d.ts +22 -3
  6. package/dist/types/extensibility/extensions/index.d.ts +1 -1
  7. package/dist/types/extensibility/extensions/loader.d.ts +17 -1
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  9. package/dist/types/mcp/transports/stdio.d.ts +12 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  11. package/dist/types/modes/components/transcript-container.d.ts +12 -26
  12. package/dist/types/sdk.d.ts +42 -2
  13. package/dist/types/task/discovery.d.ts +1 -2
  14. package/dist/types/task/executor.d.ts +16 -0
  15. package/dist/types/tiny/title-client.d.ts +1 -1
  16. package/dist/types/tools/index.d.ts +17 -0
  17. package/dist/types/tools/todo.d.ts +2 -0
  18. package/dist/types/tui/hyperlink.d.ts +8 -0
  19. package/package.json +9 -9
  20. package/src/cli/list-models.ts +5 -11
  21. package/src/config/model-registry.ts +91 -20
  22. package/src/config/settings-schema.ts +0 -10
  23. package/src/debug/terminal-info.ts +0 -3
  24. package/src/edit/diff.ts +48 -15
  25. package/src/eval/js/shared/rewrite-imports.ts +9 -1
  26. package/src/extensibility/custom-tools/loader.ts +43 -19
  27. package/src/extensibility/extensions/index.ts +1 -0
  28. package/src/extensibility/extensions/loader.ts +29 -6
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +30 -6
  30. package/src/internal-urls/docs-index.generated.ts +4 -4
  31. package/src/mcp/transports/stdio.ts +139 -3
  32. package/src/modes/components/custom-editor.ts +69 -9
  33. package/src/modes/components/model-selector.ts +62 -52
  34. package/src/modes/components/transcript-container.ts +204 -125
  35. package/src/modes/controllers/event-controller.ts +0 -45
  36. package/src/modes/controllers/input-controller.ts +5 -5
  37. package/src/modes/controllers/mcp-command-controller.ts +2 -2
  38. package/src/modes/controllers/selector-controller.ts +0 -4
  39. package/src/modes/interactive-mode.ts +2 -10
  40. package/src/prompts/system/system-prompt.md +3 -3
  41. package/src/prompts/tools/bash.md +3 -3
  42. package/src/prompts/tools/todo.md +5 -1
  43. package/src/sdk.ts +138 -56
  44. package/src/ssh/ssh-executor.ts +60 -4
  45. package/src/task/discovery.ts +17 -24
  46. package/src/task/executor.ts +19 -0
  47. package/src/task/index.ts +4 -0
  48. package/src/tiny/title-client.ts +6 -3
  49. package/src/tools/index.ts +17 -0
  50. package/src/tools/todo.ts +16 -7
  51. package/src/tui/hyperlink.ts +27 -3
  52. package/src/web/search/providers/anthropic.ts +8 -2
@@ -1,17 +1,36 @@
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;
9
15
  appendOnly: boolean;
10
- volatile: boolean;
16
+ /**
17
+ * Frames remaining until a block that rewrote an interior row may re-earn
18
+ * append-only status. `0` means the block is not under rewrite suspicion.
19
+ */
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;
11
30
  }
12
31
 
13
32
  interface SnapshotCarrier {
14
- [kSnapshot]?: FrozenRender;
33
+ [kSnapshot]?: LiveDiffSnapshot;
15
34
  }
16
35
 
17
36
  /**
@@ -51,125 +70,212 @@ function stripPlainBlankEdges(lines: string[]): string[] {
51
70
 
52
71
  interface LiveCommitState {
53
72
  appendOnly: boolean;
54
- volatile: boolean;
73
+ volatileCooldown: number;
74
+ stablePrefixLength: number;
75
+ candidatePrefixLength: number;
76
+ candidatePrefixAge: number;
55
77
  safeLength: number;
56
78
  }
57
79
 
80
+ /**
81
+ * Render frames a block must stay clean (static or append-shaped) after an
82
+ * interior rewrite before its rows become committable again. A one-off
83
+ * re-layout (a codespan finalizing across a wrap boundary, a paragraph
84
+ * re-parsed as a heading) only suspends commits briefly — the pinned emitter
85
+ * appends from the stalled high-water mark, so the gap backfills contiguously
86
+ * once the block re-earns append-only. Periodic animations (a spinner rewrites
87
+ * its row every few frames) keep resetting the countdown and never re-earn it,
88
+ * so genuinely volatile blocks stay deferred. Frames arrive at most at the
89
+ * TUI's 30 Hz render cadence, so 30 frames ≈ 1s of clean streaming.
90
+ */
91
+ const VOLATILE_REARM_FRAMES = 30;
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
+
109
+ /**
110
+ * Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
111
+ * write framing, not content. A styled line's closing escape moves when the
112
+ * line stops being the last of its span (a wrapped thinking paragraph growing
113
+ * by one row), and width-padded rows shift their trailing spaces as text
114
+ * grows; both leave the on-screen cells identical and must not count as a
115
+ * rewrite of a committed-candidate row. Committed scrollback rows are written
116
+ * with a full SGR/OSC reset terminator, so escape-placement drift between
117
+ * visually identical renders cannot bleed styles across rows.
118
+ */
119
+ function normalizeRow(line: string): string {
120
+ return Bun.stripANSI(line).trimEnd();
121
+ }
122
+
123
+ function rowsVisiblyEqual(prev: string, cur: string): boolean {
124
+ return prev === cur || normalizeRow(prev) === normalizeRow(cur);
125
+ }
126
+
58
127
  function hasValidSnapshot(
59
- snapshot: FrozenRender | undefined,
128
+ snapshot: LiveDiffSnapshot | undefined,
60
129
  width: number,
61
130
  generation: number,
62
- ): snapshot is FrozenRender {
131
+ ): snapshot is LiveDiffSnapshot {
63
132
  return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
64
133
  }
65
134
 
66
135
  function commonPrefixLength(prev: string[], cur: string[]): number {
67
136
  const limit = Math.min(prev.length, cur.length);
68
137
  let i = 0;
69
- while (i < limit && prev[i] === cur[i]) i++;
138
+ while (i < limit && rowsVisiblyEqual(prev[i]!, cur[i]!)) i++;
70
139
  return i;
71
140
  }
72
141
 
73
142
  function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
74
143
  const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
75
144
  let i = 0;
76
- while (i < limit && prev[prev.length - 1 - i] === cur[cur.length - 1 - i]) i++;
145
+ while (i < limit && rowsVisiblyEqual(prev[prev.length - 1 - i]!, cur[cur.length - 1 - i]!)) i++;
77
146
  return i;
78
147
  }
79
148
 
80
149
  function deriveLiveCommitState(
81
- previous: FrozenRender | undefined,
150
+ previous: LiveDiffSnapshot | undefined,
82
151
  current: string[],
83
152
  width: number,
84
153
  generation: number,
85
154
  ): LiveCommitState {
86
155
  let appendOnly = false;
87
- let volatile = false;
156
+ let volatileCooldown = 0;
157
+ let stablePrefixLength = 0;
158
+ let candidatePrefixLength = 0;
159
+ let candidatePrefixAge = 0;
88
160
  if (hasValidSnapshot(previous, width, generation)) {
89
161
  appendOnly = previous.appendOnly;
90
- volatile = previous.volatile;
162
+ volatileCooldown = previous.volatileCooldown;
163
+ stablePrefixLength = previous.stablePrefixLength;
164
+ candidatePrefixLength = previous.candidatePrefixLength;
165
+ candidatePrefixAge = previous.candidatePrefixAge;
91
166
 
92
167
  const prefixLength = commonPrefixLength(previous.lines, current);
93
168
  const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
169
+ let cleanFrame = true;
94
170
  if (!staticRender) {
95
171
  const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
96
172
  // Append-only growth never rewrites a row that may already have scrolled
97
- // into native scrollback; it only grows the block at/near its tail. Three
173
+ // into native scrollback; it only grows the block at/near its tail. Four
98
174
  // shapes qualify: a pure bottom append, an insertion above stable trailing
99
- // chrome (a streaming tool's footer/border), and an in-place extension of
100
- // the current line by one streamed token (line count unchanged). The first
101
- // two preserve every previous row across a matching prefix + suffix; the
102
- // last leaves a single divergent previous row that the current row merely
103
- // lengthens. A divergent interior row that is genuinely rewritten means the
104
- // block re-laid-out committed contentvolatile, and never committed.
175
+ // chrome (a streaming tool's footer/border), an in-place extension of the
176
+ // current line by one streamed token (line count unchanged), and a
177
+ // wrap-shrink of the current line where its last word grew past the wrap
178
+ // column and moved down onto an appended row. The first two preserve every
179
+ // previous row across a matching prefix + suffix; the last two leave a
180
+ // single divergent previous rowthe block's in-flight bottom line, which
181
+ // cannot have been committed (commits stop at the viewport top and the
182
+ // bottom line is by definition on screen). Any other divergent interior
183
+ // row means the block re-laid-out committed-candidate content — a rewrite,
184
+ // which suspends commits until the block re-earns append-only.
105
185
  const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
106
- const tailExtendedInPlace =
186
+ let tailExtendedInPlace = false;
187
+ if (
188
+ !preservedEveryRow &&
107
189
  prefixLength + suffixLength === previous.lines.length - 1 &&
108
- prefixLength < current.length &&
109
- current[prefixLength]!.startsWith(previous.lines[prefixLength]!);
110
- if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length && !volatile) {
111
- appendOnly = true;
112
- } else if (!preservedEveryRow && !tailExtendedInPlace) {
113
- volatile = true;
190
+ prefixLength < current.length
191
+ ) {
192
+ const prevTail = normalizeRow(previous.lines[prefixLength]!);
193
+ const curTail = normalizeRow(current[prefixLength]!);
194
+ tailExtendedInPlace =
195
+ curTail.startsWith(prevTail) || (current.length > previous.lines.length && prevTail.startsWith(curTail));
196
+ }
197
+ if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length) {
198
+ if (volatileCooldown === 0) appendOnly = true;
199
+ } else {
200
+ cleanFrame = false;
114
201
  appendOnly = false;
202
+ volatileCooldown = VOLATILE_REARM_FRAMES;
203
+ }
204
+ }
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;
115
228
  }
116
229
  }
117
230
  }
118
231
 
119
232
  return {
120
233
  appendOnly,
121
- volatile,
122
- safeLength: volatile ? 0 : appendOnly ? current.length : 0,
234
+ volatileCooldown,
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,
123
241
  };
124
242
  }
125
243
 
126
244
  /**
127
- * Transcript container that freezes the rendered output of every block except
128
- * the bottom-most (live) one on terminals where committed native scrollback is
129
- * 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.
130
248
  *
131
- * On ED3-risk terminals with an unobservable viewport (ghostty/kitty/iTerm2/…)
132
- * the renderer cannot clear saved lines (`\x1b[3J` may yank a reader) or query
133
- * whether the user has scrolled, so any block that re-lays-out *after* it has
134
- * scrolled past the viewport leaves a stale duplicate above the live region
135
- * (a finalized assistant message re-wrapping, a tool preview collapsing to its
136
- * compact result, a late async tool completion). The renderer's only safe move
137
- * for such an offscreen edit is to not repaint which is correct only if the
138
- * committed region never changes underneath it.
139
- *
140
- * This container provides that guarantee: a block's render is snapshotted while
141
- * it is the live (bottom-most) block, and once a newer block is appended it
142
- * replays the snapshot instead of recomputing. Mutations after a block leaves
143
- * live are intentionally deferred until the next checkpoint {@link thaw} (prompt
144
- * submit → native-scrollback rebuild), where the whole transcript is replayed
145
- * and any drift reconciles safely. On terminals that can rebuild history this
146
- * 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.
147
258
  */
148
259
  export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
149
- // Bumped to invalidate every block's snapshot at once; a snapshot is only
150
- // 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.
151
262
  #generation = 0;
152
- // Line index where the live (repaintable) region began on the previous
153
- // render — the start of the earliest still-mutating block, or the bottom
154
- // block when everything is finalized. A block leaves the live region only
155
- // once it has finalized AND a finalized block sits below it; the frame it
156
- // crosses out is recomputed so it freezes at its true final content, not the
157
- // mid-stream snapshot it last rendered while live (TUI render coalescing can
158
- // advance a block's content in the very frame it stops being live).
159
- #prevLiveStartIndex = 0;
160
263
  // Local line index where the current live region begins in the most recent
161
- // render. TUI extends the native-scrollback pinned region from this point
162
- // 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).
163
266
  #nativeScrollbackLiveRegionStart: number | undefined;
164
267
  // Local line index up to which the leading run of live blocks is safe to
165
- // commit. Finalized blocks contribute their full frozen body; still-live
166
- // blocks contribute only after their stripped render has been observed
167
- // growing without changing a previously rendered interior row.
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.
168
274
  #nativeScrollbackCommitSafeEnd: number | undefined;
169
275
 
170
276
  override invalidate(): void {
171
- // A theme/global invalidation forces a full recompute on the rebuild that
172
- // follows; retire every snapshot.
277
+ // Theme/global invalidation: retire every diff snapshot so stale styling
278
+ // is not diffed against the recolored render.
173
279
  this.#generation++;
174
280
  super.invalidate();
175
281
  }
@@ -187,32 +293,19 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
187
293
  return this.#nativeScrollbackCommitSafeEnd;
188
294
  }
189
295
 
190
- /**
191
- * Retire all frozen snapshots so the next render reflects each block's current
192
- * state. Call at reconciliation checkpoints (prompt submit) where the whole
193
- * transcript is replayed into native scrollback and any drift a frozen block
194
- * accumulated is reconciled.
195
- */
196
- thaw(): void {
197
- this.#generation++;
198
- }
199
-
200
296
  override render(width: number): string[] {
201
297
  width = Math.max(1, width);
202
298
  this.#nativeScrollbackLiveRegionStart = undefined;
203
299
  this.#nativeScrollbackCommitSafeEnd = undefined;
204
300
 
205
- // Freezing/snapshotting only applies on ED3-risk terminals; elsewhere every
206
- // block renders live. Inter-block spacing applies on BOTH paths so the gap
207
- // between blocks is identical regardless of terminal.
208
- const risk = TERMINAL.eagerEraseScrollbackRisk;
209
301
  const count = this.children.length;
210
302
 
211
303
  // The live region spans from the earliest still-mutating block through the
212
- // bottom. A block that has not finalized must stay repaintable: out-of-band
213
- // inserts (TTSR/todo cards) can append a finalized block *below* a tool that
214
- // is still awaiting its result, and freezing the tool there would strand its
215
- // 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.
216
309
  let liveStartIndex = count - 1;
217
310
  for (let i = 0; i < count; i++) {
218
311
  if (!isBlockFinalized(this.children[i]!)) {
@@ -220,62 +313,48 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
220
313
  break;
221
314
  }
222
315
  }
223
- // Blocks at [prevLiveStart, liveStart) just crossed out of the live region;
224
- // recompute them so they freeze at their final content. Everything below
225
- // the lower of the two cutoffs was already frozen last frame and replays.
226
- const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
227
- if (risk) this.#prevLiveStartIndex = liveStartIndex;
228
316
 
229
317
  const lines: string[] = [];
230
318
  // Tracks whether we are still inside the leading run of commit-safe live
231
319
  // blocks. The first still-live volatile block closes it, but rendering
232
320
  // continues so lower blocks remain visible.
233
321
  let commitSafeOpen = true;
234
- // The live-region start is recorded at the first visible row at/after the
235
- // 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.
236
325
  let liveRecorded = false;
237
326
  for (let i = 0; i < count; i++) {
238
327
  const child = this.children[i]! as Component & SnapshotCarrier;
239
328
 
240
- // Resolve this child's contribution its visible body with plain-blank
241
- // top/bottom edges stripped (the container owns inter-block gaps). On
242
- // ED3-risk terminals a frozen, scrolled-off block replays its snapshot
243
- // instead of recomputing; a stale generation (post-thaw) or width
244
- // mismatch (resize) recomputes, as does a block still live last frame.
245
- let contribution: string[] | undefined;
246
- const previousSnapshot = risk ? child[kSnapshot] : undefined;
247
- if (risk && i < liveStartIndex && i < replayCutoff) {
248
- if (hasValidSnapshot(previousSnapshot, width, this.#generation)) {
249
- contribution = previousSnapshot.lines;
250
- }
251
- }
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));
252
336
  let liveCommitState: LiveCommitState | undefined;
253
- if (contribution === undefined) {
254
- const rendered = child.render(width);
255
- contribution = stripPlainBlankEdges(rendered);
256
- if (risk && i >= liveStartIndex && !isBlockFinalized(child)) {
257
- liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
258
- }
259
- // Cache every block's latest contribution. While a block is in the
260
- // live region this keeps its snapshot current; on the frame it crosses
261
- // out, the recompute above refreshes it before it freezes.
262
- if (risk) {
263
- child[kSnapshot] = {
264
- width,
265
- lines: contribution,
266
- generation: this.#generation,
267
- appendOnly: liveCommitState?.appendOnly ?? false,
268
- volatile: liveCommitState?.volatile ?? false,
269
- };
270
- }
337
+ if (i >= liveStartIndex && !isBlockFinalized(child)) {
338
+ liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
271
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
+ };
272
351
 
273
352
  // Empty (or stripped-to-nothing) children contribute nothing and never
274
353
  // affect spacing or the live-region offsets. An empty still-live child
275
354
  // still closes the commit-safe run: if it later gains rows, it pushes
276
355
  // everything below it.
277
356
  if (contribution.length === 0) {
278
- if (risk && i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
357
+ if (i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
279
358
  continue;
280
359
  }
281
360
 
@@ -284,10 +363,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
284
363
  // already a plain blank (a fragment's own trailing pad), never doubling.
285
364
  const sep = lines.length > 0 && !isPlainBlank(lines[lines.length - 1]!) ? 1 : 0;
286
365
 
287
- // The separator before the first live block stays in the committed prefix
288
- // (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),
289
368
  // so the live region begins at the block's first content row.
290
- if (risk && !liveRecorded && i >= liveStartIndex) {
369
+ if (!liveRecorded && i >= liveStartIndex) {
291
370
  this.#nativeScrollbackLiveRegionStart = lines.length + sep;
292
371
  liveRecorded = true;
293
372
  }
@@ -296,7 +375,7 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
296
375
  const blockStart = lines.length;
297
376
  for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
298
377
 
299
- if (risk && i >= liveStartIndex && commitSafeOpen) {
378
+ if (i >= liveStartIndex && commitSafeOpen) {
300
379
  const finalized = isBlockFinalized(child);
301
380
  const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
302
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();
@@ -191,7 +191,7 @@ export class InputController {
191
191
  this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
192
192
  );
193
193
  this.ctx.editor.onPasteImage = () => this.handleImagePaste();
194
- this.ctx.editor.onPasteImagePath = path => void this.handleImagePathPaste(path);
194
+ this.ctx.editor.onPasteImagePath = path => this.handleImagePathPaste(path);
195
195
  this.ctx.editor.setActionKeys(
196
196
  "app.clipboard.pasteTextRaw",
197
197
  this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
@@ -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
  );
@@ -36,7 +36,7 @@ import {
36
36
  import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
37
37
  import type { OAuthCredential } from "../../session/auth-storage";
38
38
  import { shortenPath } from "../../tools/render-utils";
39
- import { urlHyperlink } from "../../tui";
39
+ import { urlHyperlinkAlways } from "../../tui";
40
40
  import { openPath } from "../../utils/open";
41
41
  import { ChatBlock } from "../components/chat-block";
42
42
  import { MCPAddWizard } from "../components/mcp-add-wizard";
@@ -63,7 +63,7 @@ export class MCPAuthorizationLinkPrompt implements Component {
63
63
  invalidate(): void {}
64
64
 
65
65
  render(_width: number): string[] {
66
- const link = urlHyperlink(this.#url, "Click here to authorize");
66
+ const link = urlHyperlinkAlways(this.#url, "Click here to authorize");
67
67
  return [
68
68
  ` ${theme.fg("success", "Open authorization URL:")}`,
69
69
  ` ${theme.fg("accent", link)}`,
@@ -266,10 +266,6 @@ export class SelectorController {
266
266
  this.ctx.updateEditorBorderColor();
267
267
  break;
268
268
 
269
- case "clearOnShrink":
270
- this.ctx.ui.setClearOnShrink(value as boolean);
271
- break;
272
-
273
269
  case "autocompleteMaxVisible":
274
270
  this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value));
275
271
  break;