@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.1

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 (176) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  4. package/dist/types/commit/analysis/summary.d.ts +2 -2
  5. package/dist/types/commit/changelog/generate.d.ts +2 -2
  6. package/dist/types/commit/changelog/index.d.ts +2 -2
  7. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  8. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  10. package/dist/types/commit/model-selection.d.ts +10 -4
  11. package/dist/types/config/api-key-resolver.d.ts +34 -0
  12. package/dist/types/config/model-registry.d.ts +17 -1
  13. package/dist/types/config/settings-schema.d.ts +9 -0
  14. package/dist/types/dap/config.d.ts +14 -1
  15. package/dist/types/dap/types.d.ts +10 -0
  16. package/dist/types/lsp/utils.d.ts +3 -2
  17. package/dist/types/modes/components/chat-block.d.ts +64 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  19. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  20. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  21. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  22. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  23. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  27. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  28. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  29. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  30. package/dist/types/modes/interactive-mode.d.ts +15 -5
  31. package/dist/types/modes/theme/theme.d.ts +1 -1
  32. package/dist/types/modes/types.d.ts +18 -5
  33. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  34. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  35. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  36. package/dist/types/sdk.d.ts +2 -0
  37. package/dist/types/session/agent-session.d.ts +21 -0
  38. package/dist/types/session/messages.d.ts +12 -0
  39. package/dist/types/session/session-manager.d.ts +3 -1
  40. package/dist/types/slash-commands/types.d.ts +4 -6
  41. package/dist/types/task/executor.d.ts +7 -0
  42. package/dist/types/task/index.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +3 -2
  44. package/dist/types/tools/archive-reader.d.ts +5 -0
  45. package/dist/types/tools/ast-edit.d.ts +3 -0
  46. package/dist/types/tools/ast-grep.d.ts +3 -0
  47. package/dist/types/tools/bash.d.ts +1 -0
  48. package/dist/types/tools/find.d.ts +8 -4
  49. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  50. package/dist/types/tools/memory-render.d.ts +4 -1
  51. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  52. package/dist/types/tools/render-utils.d.ts +5 -9
  53. package/dist/types/tools/search.d.ts +4 -0
  54. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  55. package/dist/types/tools/todo.d.ts +3 -2
  56. package/dist/types/tools/write.d.ts +3 -0
  57. package/dist/types/tui/output-block.d.ts +16 -4
  58. package/dist/types/tui/status-line.d.ts +3 -0
  59. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  60. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  61. package/package.json +9 -9
  62. package/src/auto-thinking/classifier.ts +5 -1
  63. package/src/cli/dry-balance-cli.ts +52 -17
  64. package/src/cli/gallery-cli.ts +4 -1
  65. package/src/cli/gallery-fixtures/misc.ts +29 -0
  66. package/src/commit/analysis/conventional.ts +2 -2
  67. package/src/commit/analysis/summary.ts +2 -2
  68. package/src/commit/changelog/generate.ts +2 -2
  69. package/src/commit/changelog/index.ts +2 -2
  70. package/src/commit/map-reduce/index.ts +3 -3
  71. package/src/commit/map-reduce/map-phase.ts +2 -2
  72. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  73. package/src/commit/model-selection.ts +33 -9
  74. package/src/commit/pipeline.ts +4 -4
  75. package/src/config/api-key-resolver.ts +58 -0
  76. package/src/config/model-registry.ts +25 -2
  77. package/src/config/settings-schema.ts +10 -0
  78. package/src/config/settings.ts +20 -2
  79. package/src/dap/config.ts +41 -2
  80. package/src/dap/defaults.json +1 -0
  81. package/src/dap/session.ts +1 -0
  82. package/src/dap/types.ts +10 -0
  83. package/src/debug/index.ts +40 -54
  84. package/src/edit/renderer.ts +82 -78
  85. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  86. package/src/eval/llm-bridge.ts +8 -3
  87. package/src/goals/tools/goal-tool.ts +36 -26
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/lsp/utils.ts +3 -2
  90. package/src/main.ts +9 -7
  91. package/src/memories/index.ts +12 -5
  92. package/src/mnemopi/backend.ts +5 -1
  93. package/src/modes/acp/acp-agent.ts +33 -26
  94. package/src/modes/components/assistant-message.ts +2 -9
  95. package/src/modes/components/chat-block.ts +111 -0
  96. package/src/modes/components/copy-selector.ts +1 -44
  97. package/src/modes/components/custom-editor.ts +23 -0
  98. package/src/modes/components/custom-message.ts +1 -3
  99. package/src/modes/components/execution-shared.ts +1 -2
  100. package/src/modes/components/hook-message.ts +1 -3
  101. package/src/modes/components/overlay-box.ts +108 -0
  102. package/src/modes/components/plan-review-overlay.ts +799 -0
  103. package/src/modes/components/plan-toc.ts +138 -0
  104. package/src/modes/components/read-tool-group.ts +20 -4
  105. package/src/modes/components/skill-message.ts +0 -1
  106. package/src/modes/components/tips.txt +1 -0
  107. package/src/modes/components/todo-reminder.ts +0 -2
  108. package/src/modes/components/tool-execution.ts +68 -88
  109. package/src/modes/components/transcript-container.ts +84 -24
  110. package/src/modes/components/user-message.ts +1 -2
  111. package/src/modes/controllers/command-controller-shared.ts +7 -6
  112. package/src/modes/controllers/command-controller.ts +57 -55
  113. package/src/modes/controllers/event-controller.ts +41 -40
  114. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  115. package/src/modes/controllers/input-controller.ts +124 -119
  116. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  117. package/src/modes/controllers/selector-controller.ts +23 -25
  118. package/src/modes/controllers/streaming-reveal.ts +212 -0
  119. package/src/modes/controllers/tan-command-controller.ts +173 -0
  120. package/src/modes/interactive-mode.ts +169 -94
  121. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  122. package/src/modes/theme/theme-schema.json +1 -1
  123. package/src/modes/theme/theme.ts +8 -4
  124. package/src/modes/types.ts +18 -7
  125. package/src/modes/utils/copy-targets.ts +133 -27
  126. package/src/modes/utils/ui-helpers.ts +44 -46
  127. package/src/plan-mode/approved-plan.ts +66 -43
  128. package/src/plan-mode/plan-protection.ts +4 -4
  129. package/src/prompts/system/background-tan-dispatch.md +8 -0
  130. package/src/prompts/system/plan-mode-active.md +67 -58
  131. package/src/prompts/system/plan-mode-approved.md +1 -1
  132. package/src/sdk.ts +11 -37
  133. package/src/session/agent-session.ts +82 -6
  134. package/src/session/messages.ts +26 -0
  135. package/src/session/session-manager.ts +13 -5
  136. package/src/slash-commands/builtin-registry.ts +36 -9
  137. package/src/slash-commands/types.ts +4 -6
  138. package/src/task/executor.ts +5 -2
  139. package/src/task/index.ts +4 -0
  140. package/src/task/render.ts +212 -147
  141. package/src/tools/archive-reader.ts +64 -0
  142. package/src/tools/ask.ts +119 -164
  143. package/src/tools/ast-edit.ts +98 -71
  144. package/src/tools/ast-grep.ts +37 -43
  145. package/src/tools/bash.ts +50 -6
  146. package/src/tools/debug.ts +20 -8
  147. package/src/tools/fetch.ts +297 -7
  148. package/src/tools/find.ts +44 -30
  149. package/src/tools/gh-renderer.ts +81 -42
  150. package/src/tools/grouped-file-output.ts +272 -48
  151. package/src/tools/image-gen.ts +150 -103
  152. package/src/tools/inspect-image-renderer.ts +63 -41
  153. package/src/tools/inspect-image.ts +8 -1
  154. package/src/tools/job.ts +3 -4
  155. package/src/tools/memory-render.ts +4 -1
  156. package/src/tools/plan-mode-guard.ts +21 -39
  157. package/src/tools/read.ts +23 -16
  158. package/src/tools/render-utils.ts +21 -37
  159. package/src/tools/resolve.ts +14 -0
  160. package/src/tools/search-tool-bm25.ts +36 -23
  161. package/src/tools/search.ts +80 -78
  162. package/src/tools/sqlite-reader.ts +9 -12
  163. package/src/tools/todo.ts +118 -52
  164. package/src/tools/write.ts +81 -62
  165. package/src/tui/output-block.ts +60 -13
  166. package/src/tui/status-line.ts +5 -1
  167. package/src/utils/commit-message-generator.ts +9 -1
  168. package/src/utils/enhanced-paste.ts +202 -0
  169. package/src/utils/title-generator.ts +2 -1
  170. package/src/web/search/providers/anthropic.ts +25 -19
  171. package/src/web/search/providers/exa.ts +11 -3
  172. package/src/web/search/providers/kimi.ts +28 -17
  173. package/src/web/search/providers/parallel.ts +35 -24
  174. package/src/web/search/providers/synthetic.ts +8 -6
  175. package/src/web/search/providers/tavily.ts +9 -8
  176. package/src/web/search/providers/zai.ts +8 -6
@@ -39,6 +39,26 @@ function isBlockAppendOnly(child: Component): boolean {
39
39
  return fn ? fn.call(child) : false;
40
40
  }
41
41
 
42
+ // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
43
+ // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
44
+ // to a background-colored padding row, whose escape sequences contain `\S` and
45
+ // are therefore preserved as part of a block's visual design.
46
+ const NON_WHITESPACE = /\S/;
47
+ function isPlainBlank(line: string): boolean {
48
+ return !NON_WHITESPACE.test(line);
49
+ }
50
+
51
+ // Strip leading/trailing plain-blank rows so each block contributes only its
52
+ // visible body; the container owns the gaps between blocks. Returns the input
53
+ // array unchanged when there is nothing to trim (no allocation on the hot path).
54
+ function stripPlainBlankEdges(lines: string[]): string[] {
55
+ let start = 0;
56
+ let end = lines.length;
57
+ while (start < end && isPlainBlank(lines[start]!)) start++;
58
+ while (end > start && isPlainBlank(lines[end - 1]!)) end--;
59
+ return start === 0 && end === lines.length ? lines : lines.slice(start, end);
60
+ }
61
+
42
62
  /**
43
63
  * Transcript container that freezes the rendered output of every block except
44
64
  * the bottom-most (live) one on terminals where committed native scrollback is
@@ -118,9 +138,13 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
118
138
  width = Math.max(1, width);
119
139
  this.#nativeScrollbackLiveRegionStart = undefined;
120
140
  this.#nativeScrollbackCommitSafeEnd = undefined;
121
- if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
122
141
 
142
+ // Freezing/snapshotting only applies on ED3-risk terminals; elsewhere every
143
+ // block renders live. Inter-block spacing applies on BOTH paths so the gap
144
+ // between blocks is identical regardless of terminal.
145
+ const risk = TERMINAL.eagerEraseScrollbackRisk;
123
146
  const count = this.children.length;
147
+
124
148
  // The live region spans from the earliest still-mutating block through the
125
149
  // bottom. A block that has not finalized must stay repaintable: out-of-band
126
150
  // inserts (TTSR/todo cards) can append a finalized block *below* a tool that
@@ -137,45 +161,81 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
137
161
  // recompute them so they freeze at their final content. Everything below
138
162
  // the lower of the two cutoffs was already frozen last frame and replays.
139
163
  const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
140
- this.#prevLiveStartIndex = liveStartIndex;
164
+ if (risk) this.#prevLiveStartIndex = liveStartIndex;
141
165
 
142
166
  const lines: string[] = [];
143
167
  // Tracks whether we are still inside the leading run of append-only live
144
- // blocks. The first non-append-only live block (or a finalized block below
145
- // the live region's start, which cannot happen for a leading run) closes it.
168
+ // blocks. The first non-append-only live block closes it.
146
169
  let commitSafeOpen = true;
170
+ // The live-region start is recorded at the first visible row at/after the
171
+ // cutoff; empty leading blocks (or a separator) must not claim it early.
172
+ let liveRecorded = false;
147
173
  for (let i = 0; i < count; i++) {
148
174
  const child = this.children[i]! as Component & SnapshotCarrier;
149
- if (i >= liveStartIndex) {
150
- if (i === liveStartIndex) this.#nativeScrollbackLiveRegionStart = lines.length;
151
- } else {
175
+
176
+ // Resolve this child's contribution its visible body with plain-blank
177
+ // top/bottom edges stripped (the container owns inter-block gaps). On
178
+ // ED3-risk terminals a frozen, scrolled-off block replays its snapshot
179
+ // instead of recomputing; a stale generation (post-thaw) or width
180
+ // mismatch (resize) recomputes, as does a block still live last frame.
181
+ let contribution: string[] | undefined;
182
+ if (risk && i < liveStartIndex && i < replayCutoff) {
152
183
  const snapshot = child[kSnapshot];
153
- // Replay a frozen block's last live render. A stale generation
154
- // (post-thaw) or width mismatch (resize, explicit rebuild) recomputes
155
- // instead, as does a block that was still live last frame (i >= cutoff).
156
- if (i < replayCutoff && snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
157
- lines.push(...snapshot.lines);
158
- continue;
184
+ if (snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
185
+ contribution = snapshot.lines;
159
186
  }
160
187
  }
161
- const rendered = child.render(width);
188
+ if (contribution === undefined) {
189
+ const rendered = child.render(width);
190
+ contribution = stripPlainBlankEdges(rendered);
191
+ // Cache every block's latest contribution. While a block is in the
192
+ // live region this keeps its snapshot current; on the frame it crosses
193
+ // out, the recompute above refreshes it before it freezes.
194
+ if (risk) child[kSnapshot] = { width, lines: contribution, generation: this.#generation };
195
+ }
196
+
197
+ // Empty (or stripped-to-nothing) children contribute nothing and never
198
+ // affect spacing or the live-region offsets.
199
+ if (contribution.length === 0) continue;
200
+
201
+ // Every block is separated from preceding visible content by exactly one
202
+ // blank row — skipped when it opens the transcript or the prior row is
203
+ // already a plain blank (a fragment's own trailing pad), never doubling.
204
+ const sep = lines.length > 0 && !isPlainBlank(lines[lines.length - 1]!) ? 1 : 0;
205
+
206
+ // The separator before the first live block stays in the committed prefix
207
+ // (it is deterministic and never changes once the prior block is frozen),
208
+ // so the live region begins at the block's first content row.
209
+ if (risk && !liveRecorded && i >= liveStartIndex) {
210
+ this.#nativeScrollbackLiveRegionStart = lines.length + sep;
211
+ liveRecorded = true;
212
+ }
213
+
214
+ if (sep) lines.push("");
215
+ for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
216
+
162
217
  // Extend the commit-safe boundary through each leading append-only live
163
- // block. `lines.length` here is this block's start offset; the boundary
164
- // runs to the end of its rendered rows. The first volatile live block
165
- // closes the run so its mutable rows stay deferred.
166
- if (i >= liveStartIndex && commitSafeOpen) {
218
+ // block. The first volatile live block closes the run so its mutable
219
+ // rows stay deferred.
220
+ if (risk && i >= liveStartIndex && commitSafeOpen) {
167
221
  if (isBlockAppendOnly(child)) {
168
- this.#nativeScrollbackCommitSafeEnd = lines.length + rendered.length;
222
+ this.#nativeScrollbackCommitSafeEnd = lines.length;
169
223
  } else {
170
224
  commitSafeOpen = false;
171
225
  }
172
226
  }
173
- // Cache every block's latest render. While a block is in the live region
174
- // this keeps its snapshot current; on the frame it crosses out, the
175
- // recompute above refreshes it to the final state before it freezes.
176
- child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
177
- lines.push(...rendered);
178
227
  }
179
228
  return lines;
180
229
  }
181
230
  }
231
+
232
+ /**
233
+ * Groups a run of sibling rows (an IRC card's header + body, a file-mention
234
+ * list, a bordered command/version panel) into a single transcript child so the
235
+ * container spaces it as one block — one blank line above, none injected between
236
+ * its rows. Without this wrapper the rows would be top-level children and the
237
+ * container would put a blank line between each (and inside any border box).
238
+ * It is a plain {@link Container}; the named subclass documents intent and makes
239
+ * every manual block grouping greppable.
240
+ */
241
+ export class TranscriptBlock extends Container {}
@@ -1,4 +1,4 @@
1
- import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
1
+ import { Container, Markdown } from "@oh-my-pi/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
3
  import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
4
4
  import { highlightMagicKeywords } from "../magic-keywords";
@@ -30,7 +30,6 @@ export class UserMessageComponent extends Container {
30
30
  renderText: baseText,
31
31
  renderReference: (label, index) => imageReferenceHyperlink(label, index, imageLinks, imageLabel),
32
32
  });
33
- this.addChild(new Spacer(1));
34
33
  this.addChild(
35
34
  new Markdown(text, 1, 1, getMarkdownTheme(), {
36
35
  bgColor,
@@ -7,10 +7,11 @@
7
7
  * wording, and add-flow logic stay in the per-controller files because they
8
8
  * diverge in workflow.
9
9
  */
10
- import { Spacer, Text } from "@oh-my-pi/pi-tui";
10
+ import { Text } from "@oh-my-pi/pi-tui";
11
11
  import type { SourceMeta } from "../../capability/types";
12
12
  import { shortenPath } from "../../tools/render-utils";
13
13
  import { DynamicBorder } from "../components/dynamic-border";
14
+ import { TranscriptBlock } from "../components/transcript-container";
14
15
  import { parseCommandArgs } from "../shared";
15
16
  import type { InteractiveModeContext } from "../types";
16
17
 
@@ -100,9 +101,9 @@ export function* groupBySource<T>(
100
101
  * container and request a render.
101
102
  */
102
103
  export function showCommandMessage(ctx: InteractiveModeContext, text: string): void {
103
- ctx.chatContainer.addChild(new Spacer(1));
104
- ctx.chatContainer.addChild(new DynamicBorder());
105
- ctx.chatContainer.addChild(new Text(text, 1, 1));
106
- ctx.chatContainer.addChild(new DynamicBorder());
107
- ctx.ui.requestRender();
104
+ const block = new TranscriptBlock();
105
+ block.addChild(new DynamicBorder());
106
+ block.addChild(new Text(text, 1, 1));
107
+ block.addChild(new DynamicBorder());
108
+ ctx.present(block);
108
109
  }
@@ -28,6 +28,7 @@ import { BashExecutionComponent } from "../../modes/components/bash-execution";
28
28
  import { BorderedLoader } from "../../modes/components/bordered-loader";
29
29
  import { DynamicBorder } from "../../modes/components/dynamic-border";
30
30
  import { EvalExecutionComponent } from "../../modes/components/eval-execution";
31
+ import { TranscriptBlock } from "../../modes/components/transcript-container";
31
32
  import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
32
33
  import type { InteractiveModeContext } from "../../modes/types";
33
34
  import { computeContextBreakdown, renderContextUsage } from "../../modes/utils/context-usage";
@@ -46,13 +47,13 @@ import { openPath } from "../../utils/open";
46
47
  import { setSessionTerminalTitle } from "../../utils/title-generator";
47
48
 
48
49
  function showMarkdownPanel(ctx: InteractiveModeContext, title: string, markdown: string): void {
49
- ctx.chatContainer.addChild(new Spacer(1));
50
- ctx.chatContainer.addChild(new DynamicBorder());
51
- ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", title)), 1, 0));
52
- ctx.chatContainer.addChild(new Spacer(1));
53
- ctx.chatContainer.addChild(new Markdown(markdown.trim(), 1, 1, getMarkdownTheme()));
54
- ctx.chatContainer.addChild(new DynamicBorder());
55
- ctx.ui.requestRender();
50
+ const block = new TranscriptBlock();
51
+ block.addChild(new DynamicBorder());
52
+ block.addChild(new Text(theme.bold(theme.fg("accent", title)), 1, 0));
53
+ block.addChild(new Spacer(1));
54
+ block.addChild(new Markdown(markdown.trim(), 1, 1, getMarkdownTheme()));
55
+ block.addChild(new DynamicBorder());
56
+ ctx.present(block);
56
57
  }
57
58
 
58
59
  export class CommandController {
@@ -334,9 +335,7 @@ export class CommandController {
334
335
  }
335
336
  }
336
337
 
337
- this.ctx.chatContainer.addChild(new Spacer(1));
338
- this.ctx.chatContainer.addChild(new Text(info, 1, 0));
339
- this.ctx.ui.requestRender();
338
+ this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
340
339
  }
341
340
 
342
341
  async handleJobsCommand(): Promise<void> {
@@ -353,9 +352,7 @@ export class CommandController {
353
352
 
354
353
  if (snapshot.running.length === 0 && snapshot.recent.length === 0) {
355
354
  info += `\n${theme.fg("dim", "No async jobs yet.")}\n`;
356
- this.ctx.chatContainer.addChild(new Spacer(1));
357
- this.ctx.chatContainer.addChild(new Text(info, 1, 0));
358
- this.ctx.ui.requestRender();
355
+ this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
359
356
  return;
360
357
  }
361
358
 
@@ -375,9 +372,7 @@ export class CommandController {
375
372
  }
376
373
  }
377
374
 
378
- this.ctx.chatContainer.addChild(new Spacer(1));
379
- this.ctx.chatContainer.addChild(new Text(info.trimEnd(), 1, 0));
380
- this.ctx.ui.requestRender();
375
+ this.ctx.present([new Spacer(1), new Text(info.trimEnd(), 1, 0)]);
381
376
  }
382
377
 
383
378
  async handleUsageCommand(reports?: UsageReport[] | null): Promise<void> {
@@ -403,9 +398,7 @@ export class CommandController {
403
398
 
404
399
  const availableWidth = Math.max(40, (this.ctx.ui.terminal.columns ?? 100) - 2);
405
400
  const output = renderUsageReports(usageReports, theme, Date.now(), availableWidth);
406
- this.ctx.chatContainer.addChild(new Spacer(1));
407
- this.ctx.chatContainer.addChild(new Text(output, 1, 0));
408
- this.ctx.ui.requestRender();
401
+ this.ctx.present([new Spacer(1), new Text(output, 1, 0)]);
409
402
  }
410
403
 
411
404
  async handleChangelogCommand(showFull = false): Promise<void> {
@@ -426,13 +419,13 @@ export class CommandController {
426
419
  ? ""
427
420
  : `\n\n${theme.fg("dim", "Use")} ${theme.bold("/changelog full")} ${theme.fg("dim", "to view the complete changelog.")}`;
428
421
 
429
- this.ctx.chatContainer.addChild(new Spacer(1));
430
- this.ctx.chatContainer.addChild(new DynamicBorder());
431
- this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", title)), 1, 0));
432
- this.ctx.chatContainer.addChild(new Spacer(1));
433
- this.ctx.chatContainer.addChild(new Markdown(changelogMarkdown + hint, 1, 1, getMarkdownTheme()));
434
- this.ctx.chatContainer.addChild(new DynamicBorder());
435
- this.ctx.ui.requestRender();
422
+ const block = new TranscriptBlock();
423
+ block.addChild(new DynamicBorder());
424
+ block.addChild(new Text(theme.bold(theme.fg("accent", title)), 1, 0));
425
+ block.addChild(new Spacer(1));
426
+ block.addChild(new Markdown(changelogMarkdown + hint, 1, 1, getMarkdownTheme()));
427
+ block.addChild(new DynamicBorder());
428
+ this.ctx.present(block);
436
429
  }
437
430
 
438
431
  handleHotkeysCommand(): void {
@@ -452,13 +445,13 @@ export class CommandController {
452
445
  return;
453
446
  }
454
447
  const output = renderContextUsage(breakdown, theme);
455
- this.ctx.chatContainer.addChild(new Spacer(1));
456
- this.ctx.chatContainer.addChild(new DynamicBorder());
457
- this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Context Usage")), 1, 0));
458
- this.ctx.chatContainer.addChild(new Spacer(1));
459
- this.ctx.chatContainer.addChild(new Text(output, 1, 0));
460
- this.ctx.chatContainer.addChild(new DynamicBorder());
461
- this.ctx.ui.requestRender();
448
+ const block = new TranscriptBlock();
449
+ block.addChild(new DynamicBorder());
450
+ block.addChild(new Text(theme.bold(theme.fg("accent", "Context Usage")), 1, 0));
451
+ block.addChild(new Spacer(1));
452
+ block.addChild(new Text(output, 1, 0));
453
+ block.addChild(new DynamicBorder());
454
+ this.ctx.present(block);
462
455
  }
463
456
 
464
457
  async handleMemoryCommand(text: string): Promise<void> {
@@ -473,13 +466,13 @@ export class CommandController {
473
466
  this.ctx.showWarning("Memory payload is empty (memory backend off, disabled, or no memory available).");
474
467
  return;
475
468
  }
476
- this.ctx.chatContainer.addChild(new Spacer(1));
477
- this.ctx.chatContainer.addChild(new DynamicBorder());
478
- this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Memory Injection Payload")), 1, 0));
479
- this.ctx.chatContainer.addChild(new Spacer(1));
480
- this.ctx.chatContainer.addChild(new Markdown(payload, 1, 1, getMarkdownTheme()));
481
- this.ctx.chatContainer.addChild(new DynamicBorder());
482
- this.ctx.ui.requestRender();
469
+ const block = new TranscriptBlock();
470
+ block.addChild(new DynamicBorder());
471
+ block.addChild(new Text(theme.bold(theme.fg("accent", "Memory Injection Payload")), 1, 0));
472
+ block.addChild(new Spacer(1));
473
+ block.addChild(new Markdown(payload, 1, 1, getMarkdownTheme()));
474
+ block.addChild(new DynamicBorder());
475
+ this.ctx.present(block);
483
476
  return;
484
477
  }
485
478
 
@@ -800,8 +793,7 @@ export class CommandController {
800
793
  this.ctx.streamingMessage = undefined;
801
794
  this.ctx.pendingTools.clear();
802
795
 
803
- this.ctx.chatContainer.addChild(new Spacer(1));
804
- this.ctx.chatContainer.addChild(new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1));
796
+ this.ctx.present([new Spacer(1), new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1)]);
805
797
  await this.ctx.reloadTodos();
806
798
  this.ctx.ui.requestRender(true, { clearScrollback: true });
807
799
  }
@@ -810,6 +802,18 @@ export class CommandController {
810
802
  await this.#runNewSessionFlow();
811
803
  }
812
804
 
805
+ async handleFreshCommand(): Promise<void> {
806
+ const result = this.ctx.session.freshSession();
807
+ if (!result) {
808
+ this.ctx.showWarning("Wait for the current response to finish or abort it before refreshing provider state.");
809
+ return;
810
+ }
811
+ const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states";
812
+ this.ctx.statusLine.invalidate();
813
+ this.ctx.updateEditorTopBorder();
814
+ this.ctx.showStatus(`Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`);
815
+ }
816
+
813
817
  async handleDropCommand(): Promise<void> {
814
818
  if (!this.ctx.sessionManager.getSessionFile()) {
815
819
  this.ctx.showError("Nothing to drop (in-memory session)");
@@ -840,11 +844,10 @@ export class CommandController {
840
844
 
841
845
  const sessionFile = this.ctx.session.sessionFile;
842
846
  const shortPath = sessionFile ? sessionFile.split("/").pop() : "new session";
843
- this.ctx.chatContainer.addChild(new Spacer(1));
844
- this.ctx.chatContainer.addChild(
847
+ this.ctx.present([
848
+ new Spacer(1),
845
849
  new Text(`${theme.fg("accent", `${theme.status.success} Session forked to ${shortPath}`)}`, 1, 1),
846
- );
847
- this.ctx.ui.requestRender();
850
+ ]);
848
851
  }
849
852
 
850
853
  async handleMoveCommand(targetPath: string): Promise<void> {
@@ -878,11 +881,10 @@ export class CommandController {
878
881
  await this.ctx.sessionManager.moveTo(resolvedPath);
879
882
  await this.ctx.applyCwdChange(resolvedPath);
880
883
 
881
- this.ctx.chatContainer.addChild(new Spacer(1));
882
- this.ctx.chatContainer.addChild(
884
+ this.ctx.present([
885
+ new Spacer(1),
883
886
  new Text(`${theme.fg("accent", `${theme.status.success} Session moved to ${resolvedPath}`)}`, 1, 1),
884
- );
885
- this.ctx.ui.requestRender();
887
+ ]);
886
888
  } catch (err) {
887
889
  this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
888
890
  }
@@ -913,7 +915,7 @@ export class CommandController {
913
915
  this.ctx.pendingMessagesContainer.addChild(this.ctx.bashComponent);
914
916
  this.ctx.pendingBashComponents.push(this.ctx.bashComponent);
915
917
  } else {
916
- this.ctx.chatContainer.addChild(this.ctx.bashComponent);
918
+ this.ctx.present(this.ctx.bashComponent);
917
919
  }
918
920
  this.ctx.ui.requestRender();
919
921
 
@@ -954,7 +956,7 @@ export class CommandController {
954
956
  this.ctx.pendingMessagesContainer.addChild(this.ctx.pythonComponent);
955
957
  this.ctx.pendingPythonComponents.push(this.ctx.pythonComponent);
956
958
  } else {
957
- this.ctx.chatContainer.addChild(this.ctx.pythonComponent);
959
+ this.ctx.present(this.ctx.pythonComponent);
958
960
  }
959
961
  this.ctx.ui.requestRender();
960
962
 
@@ -1143,10 +1145,10 @@ export class CommandController {
1143
1145
  this.ctx.updateEditorBorderColor();
1144
1146
  await this.ctx.reloadTodos();
1145
1147
 
1146
- this.ctx.chatContainer.addChild(new Spacer(1));
1147
- this.ctx.chatContainer.addChild(
1148
+ this.ctx.present([
1149
+ new Spacer(1),
1148
1150
  new Text(`${theme.fg("accent", `${theme.status.success} New session started with handoff context`)}`, 1, 1),
1149
- );
1151
+ ]);
1150
1152
  if (result.savedPath) {
1151
1153
  this.ctx.showStatus(`Handoff document saved to: ${result.savedPath}`);
1152
1154
  }
@@ -1,7 +1,7 @@
1
1
  import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
2
2
  import { calculatePromptTokens } from "@oh-my-pi/pi-agent-core/compaction/compaction";
3
3
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
4
- import { type Component, Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
4
+ import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
5
5
  import { settings } from "../../config/settings";
6
6
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
7
7
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
@@ -17,9 +17,10 @@ import { getSymbolTheme, theme } from "../../modes/theme/theme";
17
17
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
18
18
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
19
19
  import type { AgentSessionEvent } from "../../session/agent-session";
20
- import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
20
+ import { isSilentAbort, readPendingDisplayTag, resolveAbortLabel } from "../../session/messages";
21
21
  import type { ResolveToolDetails } from "../../tools/resolve";
22
22
  import { interruptHint } from "../shared";
23
+ import { StreamingRevealController } from "./streaming-reveal";
23
24
 
24
25
  type AgentSessionEventKind = AgentSessionEvent["type"];
25
26
 
@@ -44,7 +45,13 @@ type AgentSessionEventHandlers = {
44
45
 
45
46
  export class EventController {
46
47
  #lastReadGroup: ReadToolGroupComponent | undefined = undefined;
47
- #lastThinkingCount = 0;
48
+ // Count of visible assistant content blocks (rendered non-empty text/thinking)
49
+ // already seen in the current streaming message. A newly appearing one breaks
50
+ // the read run: the rendered reasoning/answer is a visual separator, so reads
51
+ // after it start a fresh group. Empty/absent thinking — common when a model
52
+ // emits one read per completion — does not break it, so a run of consecutive
53
+ // reads collapses into one group even across completion boundaries.
54
+ #lastVisibleBlockCount = 0;
48
55
  #renderedCustomMessages = new Set<string>();
49
56
  #lastIntent: string | undefined = undefined;
50
57
  #backgroundToolCallIds = new Set<string>();
@@ -60,9 +67,15 @@ export class EventController {
60
67
  #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
61
68
  #idleCompactionTimer?: NodeJS.Timeout;
62
69
  #ircExpiryTimers = new Map<string, NodeJS.Timeout>();
70
+ #streamingReveal: StreamingRevealController;
63
71
  #handlers: AgentSessionEventHandlers;
64
72
 
65
73
  constructor(private ctx: InteractiveModeContext) {
74
+ this.#streamingReveal = new StreamingRevealController({
75
+ getSmoothStreaming: () => this.ctx.settings.get("display.smoothStreaming"),
76
+ getHideThinkingBlock: () => this.ctx.hideThinkingBlock,
77
+ requestRender: () => this.ctx.ui.requestRender(),
78
+ });
66
79
  this.#handlers = {
67
80
  agent_start: e => this.#handleAgentStart(e),
68
81
  agent_end: e => this.#handleAgentEnd(e),
@@ -95,6 +108,7 @@ export class EventController {
95
108
  }
96
109
 
97
110
  dispose(): void {
111
+ this.#streamingReveal.stop();
98
112
  this.#cancelIdleCompaction();
99
113
  for (const timer of this.#ircExpiryTimers.values()) {
100
114
  clearTimeout(timer);
@@ -103,12 +117,12 @@ export class EventController {
103
117
  }
104
118
 
105
119
  #resetReadGroup(): void {
120
+ this.#lastReadGroup?.finalize();
106
121
  this.#lastReadGroup = undefined;
107
122
  }
108
123
 
109
124
  #getReadGroup(): ReadToolGroupComponent {
110
125
  if (!this.#lastReadGroup) {
111
- this.ctx.chatContainer.addChild(new Text("", 0, 0));
112
126
  const group = new ReadToolGroupComponent({
113
127
  showContentPreview: this.ctx.settings.get("read.toolResultPreview"),
114
128
  });
@@ -209,6 +223,7 @@ export class EventController {
209
223
  this.#lastIntent = undefined;
210
224
  this.#readToolCallArgs.clear();
211
225
  this.#readToolCallAssistantComponents.clear();
226
+ this.#resetReadGroup();
212
227
  this.#assistantMessageStreaming = false;
213
228
  this.#lastAssistantComponent = undefined;
214
229
  // Restore the previous turn's inline error in the transcript before dropping
@@ -299,9 +314,8 @@ export class EventController {
299
314
  this.ctx.addMessageToChat(event.message);
300
315
  this.ctx.ui.requestRender();
301
316
  } else if (event.message.role === "assistant") {
302
- this.#lastThinkingCount = 0;
303
317
  this.#assistantMessageStreaming = true;
304
- this.#resetReadGroup();
318
+ this.#lastVisibleBlockCount = 0;
305
319
  this.ctx.streamingComponent = new AssistantMessageComponent(
306
320
  undefined,
307
321
  this.ctx.hideThinkingBlock,
@@ -311,7 +325,7 @@ export class EventController {
311
325
  );
312
326
  this.ctx.streamingMessage = event.message;
313
327
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
314
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
328
+ this.#streamingReveal.begin(this.ctx.streamingComponent, this.ctx.streamingMessage);
315
329
  this.ctx.ui.requestRender();
316
330
  }
317
331
  }
@@ -355,16 +369,17 @@ export class EventController {
355
369
  async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
356
370
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
357
371
  this.ctx.streamingMessage = event.message;
358
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
372
+ this.#streamingReveal.setTarget(this.ctx.streamingMessage);
359
373
 
360
- const thinkingCount = this.ctx.streamingMessage.content.filter(
361
- content => content.type === "thinking" && content.thinking.trim(),
374
+ const visibleBlockCount = this.ctx.streamingMessage.content.filter(
375
+ content =>
376
+ (content.type === "text" && content.text.trim().length > 0) ||
377
+ (content.type === "thinking" && content.thinking.trim().length > 0),
362
378
  ).length;
363
- if (thinkingCount > this.#lastThinkingCount) {
379
+ if (visibleBlockCount > this.#lastVisibleBlockCount) {
364
380
  this.#resetReadGroup();
365
- this.#lastThinkingCount = thinkingCount;
381
+ this.#lastVisibleBlockCount = visibleBlockCount;
366
382
  }
367
-
368
383
  for (const content of this.ctx.streamingMessage.content) {
369
384
  if (content.type !== "toolCall") continue;
370
385
  if (content.name === "read") {
@@ -397,7 +412,6 @@ export class EventController {
397
412
  : content.arguments;
398
413
  if (!this.ctx.pendingTools.has(content.id)) {
399
414
  this.#resetReadGroup();
400
- this.ctx.chatContainer.addChild(new Text("", 0, 0));
401
415
  const tool = this.ctx.session.getToolByName(content.name);
402
416
  const component = new ToolExecutionComponent(
403
417
  content.name,
@@ -456,21 +470,20 @@ export class EventController {
456
470
  }
457
471
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
458
472
  this.ctx.streamingMessage = event.message;
473
+ this.#streamingReveal.stop();
459
474
  let errorMessage: string | undefined;
460
475
  const aborted = this.ctx.streamingMessage.stopReason === "aborted";
461
476
  const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage);
462
477
  const ttsrSilenced = aborted && this.ctx.session.isTtsrAbortPending;
463
478
  if (aborted && !silentlyAborted && !ttsrSilenced) {
464
- // Real user-cancel / network / provider abort: surface the standard
465
- // operator-facing label. AgentSession.#handleAgentEvent already stamped
466
- // SILENT_ABORT_MARKER for the plan-compact transition before this
467
- // controller ran, so reaching this branch implies the abort was NOT a
468
- // silent internal transition.
469
- const retryAttempt = this.ctx.session.retryAttempt;
470
- errorMessage =
471
- retryAttempt > 0
472
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
473
- : "Operation aborted";
479
+ // Resolve the operator-facing label: a user-interrupt (Esc) abort
480
+ // carries USER_INTERRUPT_LABEL on errorMessage (threaded through the
481
+ // AbortController), which is preserved verbatim; any other abort with
482
+ // no threaded reason falls back to the retry-aware generic label.
483
+ // AgentSession.#handleAgentEvent already stamped SILENT_ABORT_MARKER for
484
+ // the plan-compact transition before this controller ran, so reaching
485
+ // this branch implies the abort was NOT a silent internal transition.
486
+ errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.session.retryAttempt);
474
487
  this.ctx.streamingMessage.errorMessage = errorMessage;
475
488
  }
476
489
  if (silentlyAborted || ttsrSilenced) {
@@ -663,6 +676,7 @@ export class EventController {
663
676
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
664
677
  this.#agentTurnActive = false;
665
678
  this.#assistantMessageStreaming = false;
679
+ this.#streamingReveal.stop();
666
680
  if (this.ctx.loadingAnimation) {
667
681
  this.ctx.loadingAnimation.stop();
668
682
  this.ctx.loadingAnimation = undefined;
@@ -689,6 +703,7 @@ export class EventController {
689
703
  );
690
704
  this.#readToolCallArgs.clear();
691
705
  this.#readToolCallAssistantComponents.clear();
706
+ this.#resetReadGroup();
692
707
  this.#lastAssistantComponent = undefined;
693
708
  this.ctx.ui.requestRender();
694
709
  this.#scheduleIdleCompaction();
@@ -832,14 +847,12 @@ export class EventController {
832
847
  async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
833
848
  const component = new TtsrNotificationComponent(event.rules);
834
849
  component.setExpanded(this.ctx.toolOutputExpanded);
835
- this.ctx.chatContainer.addChild(component);
836
- this.ctx.ui.requestRender();
850
+ this.ctx.present(component);
837
851
  }
838
852
 
839
853
  async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
840
854
  const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
841
- this.ctx.chatContainer.addChild(component);
842
- this.ctx.ui.requestRender();
855
+ this.ctx.present(component);
843
856
  }
844
857
 
845
858
  async #handleTodoAutoClear(_event: Extract<AgentSessionEvent, { type: "todo_auto_clear" }>): Promise<void> {
@@ -892,7 +905,6 @@ export class EventController {
892
905
  }
893
906
 
894
907
  sendCompletionNotification(): void {
895
- if (this.ctx.isBackgrounded === false) return;
896
908
  const notify = settings.get("completion.notify");
897
909
  if (notify === "off") return;
898
910
 
@@ -911,15 +923,4 @@ export class EventController {
911
923
  actions: "focus",
912
924
  });
913
925
  }
914
-
915
- async handleBackgroundEvent(event: AgentSessionEvent): Promise<void> {
916
- if (event.type !== "agent_end") {
917
- return;
918
- }
919
- if (this.ctx.session.queuedMessageCount > 0 || this.ctx.session.isStreaming) {
920
- return;
921
- }
922
- this.sendCompletionNotification();
923
- await this.ctx.shutdown();
924
- }
925
926
  }