From 861c4b6cf658f28dcb6d58d6c335b3b2d4ff573a Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Tue, 5 May 2026 18:42:00 +0200 Subject: [PATCH] fix: stabilize interactive extension startup --- .../extension-ui-controller.test.ts | 53 +++++++++++++++++++ .../controllers/extension-ui-controller.ts | 29 +++++++++- .../src/modes/interactive/interactive-mode.ts | 44 +++++++++++++++ tsconfig.json | 2 +- tsconfig.resources.json | 3 +- 5 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.test.ts diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.test.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.test.ts new file mode 100644 index 000000000..249f0ca04 --- /dev/null +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { createExtensionUIContext } from "./extension-ui-controller.js"; + +test("notify_when_host_has_extension_notify_uses_dedicated_handler", () => { + const calls: unknown[][] = []; + const ui = createExtensionUIContext({ + showExtensionNotify(message: string, type: string) { + calls.push([message, type]); + }, + }); + + ui.notify("Ready", "success"); + + assert.deepEqual(calls, [["Ready", "success"]]); +}); + +test("notify_when_extension_notify_missing_routes_errors_and_warnings_to_existing_host_methods", () => { + const errors: string[] = []; + const warnings: string[] = []; + const ui = createExtensionUIContext({ + showError(message: string) { + errors.push(message); + }, + showWarning(message: string) { + warnings.push(message); + }, + }); + + ui.notify("Failed", "error"); + ui.notify("Careful", "warning"); + + assert.deepEqual(errors, ["Failed"]); + assert.deepEqual(warnings, ["Careful"]); +}); + +test("notify_when_extension_notify_missing_routes_info_and_success_to_status", () => { + const statuses: unknown[][] = []; + const ui = createExtensionUIContext({ + showStatus(message: string, options?: unknown) { + statuses.push([message, options]); + }, + }); + + ui.notify("Started", "info"); + ui.notify("Done", "success"); + + assert.deepEqual(statuses, [ + ["Started", { append: false }], + ["Done", { append: true }], + ]); +}); diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts index 0e8f489f2..b438b10e5 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts @@ -9,6 +9,33 @@ import { theme, } from "../theme/theme.js"; +type ExtensionNotifyType = "info" | "warning" | "error" | "success"; + +function notifyHost( + host: any, + message: string, + type: ExtensionNotifyType = "info", +): void { + if (typeof host.showExtensionNotify === "function") { + host.showExtensionNotify(message, type); + return; + } + + if (type === "error" && typeof host.showError === "function") { + host.showError(message); + return; + } + if (type === "warning" && typeof host.showWarning === "function") { + host.showWarning(message); + return; + } + if (typeof host.showStatus === "function") { + host.showStatus(message, { append: type === "success" }); + return; + } + host.ui?.requestRender?.(); +} + export function createExtensionUIContext(host: any): ExtensionUIContext { return { select: (title, options, opts) => @@ -17,7 +44,7 @@ export function createExtensionUIContext(host: any): ExtensionUIContext { host.showExtensionConfirm(title, message, opts), input: (title, placeholder, opts) => host.showExtensionInput(title, placeholder, opts), - notify: (message, type) => host.showExtensionNotify(message, type), + notify: (message, type) => notifyHost(host, message, type), onTerminalInput: (handler) => host.addExtensionTerminalInputListener(handler), setStatus: (key, text) => host.setExtensionStatus(key, text), diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 681d4dbf6..664063225 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -57,6 +57,7 @@ import type { ExtensionRunner, ExtensionUIContext, ExtensionUIDialogOptions, + ExtensionWidgetOptions, } from "../../core/extensions/index.js"; import { FooterDataProvider, @@ -1543,6 +1544,49 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Render or clear an extension-owned widget. + * + * Purpose: let extensions expose compact live UI near the editor without + * depending on private TUI container internals. + * + * Consumer: extension-ui-controller.ts for ctx.ui.setWidget(). + */ + setExtensionWidget( + key: string, + content: + | string[] + | ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) + | undefined, + options?: ExtensionWidgetOptions, + ): void { + const target = + options?.placement === "belowEditor" + ? this.extensionWidgetsBelow + : this.extensionWidgetsAbove; + const other = + target === this.extensionWidgetsBelow + ? this.extensionWidgetsAbove + : this.extensionWidgetsBelow; + target.get(key)?.dispose?.(); + other.get(key)?.dispose?.(); + target.delete(key); + other.delete(key); + + if (content !== undefined) { + const widget = + typeof content === "function" + ? content(this.ui, theme) + : { + render: () => content, + invalidate: () => {}, + }; + target.set(key, widget); + } + + this.renderWidgets(); + } + private clearExtensionWidgets(): void { for (const widget of this.extensionWidgetsAbove.values()) { widget.dispose?.(); diff --git a/tsconfig.json b/tsconfig.json index 40b7b13e0..2b53435fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ "strict": true, "declaration": true, "incremental": true, - "tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.tsbuildinfo", + "tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.root.tsbuildinfo", "esModuleInterop": true, "skipLibCheck": true }, diff --git a/tsconfig.resources.json b/tsconfig.resources.json index 46166350d..cc63b3948 100644 --- a/tsconfig.resources.json +++ b/tsconfig.resources.json @@ -6,7 +6,8 @@ "outDir": "dist/resources", "declaration": false, "declarationMap": false, - "sourceMap": false + "sourceMap": false, + "tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.resources.tsbuildinfo" }, "include": ["src/resources/**/*.ts"], "exclude": [