fix: stabilize interactive extension startup

This commit is contained in:
Mikael Hugo 2026-05-05 18:42:00 +02:00
parent 0d440bed7a
commit 861c4b6cf6
5 changed files with 128 additions and 3 deletions

View file

@ -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 }],
]);
});

View file

@ -9,6 +9,33 @@ import {
theme, theme,
} from "../theme/theme.js"; } 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 { export function createExtensionUIContext(host: any): ExtensionUIContext {
return { return {
select: (title, options, opts) => select: (title, options, opts) =>
@ -17,7 +44,7 @@ export function createExtensionUIContext(host: any): ExtensionUIContext {
host.showExtensionConfirm(title, message, opts), host.showExtensionConfirm(title, message, opts),
input: (title, placeholder, opts) => input: (title, placeholder, opts) =>
host.showExtensionInput(title, placeholder, opts), host.showExtensionInput(title, placeholder, opts),
notify: (message, type) => host.showExtensionNotify(message, type), notify: (message, type) => notifyHost(host, message, type),
onTerminalInput: (handler) => onTerminalInput: (handler) =>
host.addExtensionTerminalInputListener(handler), host.addExtensionTerminalInputListener(handler),
setStatus: (key, text) => host.setExtensionStatus(key, text), setStatus: (key, text) => host.setExtensionStatus(key, text),

View file

@ -57,6 +57,7 @@ import type {
ExtensionRunner, ExtensionRunner,
ExtensionUIContext, ExtensionUIContext,
ExtensionUIDialogOptions, ExtensionUIDialogOptions,
ExtensionWidgetOptions,
} from "../../core/extensions/index.js"; } from "../../core/extensions/index.js";
import { import {
FooterDataProvider, FooterDataProvider,
@ -1543,6 +1544,49 @@ export class InteractiveMode {
this.ui.requestRender(); 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 { private clearExtensionWidgets(): void {
for (const widget of this.extensionWidgetsAbove.values()) { for (const widget of this.extensionWidgetsAbove.values()) {
widget.dispose?.(); widget.dispose?.();

View file

@ -8,7 +8,7 @@
"strict": true, "strict": true,
"declaration": true, "declaration": true,
"incremental": true, "incremental": true,
"tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.tsbuildinfo", "tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.root.tsbuildinfo",
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true "skipLibCheck": true
}, },

View file

@ -6,7 +6,8 @@
"outDir": "dist/resources", "outDir": "dist/resources",
"declaration": false, "declaration": false,
"declarationMap": false, "declarationMap": false,
"sourceMap": false "sourceMap": false,
"tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.resources.tsbuildinfo"
}, },
"include": ["src/resources/**/*.ts"], "include": ["src/resources/**/*.ts"],
"exclude": [ "exclude": [