@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.
- package/CHANGELOG.md +56 -0
- package/dist/cli.js +3305 -3302
- package/dist/types/advisor/advise-tool.d.ts +5 -2
- package/dist/types/cli-commands.d.ts +14 -1
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/models-config.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
- package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
- package/dist/types/lib/xai-http.d.ts +11 -0
- package/dist/types/modes/acp/acp-agent.d.ts +3 -0
- package/dist/types/modes/acp/acp-mode.d.ts +6 -2
- package/dist/types/modes/components/assistant-message.d.ts +2 -0
- package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +23 -1
- package/dist/types/modes/components/hook-editor.d.ts +9 -2
- package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
- package/dist/types/modes/components/session-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +8 -1
- package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +6 -0
- package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
- package/dist/types/modes/types.d.ts +10 -0
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +9 -1
- package/dist/types/session/streaming-output.d.ts +2 -0
- package/dist/types/slash-commands/types.d.ts +4 -4
- package/dist/types/system-prompt.d.ts +1 -1
- package/dist/types/tools/browser/registry.d.ts +7 -3
- package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/tools/manage-skill.d.ts +2 -0
- package/dist/types/web/search/index.d.ts +2 -0
- package/dist/types/web/search/providers/base.d.ts +3 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +22 -12
- package/src/advisor/advise-tool.ts +7 -3
- package/src/cli/bench-cli.ts +4 -1
- package/src/cli/grep-cli.ts +2 -1
- package/src/cli-commands.ts +57 -23
- package/src/config/model-registry.ts +9 -0
- package/src/config/model-resolver.ts +43 -5
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +2 -1
- package/src/discovery/codex.ts +14 -4
- package/src/discovery/substitute-plugin-root.ts +23 -7
- package/src/export/html/template.js +2 -0
- package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
- package/src/internal-urls/agent-protocol.ts +73 -39
- package/src/lib/xai-http.ts +28 -2
- package/src/modes/acp/acp-agent.ts +28 -9
- package/src/modes/acp/acp-event-mapper.ts +8 -0
- package/src/modes/acp/acp-mode.ts +18 -4
- package/src/modes/components/assistant-message.ts +35 -14
- package/src/modes/components/chat-transcript-builder.ts +2 -0
- package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
- package/src/modes/components/custom-editor.ts +62 -1
- package/src/modes/components/hook-editor.ts +17 -2
- package/src/modes/components/login-dialog.test.ts +56 -0
- package/src/modes/components/login-dialog.ts +7 -3
- package/src/modes/components/plan-review-overlay.ts +51 -10
- package/src/modes/components/session-selector.ts +6 -0
- package/src/modes/components/status-line/component.ts +2 -4
- package/src/modes/components/tool-execution.test.ts +101 -0
- package/src/modes/components/tool-execution.ts +117 -9
- package/src/modes/components/transcript-container.ts +30 -2
- package/src/modes/controllers/command-controller-shared.ts +1 -1
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +1 -0
- package/src/modes/controllers/selector-controller.ts +19 -17
- package/src/modes/interactive-mode.ts +66 -11
- package/src/modes/noninteractive-dispose.test.ts +60 -0
- package/src/modes/print-mode.ts +8 -3
- package/src/modes/rpc/rpc-mode.ts +12 -3
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +10 -0
- package/src/modes/utils/interactive-context-helpers.ts +3 -1
- package/src/modes/utils/transcript-render-helpers.ts +3 -2
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/sdk.ts +6 -2
- package/src/session/agent-session.ts +79 -34
- package/src/session/session-listing.ts +6 -5
- package/src/session/session-loader.ts +9 -0
- package/src/session/session-persistence.ts +11 -0
- package/src/session/streaming-output.ts +3 -1
- package/src/slash-commands/builtin-registry.ts +2 -0
- package/src/slash-commands/types.ts +4 -4
- package/src/system-prompt.ts +2 -2
- package/src/tools/browser/registry.ts +9 -2
- package/src/tools/browser/tab-supervisor.ts +65 -10
- package/src/tools/browser.ts +4 -3
- package/src/tools/eval.ts +3 -0
- package/src/tools/image-gen.ts +561 -512
- package/src/tools/index.ts +3 -1
- package/src/tools/manage-skill.ts +5 -3
- package/src/tools/output-meta.ts +7 -0
- package/src/tools/path-utils.ts +8 -5
- package/src/web/search/index.ts +21 -6
- package/src/web/search/providers/base.ts +3 -0
- 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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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.#
|
|
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)
|
|
449
|
-
|
|
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())
|
|
454
|
-
|
|
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))
|
|
468
|
-
|
|
469
|
-
|
|
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)
|
|
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.#
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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.
|
|
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.
|
|
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();
|