Files
oh-my-pi/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts
T
can1357 1643cd4673 feat(ask): migrated ask tool to AbortSignal-based cancellation for unified abort handling
- Replaced timeout-based cancellation with AbortSignal-based cancellation in ask tool for unified abort handling.
- Added signal parameter to UIContext select() and input() methods to support external cancellation propagation.
- Fixed race condition in dialog overlay handling by replacing direct resolve() calls with settled flag wrapper.
- Added comprehensive test coverage for ask tool cancellation behavior across user-initiated and timeout scenarios.
2026-03-01 11:19:32 +01:00

824 lines
26 KiB
TypeScript

import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
import { Spacer, Text } from "@oh-my-pi/pi-tui";
import { logger } from "@oh-my-pi/pi-utils";
import { KeybindingsManager } from "../../config/keybindings";
import type {
ExtensionActions,
ExtensionCommandContextActions,
ExtensionContextActions,
ExtensionError,
ExtensionUIContext,
ExtensionUIDialogOptions,
TerminalInputHandler,
} from "../../extensibility/extensions";
import { HookEditorComponent } from "../../modes/components/hook-editor";
import { HookInputComponent } from "../../modes/components/hook-input";
import { HookSelectorComponent } from "../../modes/components/hook-selector";
import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
import type { InteractiveModeContext } from "../../modes/types";
import { setTerminalTitle } from "../../utils/title-generator";
export class ExtensionUiController {
#hookSelectorOverlay: OverlayHandle | undefined;
#hookInputOverlay: OverlayHandle | undefined;
#extensionTerminalInputUnsubscribers = new Set<() => void>();
readonly #dialogOverlayOptions = {
anchor: "bottom-center",
width: "80%",
minWidth: 40,
maxHeight: "70%",
margin: 1,
} as const;
constructor(private ctx: InteractiveModeContext) {}
/**
* Initialize the hook system with TUI-based UI context.
*/
async initHooksAndCustomTools(): Promise<void> {
// Create and set hook & tool UI context
const uiContext: ExtensionUIContext = {
select: (title, options, dialogOptions) => this.showHookSelector(title, options, dialogOptions),
confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions),
notify: (message, type) => this.showHookNotify(message, type),
onTerminalInput: handler => this.addExtensionTerminalInputListener(handler),
setStatus: (key, text) => this.setHookStatus(key, text),
setWorkingMessage: message => this.ctx.setWorkingMessage(message),
setWidget: (key, content) => this.setHookWidget(key, content),
setTitle: title => setTerminalTitle(title),
custom: (factory, options) => this.showHookCustom(factory, options),
setEditorText: text => this.ctx.editor.setText(text),
pasteToEditor: text => {
this.ctx.editor.handleInput(`\x1b[200~${text}\x1b[201~`);
},
getEditorText: () => this.ctx.editor.getText(),
editor: (title, prefill) => this.showHookEditor(title, prefill),
get theme() {
return theme;
},
getAllThemes: async () => (await getAvailableThemesWithPaths()).map(t => ({ name: t.name, path: t.path })),
getTheme: name => getThemeByName(name),
setTheme: async themeArg => {
if (typeof themeArg === "string") {
return await setTheme(themeArg, true);
}
// Theme object passed directly - not supported in current implementation
return Promise.resolve({ success: false, error: "Direct theme object not supported" });
},
setFooter: () => {},
setHeader: () => {},
setEditorComponent: () => {},
getToolsExpanded: () => this.ctx.toolOutputExpanded,
setToolsExpanded: expanded => this.ctx.setToolsExpanded(expanded),
};
this.ctx.setToolUIContext(uiContext, true);
const extensionRunner = this.ctx.session.extensionRunner;
if (!extensionRunner) {
return; // No hooks loaded
}
const actions: ExtensionActions = {
sendMessage: (message, options) => {
const wasStreaming = this.ctx.session.isStreaming;
this.ctx.session
.sendCustomMessage(message, options)
.then(() => {
// For non-streaming cases with display=true, update UI
// (streaming cases update via message_end event)
if (!this.ctx.isBackgrounded && !wasStreaming && message.display) {
this.ctx.rebuildChatFromMessages();
}
})
.catch((err: unknown) => {
this.ctx.showError(
`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
},
sendUserMessage: (content, options) => {
this.ctx.session.sendUserMessage(content, options).catch((err: unknown) => {
this.ctx.showError(
`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
},
appendEntry: (customType, data) => {
this.ctx.sessionManager.appendCustomEntry(customType, data);
},
setLabel: (targetId, label) => {
this.ctx.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => this.ctx.session.getActiveToolNames(),
getAllTools: () => this.ctx.session.getAllToolNames(),
setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames),
setModel: async model => {
const key = await this.ctx.session.modelRegistry.getApiKey(model);
if (!key) return false;
await this.ctx.session.setModel(model);
return true;
},
getThinkingLevel: () => this.ctx.session.thinkingLevel,
setThinkingLevel: level => this.ctx.session.setThinkingLevel(level),
getCommands: () => [],
};
const contextActions: ExtensionContextActions = {
getModel: () => this.ctx.session.model,
isIdle: () => !this.ctx.session.isStreaming,
abort: () => this.ctx.session.abort(),
hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
shutdown: () => {
// Signal shutdown request (will be handled by main loop)
},
getContextUsage: () => this.ctx.session.getContextUsage(),
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
await this.ctx.session.compact(instructions, options);
},
getSystemPrompt: () => this.ctx.session.systemPrompt,
};
const commandActions: ExtensionCommandContextActions = {
getContextUsage: () => this.ctx.session.getContextUsage(),
waitForIdle: () => this.ctx.session.agent.waitForIdle(),
reload: async () => {
await this.ctx.session.reload();
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
this.ctx.showStatus("Reloaded session");
},
newSession: async options => {
// Stop any loading animation
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
// Create new session
this.clearExtensionTerminalInputListeners();
const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
if (!success) {
return { cancelled: true };
}
// Call setup callback if provided
if (options?.setup) {
await options.setup(this.ctx.sessionManager);
}
// Clear UI state
this.ctx.chatContainer.clear();
this.ctx.pendingMessagesContainer.clear();
this.ctx.compactionQueuedMessages = [];
this.ctx.streamingComponent = undefined;
this.ctx.streamingMessage = undefined;
this.ctx.pendingTools.clear();
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
);
await this.ctx.reloadTodos();
this.ctx.ui.requestRender();
return { cancelled: false };
},
branch: async entryId => {
const result = await this.ctx.session.branch(entryId);
if (result.cancelled) {
return { cancelled: true };
}
// Update UI
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
this.ctx.editor.setText(result.selectedText);
this.ctx.showStatus("Branched to new session");
return { cancelled: false };
},
navigateTree: async (targetId, options) => {
const result = await this.ctx.session.navigateTree(targetId, { summarize: options?.summarize });
if (result.cancelled) {
return { cancelled: true };
}
// Update UI
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
if (result.editorText && !this.ctx.editor.getText().trim()) {
this.ctx.editor.setText(result.editorText);
}
this.ctx.showStatus("Navigated to selected point");
return { cancelled: false };
},
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
if (this.ctx.isBackgrounded) {
await this.ctx.session.compact(instructions, options);
return;
}
await this.ctx.executeCompaction(instructionsOrOptions, false);
},
switchSession: async sessionPath => {
const result = await this.ctx.session.switchSession(sessionPath);
if (!result) {
return { cancelled: true };
}
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
return { cancelled: false };
},
};
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
// Subscribe to extension errors
extensionRunner.onError((error: ExtensionError) => {
this.showExtensionError(error.extensionPath, error.error);
});
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
});
}
setHookWidget(key: string, content: unknown): void {
this.ctx.statusLine.setHookStatus(key, String(content));
this.ctx.ui.requestRender();
}
initializeHookRunner(uiContext: ExtensionUIContext, _hasUI: boolean): void {
const extensionRunner = this.ctx.session.extensionRunner;
if (!extensionRunner) {
return;
}
const actions: ExtensionActions = {
sendMessage: (message, options) => {
const wasStreaming = this.ctx.session.isStreaming;
this.ctx.session
.sendCustomMessage(message, options)
.then(() => {
// For non-streaming cases with display=true, update UI
// (streaming cases update via message_end event)
if (!this.ctx.isBackgrounded && !wasStreaming && message.display) {
this.ctx.rebuildChatFromMessages();
}
})
.catch((err: unknown) => {
const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`;
if (this.ctx.isBackgrounded) {
logger.error(errorText);
return;
}
this.ctx.showError(errorText);
});
},
sendUserMessage: (content, options) => {
this.ctx.session.sendUserMessage(content, options).catch((err: unknown) => {
this.ctx.showError(
`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
},
appendEntry: (customType, data) => {
this.ctx.sessionManager.appendCustomEntry(customType, data);
},
setLabel: (targetId, label) => {
this.ctx.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => this.ctx.session.getActiveToolNames(),
getAllTools: () => this.ctx.session.getAllToolNames(),
setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames),
setModel: async model => {
const key = await this.ctx.session.modelRegistry.getApiKey(model);
if (!key) return false;
await this.ctx.session.setModel(model);
return true;
},
getThinkingLevel: () => this.ctx.session.thinkingLevel,
setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist),
getCommands: () => [],
};
const contextActions: ExtensionContextActions = {
getModel: () => this.ctx.session.model,
isIdle: () => !this.ctx.session.isStreaming,
abort: () => this.ctx.session.abort(),
hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
shutdown: () => {
// Signal shutdown request (will be handled by main loop)
},
getContextUsage: () => this.ctx.session.getContextUsage(),
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
await this.ctx.session.compact(instructions, options);
},
getSystemPrompt: () => this.ctx.session.systemPrompt,
};
const commandActions: ExtensionCommandContextActions = {
getContextUsage: () => this.ctx.session.getContextUsage(),
waitForIdle: () => this.ctx.session.agent.waitForIdle(),
reload: async () => {
if (this.ctx.isBackgrounded) {
return;
}
await this.ctx.session.reload();
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
this.ctx.showStatus("Reloaded session");
},
newSession: async options => {
if (this.ctx.isBackgrounded) {
return { cancelled: true };
}
// Stop any loading animation
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
// Create new session
this.clearExtensionTerminalInputListeners();
const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
if (!success) {
return { cancelled: true };
}
// Call setup callback if provided
if (options?.setup) {
await options.setup(this.ctx.sessionManager);
}
// Clear UI state
this.ctx.chatContainer.clear();
this.ctx.pendingMessagesContainer.clear();
this.ctx.compactionQueuedMessages = [];
this.ctx.streamingComponent = undefined;
this.ctx.streamingMessage = undefined;
this.ctx.pendingTools.clear();
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
);
await this.ctx.reloadTodos();
this.ctx.ui.requestRender();
return { cancelled: false };
},
branch: async entryId => {
if (this.ctx.isBackgrounded) {
return { cancelled: true };
}
const result = await this.ctx.session.branch(entryId);
if (result.cancelled) {
return { cancelled: true };
}
// Update UI
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
this.ctx.editor.setText(result.selectedText);
this.ctx.showStatus("Branched to new session");
return { cancelled: false };
},
navigateTree: async (targetId, options) => {
if (this.ctx.isBackgrounded) {
return { cancelled: true };
}
const result = await this.ctx.session.navigateTree(targetId, { summarize: options?.summarize });
if (result.cancelled) {
return { cancelled: true };
}
// Update UI
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
if (result.editorText && !this.ctx.editor.getText().trim()) {
this.ctx.editor.setText(result.editorText);
}
this.ctx.showStatus("Navigated to selected point");
return { cancelled: false };
},
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
if (this.ctx.isBackgrounded) {
await this.ctx.session.compact(instructions, options);
return;
}
await this.ctx.executeCompaction(instructionsOrOptions, false);
},
switchSession: async sessionPath => {
if (this.ctx.isBackgrounded) {
return { cancelled: true };
}
const result = await this.ctx.session.switchSession(sessionPath);
if (!result) {
return { cancelled: true };
}
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
return { cancelled: false };
},
};
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
}
createBackgroundUiContext(): ExtensionUIContext {
return {
select: async (_title: string, _options: string[], _dialogOptions) => undefined,
confirm: async (_title: string, _message: string, _dialogOptions) => false,
input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined,
notify: () => {},
onTerminalInput: () => () => {},
setStatus: () => {},
setWorkingMessage: () => {},
setWidget: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
pasteToEditor: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},
getAllThemes: () => Promise.resolve([]),
getTheme: () => Promise.resolve(undefined),
setTheme: () => Promise.resolve({ success: false, error: "Background mode" }),
setFooter: () => {},
setHeader: () => {},
setEditorComponent: () => {},
getToolsExpanded: () => false,
setToolsExpanded: () => {},
};
}
/**
* Emit session event to all extension tools.
*/
async emitCustomToolSessionEvent(
reason: "start" | "switch" | "branch" | "tree" | "shutdown",
previousSessionFile?: string,
): Promise<void> {
const event = { reason, previousSessionFile };
const uiContext = this.ctx.session.extensionRunner?.getUIContext();
if (!uiContext) {
return;
}
for (const registeredTool of this.ctx.session.extensionRunner?.getAllRegisteredTools() ?? []) {
if (registeredTool.definition.onSession) {
try {
await registeredTool.definition.onSession(event, {
ui: uiContext,
getContextUsage: () => this.ctx.session.getContextUsage(),
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
instructionsOrOptions && typeof instructionsOrOptions === "object"
? instructionsOrOptions
: undefined;
await this.ctx.session.compact(instructions, options);
},
hasUI: !this.ctx.isBackgrounded,
cwd: this.ctx.sessionManager.getCwd(),
sessionManager: this.ctx.session.sessionManager,
modelRegistry: this.ctx.session.modelRegistry,
model: this.ctx.session.model,
isIdle: () => !this.ctx.session.isStreaming,
hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
hasQueuedMessages: () => this.ctx.session.queuedMessageCount > 0,
abort: () => {
this.ctx.session.abort();
},
shutdown: () => {
// Signal shutdown request
},
getSystemPrompt: () => this.ctx.session.systemPrompt,
});
} catch (err) {
this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err));
}
}
}
}
/**
* Show a tool error in the chat.
*/
showToolError(toolName: string, error: string): void {
if (this.ctx.isBackgrounded) {
logger.error(`Tool "${toolName}" error: ${error}`);
return;
}
const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
this.ctx.chatContainer.addChild(errorText);
this.ctx.ui.requestRender();
}
/**
* Set hook status text in the footer.
*/
setHookStatus(key: string, text: string | undefined): void {
if (this.ctx.isBackgrounded) {
return;
}
this.ctx.statusLine.setHookStatus(key, text);
this.ctx.ui.requestRender();
}
/**
* Show a selector for hooks.
*/
showHookSelector(
title: string,
options: string[],
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
const { promise, resolve } = Promise.withResolvers<string | undefined>();
let settled = false;
const onAbort = () => {
this.hideHookSelector();
if (!settled) {
settled = true;
resolve(undefined);
}
};
const finish = (value: string | undefined) => {
if (settled) return;
settled = true;
dialogOptions?.signal?.removeEventListener("abort", onAbort);
resolve(value);
};
this.#hookSelectorOverlay?.hide();
this.#hookSelectorOverlay = undefined;
const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
this.ctx.hookSelector = new HookSelectorComponent(
title,
options,
option => {
this.hideHookSelector();
finish(option);
},
() => {
this.hideHookSelector();
finish(undefined);
},
{
initialIndex: dialogOptions?.initialIndex,
timeout: dialogOptions?.timeout,
tui: this.ctx.ui,
outline: dialogOptions?.outline,
maxVisible,
},
);
this.#hookSelectorOverlay = this.ctx.ui.showOverlay(this.ctx.hookSelector, this.#dialogOverlayOptions);
if (dialogOptions?.signal) {
if (dialogOptions.signal.aborted) {
onAbort();
} else {
dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
}
}
return promise;
}
/**
* Hide the hook selector.
*/
hideHookSelector(): void {
this.ctx.hookSelector?.dispose();
this.#hookSelectorOverlay?.hide();
this.#hookSelectorOverlay = undefined;
this.ctx.hookSelector = undefined;
this.ctx.ui.setFocus(this.ctx.editor);
this.ctx.ui.requestRender();
}
/**
* Show a confirmation dialog for hooks.
*/
async showHookConfirm(title: string, message: string): Promise<boolean> {
const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]);
return result === "Yes";
}
/**
* Show a text input for hooks.
*/
showHookInput(
title: string,
placeholder?: string,
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
const { promise, resolve } = Promise.withResolvers<string | undefined>();
let settled = false;
const onAbort = () => {
this.hideHookInput();
if (!settled) {
settled = true;
resolve(undefined);
}
};
const finish = (value: string | undefined) => {
if (settled) return;
settled = true;
dialogOptions?.signal?.removeEventListener("abort", onAbort);
resolve(value);
};
this.#hookInputOverlay?.hide();
this.#hookInputOverlay = undefined;
this.ctx.hookInput = new HookInputComponent(
title,
placeholder,
value => {
this.hideHookInput();
finish(value);
},
() => {
this.hideHookInput();
finish(undefined);
},
);
this.#hookInputOverlay = this.ctx.ui.showOverlay(this.ctx.hookInput, this.#dialogOverlayOptions);
if (dialogOptions?.signal) {
if (dialogOptions.signal.aborted) {
onAbort();
} else {
dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
}
}
return promise;
}
/**
* Hide the hook input.
*/
hideHookInput(): void {
this.ctx.hookInput?.dispose();
this.#hookInputOverlay?.hide();
this.#hookInputOverlay = undefined;
this.ctx.hookInput = undefined;
this.ctx.ui.setFocus(this.ctx.editor);
this.ctx.ui.requestRender();
}
/**
* Show a multi-line editor for hooks (with Ctrl+G support).
*/
showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
const { promise, resolve } = Promise.withResolvers<string | undefined>();
this.ctx.hookEditor = new HookEditorComponent(
this.ctx.ui,
title,
prefill,
value => {
this.hideHookEditor();
resolve(value);
},
() => {
this.hideHookEditor();
resolve(undefined);
},
);
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(this.ctx.hookEditor);
this.ctx.ui.setFocus(this.ctx.hookEditor);
this.ctx.ui.requestRender();
return promise;
}
/**
* Hide the hook editor.
*/
hideHookEditor(): void {
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(this.ctx.editor);
this.ctx.hookEditor = undefined;
this.ctx.ui.setFocus(this.ctx.editor);
this.ctx.ui.requestRender();
}
/**
* Show a notification for hooks.
*/
showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
if (type === "error") {
this.ctx.showError(message);
} else if (type === "warning") {
this.ctx.showWarning(message);
} else {
this.ctx.showStatus(message);
}
}
/**
* Show a custom component with keyboard focus.
*/
async showHookCustom<T>(
factory: (
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
options?: { overlay?: boolean },
): Promise<T> {
const savedText = this.ctx.editor.getText();
const keybindings = KeybindingsManager.inMemory();
const { promise, resolve } = Promise.withResolvers<T>();
let component: (Component & { dispose?(): void }) | undefined;
let overlayHandle: OverlayHandle | undefined;
let closed = false;
const close = (result: T) => {
if (closed) return;
closed = true;
component?.dispose?.();
overlayHandle?.hide();
overlayHandle = undefined;
if (!options?.overlay) {
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(this.ctx.editor);
this.ctx.editor.setText(savedText);
}
this.ctx.ui.setFocus(this.ctx.editor);
this.ctx.ui.requestRender();
resolve(result);
};
Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)).then(c => {
if (closed) {
c.dispose?.();
return;
}
component = c;
if (options?.overlay) {
overlayHandle = this.ctx.ui.showOverlay(component, {
anchor: "bottom-center",
width: "100%",
maxHeight: "100%",
margin: 0,
});
return;
}
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(component);
this.ctx.ui.setFocus(component);
this.ctx.ui.requestRender();
});
return promise;
}
/**
* Show an extension error in the UI.
*/
addExtensionTerminalInputListener(handler: TerminalInputHandler): () => void {
const unsubscribe = this.ctx.ui.addInputListener(handler);
this.#extensionTerminalInputUnsubscribers.add(unsubscribe);
return () => {
unsubscribe();
this.#extensionTerminalInputUnsubscribers.delete(unsubscribe);
};
}
clearExtensionTerminalInputListeners(): void {
for (const unsubscribe of this.#extensionTerminalInputUnsubscribers) {
unsubscribe();
}
this.#extensionTerminalInputUnsubscribers.clear();
}
showExtensionError(extensionPath: string, error: string): void {
const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0);
this.ctx.chatContainer.addChild(errorText);
this.ctx.ui.requestRender();
}
}