@oh-my-pi/pi-coding-agent 17.0.0 → 17.0.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 (101) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/cli.js +3305 -3302
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/cli-commands.d.ts +14 -1
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/models-config-schema.d.ts +10 -0
  7. package/dist/types/config/models-config.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +1 -1
  9. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  10. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  11. package/dist/types/lib/xai-http.d.ts +11 -0
  12. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  13. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  14. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  15. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  17. package/dist/types/modes/components/hook-editor.d.ts +9 -2
  18. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  19. package/dist/types/modes/components/session-selector.d.ts +2 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  21. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  22. package/dist/types/modes/interactive-mode.d.ts +6 -0
  23. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  24. package/dist/types/modes/types.d.ts +10 -0
  25. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  26. package/dist/types/session/agent-session.d.ts +9 -1
  27. package/dist/types/session/streaming-output.d.ts +2 -0
  28. package/dist/types/slash-commands/types.d.ts +4 -4
  29. package/dist/types/system-prompt.d.ts +1 -1
  30. package/dist/types/tools/browser/registry.d.ts +7 -3
  31. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +3 -1
  33. package/dist/types/tools/manage-skill.d.ts +2 -0
  34. package/dist/types/web/search/index.d.ts +2 -0
  35. package/dist/types/web/search/providers/base.d.ts +3 -0
  36. package/package.json +12 -12
  37. package/src/advisor/__tests__/advisor.test.ts +22 -12
  38. package/src/advisor/advise-tool.ts +7 -3
  39. package/src/cli/bench-cli.ts +4 -1
  40. package/src/cli/grep-cli.ts +2 -1
  41. package/src/cli-commands.ts +57 -23
  42. package/src/config/model-registry.ts +9 -0
  43. package/src/config/model-resolver.ts +43 -5
  44. package/src/config/models-config-schema.ts +1 -0
  45. package/src/config/settings-schema.ts +2 -1
  46. package/src/discovery/codex.ts +14 -4
  47. package/src/discovery/substitute-plugin-root.ts +23 -7
  48. package/src/export/html/template.js +2 -0
  49. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  50. package/src/internal-urls/agent-protocol.ts +73 -39
  51. package/src/lib/xai-http.ts +28 -2
  52. package/src/modes/acp/acp-agent.ts +28 -9
  53. package/src/modes/acp/acp-event-mapper.ts +8 -0
  54. package/src/modes/acp/acp-mode.ts +18 -4
  55. package/src/modes/components/assistant-message.ts +35 -14
  56. package/src/modes/components/chat-transcript-builder.ts +2 -0
  57. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  58. package/src/modes/components/custom-editor.ts +62 -1
  59. package/src/modes/components/hook-editor.ts +17 -2
  60. package/src/modes/components/login-dialog.test.ts +56 -0
  61. package/src/modes/components/login-dialog.ts +7 -3
  62. package/src/modes/components/plan-review-overlay.ts +51 -10
  63. package/src/modes/components/session-selector.ts +6 -0
  64. package/src/modes/components/status-line/component.ts +2 -4
  65. package/src/modes/components/tool-execution.test.ts +101 -0
  66. package/src/modes/components/tool-execution.ts +117 -9
  67. package/src/modes/components/transcript-container.ts +30 -2
  68. package/src/modes/controllers/command-controller-shared.ts +1 -1
  69. package/src/modes/controllers/command-controller.ts +1 -1
  70. package/src/modes/controllers/event-controller.ts +1 -0
  71. package/src/modes/controllers/selector-controller.ts +19 -17
  72. package/src/modes/interactive-mode.ts +66 -11
  73. package/src/modes/noninteractive-dispose.test.ts +60 -0
  74. package/src/modes/print-mode.ts +8 -3
  75. package/src/modes/rpc/rpc-mode.ts +12 -3
  76. package/src/modes/theme/theme.ts +2 -2
  77. package/src/modes/types.ts +10 -0
  78. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  79. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  80. package/src/prompts/system/system-prompt.md +1 -1
  81. package/src/sdk.ts +6 -2
  82. package/src/session/agent-session.ts +79 -34
  83. package/src/session/session-listing.ts +6 -5
  84. package/src/session/session-loader.ts +9 -0
  85. package/src/session/session-persistence.ts +11 -0
  86. package/src/session/streaming-output.ts +3 -1
  87. package/src/slash-commands/builtin-registry.ts +2 -0
  88. package/src/slash-commands/types.ts +4 -4
  89. package/src/system-prompt.ts +2 -2
  90. package/src/tools/browser/registry.ts +9 -2
  91. package/src/tools/browser/tab-supervisor.ts +65 -10
  92. package/src/tools/browser.ts +4 -3
  93. package/src/tools/eval.ts +3 -0
  94. package/src/tools/image-gen.ts +561 -512
  95. package/src/tools/index.ts +3 -1
  96. package/src/tools/manage-skill.ts +5 -3
  97. package/src/tools/output-meta.ts +7 -0
  98. package/src/tools/path-utils.ts +8 -5
  99. package/src/web/search/index.ts +21 -6
  100. package/src/web/search/providers/base.ts +3 -0
  101. package/src/web/search/providers/xai.ts +40 -9
@@ -7,7 +7,7 @@
7
7
  * (Ctrl+Q / Ctrl+Enter) submits, bordered popup
8
8
  * - Prompt-style (ask): Enter submits, Shift+Enter inserts newline, legacy ask chrome
9
9
  */
10
- import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
10
+ import { Container, Editor, type Focusable, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
11
11
  import { getEditorTheme, theme } from "../../modes/theme/theme";
12
12
  import {
13
13
  matchesAppExternalEditor,
@@ -22,12 +22,15 @@ export interface HookEditorOptions {
22
22
  promptStyle?: boolean;
23
23
  }
24
24
 
25
- export class HookEditorComponent extends Container {
25
+ /** Interactive multiline dialog used by hooks and the ask tool's Other response. */
26
+ export class HookEditorComponent extends Container implements Focusable {
26
27
  #editor: Editor;
27
28
  #onSubmitCallback: (value: string) => void;
28
29
  #onCancelCallback: () => void;
29
30
  #tui: TUI;
30
31
  #promptStyle: boolean;
32
+ /** Focus state mirrored to the nested editor during rendering. */
33
+ focused = false;
31
34
 
32
35
  constructor(
33
36
  tui: TUI,
@@ -75,6 +78,18 @@ export class HookEditorComponent extends Container {
75
78
  this.addChild(new DynamicBorder());
76
79
  }
77
80
 
81
+ /** Keep the nested editor's software/hardware cursor mode aligned with the dialog focus target. */
82
+ setUseTerminalCursor(useTerminalCursor: boolean): void {
83
+ if (this.#editor.getUseTerminalCursor() === useTerminalCursor) return;
84
+ this.#editor.setUseTerminalCursor(useTerminalCursor);
85
+ }
86
+
87
+ /** Render the dialog after forwarding its focus state to the nested editor. */
88
+ override render(width: number): readonly string[] {
89
+ this.#editor.focused = this.focused;
90
+ return super.render(width);
91
+ }
92
+
78
93
  handleInput(keyData: string): void {
79
94
  if (this.#promptStyle) {
80
95
  this.#handlePromptStyleInput(keyData);
@@ -0,0 +1,56 @@
1
+ import { beforeAll, describe, expect, it } from "bun:test";
2
+ import type { TUI } from "@oh-my-pi/pi-tui";
3
+ import { initTheme } from "../theme/theme";
4
+ import { LoginDialogComponent } from "./login-dialog";
5
+
6
+ /** Minimal TUI stub — the dialog only calls requestRender/setFocus. */
7
+ function makeDialog(): LoginDialogComponent {
8
+ const tui = { requestRender() {}, setFocus() {} } as unknown as TUI;
9
+ return new LoginDialogComponent(tui, "openai-codex", () => {});
10
+ }
11
+
12
+ describe("LoginDialogComponent manual code input", () => {
13
+ beforeAll(async () => {
14
+ await initTheme();
15
+ });
16
+
17
+ it("captures a pasted fallback redirect URL and resolves on submit", async () => {
18
+ // Regression for #5339: paste-code providers (Codex) route the fallback
19
+ // URL through the focused dialog. Without a mounted input, the paste is
20
+ // dropped and login never completes.
21
+ const dialog = makeDialog();
22
+ dialog.showProgress("Waiting for callback");
23
+
24
+ const pending = dialog.showManualInput("Paste the authorization code:");
25
+ expect(dialog.render(80).join("\n")).toContain("Paste the authorization code");
26
+
27
+ const url = "http://localhost:1455/auth/callback?code=THECODE&state=abc";
28
+ dialog.pasteText(url);
29
+ dialog.handleInput("\r");
30
+
31
+ expect(await pending).toBe(url);
32
+ });
33
+
34
+ it("reuses the mounted input across re-prompts instead of stacking duplicates", async () => {
35
+ // The OAuth callback loop re-invokes onManualCodeInput after an invalid
36
+ // paste; the second prompt must not append a duplicate input/hint block.
37
+ const dialog = makeDialog();
38
+ dialog.showProgress("Waiting for callback");
39
+
40
+ const first = dialog.showManualInput("Paste the code:");
41
+ dialog.handleInput("garbage");
42
+ dialog.handleInput("\r");
43
+ expect(await first).toBe("garbage");
44
+
45
+ const second = dialog.showManualInput("Paste the code:");
46
+ const rendered = dialog.render(80).join("\n");
47
+ expect(rendered.split("Paste the code:").length - 1).toBe(1);
48
+ // A stale value from the first attempt must not leak into the retry.
49
+ expect(rendered).not.toContain("garbage");
50
+
51
+ const url = "http://localhost:1455/auth/callback?code=OK&state=abc";
52
+ dialog.pasteText(url);
53
+ dialog.handleInput("\r");
54
+ expect(await second).toBe(url);
55
+ });
56
+ });
@@ -108,12 +108,16 @@ export class LoginDialogComponent extends Container {
108
108
  * Show input for manual code/URL entry (for callback server providers)
109
109
  */
110
110
  showManualInput(prompt: string): Promise<string> {
111
- this.#contentContainer.addChild(new Spacer(1));
112
- this.#contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
111
+ // Invalid pastes re-prompt (the OAuth callback loop calls this again), so
112
+ // reuse the already-mounted input instead of stacking duplicate prompt and
113
+ // hint lines beneath the dialog. Reset the value so each retry starts clean.
113
114
  if (!this.#contentContainer.children.includes(this.#input)) {
115
+ this.#contentContainer.addChild(new Spacer(1));
116
+ this.#contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
114
117
  this.#contentContainer.addChild(this.#input);
118
+ this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0));
115
119
  }
116
- this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0));
120
+ this.#input.setValue("");
117
121
  this.#tui.requestRender();
118
122
 
119
123
  const { promise, resolve, reject } = Promise.withResolvers<string>();
@@ -138,6 +138,8 @@ export class PlanReviewOverlay implements Component {
138
138
  #tocCursor = 0;
139
139
  #sidebarShown = false;
140
140
  #pendingScrollToToc = false;
141
+ /** Last meaningful relative body position, retained while a frame cannot scroll. */
142
+ #scrollProgress = 0;
141
143
 
142
144
  // Click hit-testing, rebuilt every render. Keys are 0-based rendered-line
143
145
  // indices (== screen rows, since the fullscreen overlay paints from row 0).
@@ -196,6 +198,7 @@ export class PlanReviewOverlay implements Component {
196
198
  setPlanContent(planContent: string): void {
197
199
  this.#setSections(planContent);
198
200
  this.#scrollView.scrollToTop();
201
+ this.#scrollProgress = 0;
199
202
  this.#tocCursor = 0;
200
203
  // A wholesale external-editor swap supersedes prior in-overlay deletions.
201
204
  this.#deleted = [];
@@ -343,6 +346,7 @@ export class PlanReviewOverlay implements Component {
343
346
  if (event.wheel !== null) {
344
347
  // Scroll wheel: three rows per notch.
345
348
  this.#scrollView.scroll(event.wheel * 3);
349
+ this.#captureScrollProgress();
346
350
  return true;
347
351
  }
348
352
  if (event.release) return true;
@@ -445,13 +449,21 @@ export class PlanReviewOverlay implements Component {
445
449
  // drops into the actions ("next step"); scrolling off the top steps back up
446
450
  // to the ToC.
447
451
  if (matchesSelectUp(data) || matchesKey(data, "k")) {
448
- if (this.#scrollView.getScrollOffset() <= 0 && this.#sidebarShown) this.#setFocus("toc");
449
- else this.#scrollView.scroll(-1);
452
+ if (this.#scrollView.getScrollOffset() <= 0 && this.#sidebarShown) {
453
+ this.#setFocus("toc");
454
+ } else {
455
+ this.#scrollView.scroll(-1);
456
+ this.#captureScrollProgress();
457
+ }
450
458
  return;
451
459
  }
452
460
  if (matchesSelectDown(data) || matchesKey(data, "j")) {
453
- if (this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset()) this.#setFocus("actions");
454
- else this.#scrollView.scroll(1);
461
+ if (this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset()) {
462
+ this.#setFocus("actions");
463
+ } else {
464
+ this.#scrollView.scroll(1);
465
+ this.#captureScrollProgress();
466
+ }
455
467
  return;
456
468
  }
457
469
  this.#handleBodyScroll(data);
@@ -464,9 +476,19 @@ export class PlanReviewOverlay implements Component {
464
476
  * before this runs, so here it only ever sees the paging/fast keys.
465
477
  */
466
478
  #handleBodyScroll(data: string): void {
467
- if (this.#scrollView.handleScrollKey(data)) return;
468
- if (data === "g") this.#scrollView.scrollToTop();
469
- else if (data === "G") this.#scrollView.scrollToBottom();
479
+ if (this.#scrollView.handleScrollKey(data)) {
480
+ if (matchesKey(data, "home")) this.#scrollProgress = 0;
481
+ else if (matchesKey(data, "end")) this.#scrollProgress = 1;
482
+ else this.#captureScrollProgress();
483
+ return;
484
+ }
485
+ if (data === "g") {
486
+ this.#scrollView.scrollToTop();
487
+ this.#scrollProgress = 0;
488
+ } else if (data === "G") {
489
+ this.#scrollView.scrollToBottom();
490
+ this.#scrollProgress = 1;
491
+ }
470
492
  }
471
493
 
472
494
  #handleToc(data: string): void {
@@ -517,7 +539,10 @@ export class PlanReviewOverlay implements Component {
517
539
  const sectionIndex = this.#toc[this.#tocCursor];
518
540
  if (sectionIndex === undefined) return;
519
541
  const offset = this.#sectionOffsets[sectionIndex];
520
- if (offset !== undefined) this.#scrollView.setScrollOffset(offset);
542
+ if (offset !== undefined) {
543
+ this.#scrollView.setScrollOffset(offset);
544
+ this.#captureScrollProgress();
545
+ }
521
546
  }
522
547
 
523
548
  /** Greatest ToC position whose section starts at or above the scroll offset. */
@@ -690,6 +715,23 @@ export class PlanReviewOverlay implements Component {
690
715
  return parts.join(sep);
691
716
  }
692
717
 
718
+ /**
719
+ * Retain relative progress across reflow frames. A non-scrollable intermediate
720
+ * frame has no meaningful offset, so it must not erase the last scroll position.
721
+ */
722
+ #captureScrollProgress(): void {
723
+ const maxOffset = this.#scrollView.getMaxScrollOffset();
724
+ if (maxOffset > 0) this.#scrollProgress = this.#scrollView.getScrollOffset() / maxOffset;
725
+ }
726
+
727
+ #layoutBody(lines: readonly string[], height: number): void {
728
+ this.#captureScrollProgress();
729
+ this.#scrollView.setLines(lines);
730
+ this.#scrollView.setHeight(height);
731
+ const maxOffset = this.#scrollView.getMaxScrollOffset();
732
+ if (maxOffset > 0) this.#scrollView.setScrollOffset(Math.round(this.#scrollProgress * maxOffset));
733
+ }
734
+
693
735
  /** Build the concatenated body lines and record each section's start row. */
694
736
  #buildBody(bodyContentWidth: number): string[] {
695
737
  const lines: string[] = [];
@@ -807,8 +849,7 @@ export class PlanReviewOverlay implements Component {
807
849
  const regionRows = Math.max(MIN_BODY_ROWS, termHeight - chrome);
808
850
 
809
851
  const bodyLines = this.#buildBody(bodyContentWidth);
810
- this.#scrollView.setLines(bodyLines);
811
- this.#scrollView.setHeight(regionRows);
852
+ this.#layoutBody(bodyLines, regionRows);
812
853
  if (this.#pendingScrollToToc) {
813
854
  this.#pendingScrollToToc = false;
814
855
  this.#scrubBodyToToc();
@@ -760,6 +760,7 @@ export class SessionSelectorComponent extends Container {
760
760
  #globalSessions: SessionInfo[] | null = null;
761
761
  #scope: "folder" | "all" = "folder";
762
762
  #toggling = false;
763
+ #inputLocked = false;
763
764
  // 0-based line where the session list begins within this component's own
764
765
  // render, captured each frame. The fullscreen picker overlay paints from
765
766
  // screen row 0, so a mouse row maps to `row - #listLineOffset` inside the
@@ -874,6 +875,10 @@ export class SessionSelectorComponent extends Container {
874
875
  setOnRequestRender(callback: () => void): void {
875
876
  this.#onRequestRender = callback;
876
877
  }
878
+ /** Ignore input after selection while the host resumes the session. */
879
+ lockInput(): void {
880
+ this.#inputLocked = true;
881
+ }
877
882
 
878
883
  /**
879
884
  * Dispose the session list explicitly: while the delete-confirmation dialog
@@ -972,6 +977,7 @@ export class SessionSelectorComponent extends Container {
972
977
  }
973
978
 
974
979
  handleInput(keyData: string): void {
980
+ if (this.#inputLocked) return;
975
981
  if (keyData.startsWith("\x1b[<")) {
976
982
  this.#handleMouse(keyData);
977
983
  return;
@@ -1334,10 +1334,8 @@ export class StatusLineComponent implements Component {
1334
1334
  return [];
1335
1335
  }
1336
1336
 
1337
- const sortedStatuses = Array.from(this.#hookStatuses.entries())
1337
+ return Array.from(this.#hookStatuses.entries())
1338
1338
  .sort(([a], [b]) => a.localeCompare(b))
1339
- .map(([, text]) => sanitizeStatusText(text));
1340
- const hookLine = sortedStatuses.join(" ");
1341
- return [truncateToWidth(hookLine, width)];
1339
+ .map(([, text]) => truncateToWidth(sanitizeStatusText(text), width));
1342
1340
  }
1343
1341
  }
@@ -0,0 +1,101 @@
1
+ import { beforeAll, describe, expect, it } from "bun:test";
2
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
3
+ import { type Component, Text } from "@oh-my-pi/pi-tui";
4
+ import { Settings } from "../../config/settings";
5
+ import { getThemeByName, setThemeInstance, theme } from "../theme/theme";
6
+ import { ToolExecutionComponent, type ToolExecutionUi } from "./tool-execution";
7
+
8
+ class BoldTypeErrorComponent implements Component {
9
+ render(_width: number): readonly string[] {
10
+ throw new TypeError("th.bold is not a function");
11
+ }
12
+ }
13
+
14
+ function visibleText(lines: readonly string[]): string {
15
+ let text = lines.join("\n");
16
+ text = text.replace(/\x1b\]8;[^\x1b\x07]*(?:\x07|\x1b\\)/g, "");
17
+ text = text.replace(/\x1b\[[0-9;]*m/g, "");
18
+ return text;
19
+ }
20
+
21
+ describe("ToolExecutionComponent custom renderer failures", () => {
22
+ beforeAll(async () => {
23
+ await Settings.init({ inMemory: true });
24
+ const loaded = await getThemeByName("dark");
25
+ if (!loaded) throw new Error("theme unavailable");
26
+ setThemeInstance(loaded);
27
+ });
28
+
29
+ it("falls back to the custom tool label when a renderCall child component throws during render", () => {
30
+ const tool: AgentTool = {
31
+ name: "graphify_graph",
32
+ label: "Graphify Graph",
33
+ description: "renders a graph",
34
+ parameters: { type: "object", additionalProperties: true },
35
+ renderCall() {
36
+ return new BoldTypeErrorComponent();
37
+ },
38
+ async execute() {
39
+ return { content: [{ type: "text", text: "ok" }] };
40
+ },
41
+ };
42
+ const ui: ToolExecutionUi = {
43
+ requestRender() {},
44
+ requestComponentRender(_component: Component) {},
45
+ resetDisplay() {},
46
+ };
47
+ const component = new ToolExecutionComponent(
48
+ "graphify_graph",
49
+ {},
50
+ { showImages: false },
51
+ tool,
52
+ ui,
53
+ process.cwd(),
54
+ );
55
+ let text = "";
56
+
57
+ expect(() => {
58
+ text = visibleText(component.render(80));
59
+ }).not.toThrow();
60
+ expect(text).toContain("Graphify Graph");
61
+ });
62
+
63
+ it("preserves raw result text when a renderResult child component throws during render", () => {
64
+ const rawResultText = "raw result survives child renderer failure";
65
+ const tool: AgentTool = {
66
+ name: "crashy_result_renderer",
67
+ label: "Crashy Result Renderer",
68
+ description: "renders result output",
69
+ parameters: { type: "object", additionalProperties: true },
70
+ renderCall() {
71
+ return new Text(theme.fg("toolTitle", theme.bold("Crashy Result Renderer")), 0, 0);
72
+ },
73
+ renderResult() {
74
+ return new BoldTypeErrorComponent();
75
+ },
76
+ async execute() {
77
+ return { content: [{ type: "text", text: rawResultText }] };
78
+ },
79
+ };
80
+ const ui: ToolExecutionUi = {
81
+ requestRender() {},
82
+ requestComponentRender(_component: Component) {},
83
+ resetDisplay() {},
84
+ };
85
+ const component = new ToolExecutionComponent(
86
+ "crashy_result_renderer",
87
+ {},
88
+ { showImages: false },
89
+ tool,
90
+ ui,
91
+ process.cwd(),
92
+ );
93
+ component.updateResult({ content: [{ type: "text", text: rawResultText }] }, false);
94
+ let text = "";
95
+
96
+ expect(() => {
97
+ text = visibleText(component.render(80));
98
+ }).not.toThrow();
99
+ expect(text).toContain(rawResultText);
100
+ });
101
+ });
@@ -40,7 +40,7 @@ import {
40
40
  } from "../../tools/render-utils";
41
41
  import { type FirstResultViewportRepaint, toolRenderers } from "../../tools/renderers";
42
42
  import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
43
- import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
43
+ import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
44
44
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
45
45
  import { renderDiff } from "./diff";
46
46
 
@@ -148,6 +148,68 @@ function getArgsWithStreamedTextInput(args: unknown): unknown {
148
148
  return input === undefined ? args : { ...record, input };
149
149
  }
150
150
 
151
+ type ToolRendererStage = "call" | "result";
152
+
153
+ class SafeToolRendererComponent implements Component {
154
+ #toolName: string;
155
+ #stage: ToolRendererStage;
156
+ #component: Component;
157
+ #fallback: () => Component | undefined;
158
+ #warned = false;
159
+ readonly wantsKeyRelease: boolean | undefined;
160
+
161
+ constructor(
162
+ toolName: string,
163
+ stage: ToolRendererStage,
164
+ component: Component,
165
+ fallback: () => Component | undefined,
166
+ ) {
167
+ this.#toolName = toolName;
168
+ this.#stage = stage;
169
+ this.#component = component;
170
+ this.#fallback = fallback;
171
+ this.wantsKeyRelease = component.wantsKeyRelease;
172
+ if (isFramedBlockComponent(component)) {
173
+ markFramedBlockComponent(this);
174
+ }
175
+ }
176
+
177
+ render(width: number): readonly string[] {
178
+ try {
179
+ return this.#component.render(width);
180
+ } catch (err) {
181
+ if (!this.#warned) {
182
+ this.#warned = true;
183
+ logger.warn("Tool renderer failed", { tool: this.#toolName, stage: this.#stage, error: String(err) });
184
+ }
185
+ return this.#fallback()?.render(width) ?? [];
186
+ }
187
+ }
188
+
189
+ handleInput(data: string): void {
190
+ const handleInput = this.#component.handleInput;
191
+ if (handleInput === undefined) return;
192
+ handleInput.call(this.#component, data);
193
+ }
194
+
195
+ invalidate(): void {
196
+ const invalidate = this.#component.invalidate;
197
+ if (invalidate === undefined) return;
198
+ invalidate.call(this.#component);
199
+ }
200
+
201
+ setIgnoreTight(ignore: boolean): void {
202
+ const setIgnoreTight = this.#component.setIgnoreTight;
203
+ if (setIgnoreTight === undefined) return;
204
+ setIgnoreTight.call(this.#component, ignore);
205
+ }
206
+
207
+ dispose(): void {
208
+ const dispose = this.#component.dispose;
209
+ if (dispose === undefined) return;
210
+ dispose.call(this.#component);
211
+ }
212
+ }
151
213
  /**
152
214
  * Transcript-side probe telling a block whether it is still inside the live
153
215
  * (repaintable) region. Implemented by `TranscriptContainer`; injected rather
@@ -157,6 +219,14 @@ export interface TranscriptLiveRegionProbe {
157
219
  isBlockInLiveRegion(component: Component): boolean;
158
220
  }
159
221
 
222
+ /** Minimal TUI surface ToolExecutionComponent uses to schedule repaints and share image budget. */
223
+ export interface ToolExecutionUi {
224
+ requestRender(): void;
225
+ requestComponentRender(component: Component): void;
226
+ resetDisplay(): void;
227
+ imageBudget?: TUI["imageBudget"];
228
+ }
229
+
160
230
  export interface ToolExecutionOptions {
161
231
  snapshots?: SnapshotStore;
162
232
  showImages?: boolean; // default: true (only used if terminal supports images)
@@ -240,7 +310,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
240
310
  // forcing the common image-free result to re-shape on every resize tick.
241
311
  #renderedImageCount = 0;
242
312
  #tool?: AgentTool;
243
- #ui: TUI;
313
+ #ui: ToolExecutionUi;
244
314
  #cwd: string;
245
315
  #result?: {
246
316
  content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
@@ -308,7 +378,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
308
378
  args: any,
309
379
  options: ToolExecutionOptions = {},
310
380
  tool: AgentTool | undefined,
311
- ui: TUI,
381
+ ui: ToolExecutionUi,
312
382
  cwd: string = getProjectDir(),
313
383
  _toolCallId?: string,
314
384
  ) {
@@ -902,8 +972,17 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
902
972
  if (tool.renderCall) {
903
973
  try {
904
974
  const callArgs = this.#getCallArgsForRender();
905
- const callComponent = tool.renderCall(callArgs, this.#renderState, theme);
906
- if (callComponent) this.#contentBox.addChild(callComponent as Component);
975
+ const callComponent = tool.renderCall(callArgs, this.#renderState, theme) as Component | undefined;
976
+ if (callComponent) {
977
+ this.#contentBox.addChild(
978
+ new SafeToolRendererComponent(
979
+ this.#toolName,
980
+ "call",
981
+ callComponent,
982
+ () => new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0),
983
+ ),
984
+ );
985
+ }
907
986
  } catch (err) {
908
987
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
909
988
  // Fall back to default on error
@@ -934,7 +1013,15 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
934
1013
  theme,
935
1014
  this.#args,
936
1015
  );
937
- if (resultComponent) this.#contentBox.addChild(resultComponent);
1016
+ if (resultComponent) {
1017
+ this.#contentBox.addChild(
1018
+ new SafeToolRendererComponent(this.#toolName, "result", resultComponent, () => {
1019
+ const output = this.#getTextOutput();
1020
+ if (!output) return undefined;
1021
+ return new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0);
1022
+ }),
1023
+ );
1024
+ }
938
1025
  } catch (err) {
939
1026
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
940
1027
  // Fall back to showing raw output on error
@@ -991,7 +1078,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
991
1078
  this.#renderState,
992
1079
  theme,
993
1080
  );
994
- if (resultComponent) fileBox.addChild(resultComponent);
1081
+ if (resultComponent) {
1082
+ fileBox.addChild(
1083
+ new SafeToolRendererComponent(this.#toolName, "result", resultComponent, () => undefined),
1084
+ );
1085
+ }
995
1086
  } catch (err) {
996
1087
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
997
1088
  }
@@ -1038,7 +1129,16 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
1038
1129
  try {
1039
1130
  const callArgs = this.#getCallArgsForRender();
1040
1131
  const callComponent = renderer.renderCall(callArgs, this.#renderState, theme);
1041
- if (callComponent) this.#contentBox.addChild(callComponent);
1132
+ if (callComponent) {
1133
+ this.#contentBox.addChild(
1134
+ new SafeToolRendererComponent(
1135
+ this.#toolName,
1136
+ "call",
1137
+ callComponent,
1138
+ () => new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0),
1139
+ ),
1140
+ );
1141
+ }
1042
1142
  } catch (err) {
1043
1143
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
1044
1144
  // Fall back to default on error
@@ -1059,7 +1159,15 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
1059
1159
  theme,
1060
1160
  this.#getCallArgsForRender(),
1061
1161
  );
1062
- if (resultComponent) this.#contentBox.addChild(resultComponent);
1162
+ if (resultComponent) {
1163
+ this.#contentBox.addChild(
1164
+ new SafeToolRendererComponent(this.#toolName, "result", resultComponent, () => {
1165
+ const output = this.#getTextOutput();
1166
+ if (!output) return undefined;
1167
+ return new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0);
1168
+ }),
1169
+ );
1170
+ }
1063
1171
  } catch (err) {
1064
1172
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
1065
1173
  // Fall back to showing raw output on error
@@ -79,6 +79,10 @@ function sealCommittedSnapshot(child: Component): void {
79
79
  if (block.isDisplaceableBlock?.()) block.seal?.();
80
80
  }
81
81
 
82
+ function setBlockCommittedRows(child: Component, rows: number): void {
83
+ (child as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
84
+ }
85
+
82
86
  // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
83
87
  // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
84
88
  // to a background-colored padding row, whose escape sequences contain `\S` and
@@ -208,11 +212,35 @@ export class TranscriptContainer
208
212
  this.#replayPending = false;
209
213
  }
210
214
 
211
- setNativeScrollbackCommittedRows(rows: number): void {
215
+ override setNativeScrollbackCommittedRows(rows: number): void {
212
216
  this.#committedRows = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
217
+ for (let i = this.#compactedChildStart; i < this.children.length; i++) {
218
+ const child = this.children[i]!;
219
+ const segment = this.#segments[i];
220
+ if (segment === undefined || segment.component !== child) continue;
221
+ const committedContribution = Math.min(
222
+ segment.contribution.length,
223
+ Math.max(0, this.#committedRows - segment.startRow - segment.sep),
224
+ );
225
+ if (committedContribution === 0) {
226
+ setBlockCommittedRows(child, 0);
227
+ continue;
228
+ }
229
+ // Transcript assembly strips plain blank edges from each block. Map the
230
+ // committed contribution back into the child's raw render coordinates so
231
+ // nested containers can split the prefix against their exact child rows.
232
+ let leadingTrimmedRows = 0;
233
+ while (leadingTrimmedRows < segment.rawRef.length && isPlainBlank(segment.rawRef[leadingTrimmedRows]!)) {
234
+ leadingTrimmedRows++;
235
+ }
236
+ setBlockCommittedRows(child, Math.min(segment.rawRef.length, leadingTrimmedRows + committedContribution));
237
+ }
213
238
  }
214
239
 
215
- prepareNativeScrollbackReplay(): void {
240
+ override prepareNativeScrollbackReplay(): void {
241
+ // Replay retires the old terminal tape, so descendants may discard layout
242
+ // locks whose only purpose was keeping that immutable history byte-stable.
243
+ super.prepareNativeScrollbackReplay();
216
244
  if (this.#compactedChildStart === 0) return;
217
245
  this.#compactedChildStart = 0;
218
246
  this.#replayPending = true;
@@ -105,5 +105,5 @@ export function showCommandMessage(ctx: InteractiveModeContext, text: string): v
105
105
  block.addChild(new DynamicBorder());
106
106
  block.addChild(new Text(text, 1, 1));
107
107
  block.addChild(new DynamicBorder());
108
- ctx.present(block);
108
+ ctx.presentCommandOutput(block);
109
109
  }
@@ -64,7 +64,7 @@ function showMarkdownPanel(ctx: InteractiveModeContext, title: string, markdown:
64
64
  block.addChild(new Spacer(1));
65
65
  block.addChild(new Markdown(markdown.trim(), 1, 1, getMarkdownTheme()));
66
66
  block.addChild(new DynamicBorder());
67
- ctx.present(block);
67
+ ctx.presentCommandOutput(block);
68
68
  }
69
69
 
70
70
  export class CommandController {
@@ -1187,6 +1187,7 @@ export class EventController {
1187
1187
  // final history — seal it instead of letting its spinner tick while idle.
1188
1188
  this.#resolveDisplaceablePoll();
1189
1189
  this.#resolveDisplaceableTodo();
1190
+ this.ctx.flushPendingCommandOutput();
1190
1191
  this.#lastAssistantComponent = undefined;
1191
1192
  this.ctx.ui.requestRender();
1192
1193
  this.#scheduleIdleCompaction();