957 lines
30 KiB
TypeScript
957 lines
30 KiB
TypeScript
import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
|
|
import { Container, 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,
|
|
ExtensionUiComponent,
|
|
ExtensionWidgetContent,
|
|
ExtensionWidgetOptions,
|
|
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 { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
|
|
|
|
const MAX_WIDGET_LINES = 10;
|
|
|
|
export class ExtensionUiController {
|
|
#extensionTerminalInputUnsubscribers = new Set<() => void>();
|
|
#hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
|
|
#hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
|
|
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, options) => this.setHookWidget(key, content, options),
|
|
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, dialogOptions, editorOptions) =>
|
|
this.showHookEditor(title, prefill, dialogOptions, editorOptions),
|
|
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();
|
|
this.clearHookWidgets();
|
|
const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
|
|
if (!success) {
|
|
return { cancelled: true };
|
|
}
|
|
setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
|
|
|
|
// Call setup callback if provided
|
|
if (options?.setup) {
|
|
await options.setup(this.ctx.sessionManager);
|
|
}
|
|
|
|
// Reset and update status line
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.statusLine.setSessionStartTime(Date.now());
|
|
this.ctx.updateEditorTopBorder();
|
|
this.ctx.ui.requestRender();
|
|
|
|
// 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 => {
|
|
this.clearHookWidgets();
|
|
const result = await this.ctx.session.switchSession(sessionPath);
|
|
if (!result) {
|
|
return { cancelled: true };
|
|
}
|
|
setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
|
|
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: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
|
|
const placement = options?.placement ?? "aboveEditor";
|
|
this.#removeHookWidget(this.#hookWidgetsAbove, key);
|
|
this.#removeHookWidget(this.#hookWidgetsBelow, key);
|
|
|
|
if (content === undefined) {
|
|
this.#rebuildHookWidgets();
|
|
return;
|
|
}
|
|
|
|
const target = placement === "belowEditor" ? this.#hookWidgetsBelow : this.#hookWidgetsAbove;
|
|
target.set(key, this.#createHookWidget(content));
|
|
this.#rebuildHookWidgets();
|
|
}
|
|
|
|
#removeHookWidget(widgets: Map<string, ExtensionUiComponent>, key: string): void {
|
|
const existing = widgets.get(key);
|
|
existing?.dispose?.();
|
|
widgets.delete(key);
|
|
}
|
|
|
|
#createHookWidget(content: ExtensionWidgetContent): ExtensionUiComponent {
|
|
if (Array.isArray(content)) {
|
|
const container = new Container();
|
|
for (const line of content.slice(0, MAX_WIDGET_LINES)) {
|
|
container.addChild(new Text(line, 1, 0));
|
|
}
|
|
if (content.length > MAX_WIDGET_LINES) {
|
|
container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
|
|
}
|
|
return container;
|
|
}
|
|
if (content === undefined) {
|
|
throw new Error("Widget content missing");
|
|
}
|
|
return content(this.ctx.ui, theme);
|
|
}
|
|
|
|
#rebuildHookWidgets(): void {
|
|
this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerAbove, this.#hookWidgetsAbove, true, true);
|
|
this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerBelow, this.#hookWidgetsBelow, false, false);
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
#renderHookWidgetContainer(
|
|
container: Container,
|
|
widgets: Map<string, ExtensionUiComponent>,
|
|
spacerWhenEmpty: boolean,
|
|
leadingSpacer: boolean,
|
|
): void {
|
|
container.clear();
|
|
|
|
if (widgets.size === 0) {
|
|
if (spacerWhenEmpty) {
|
|
container.addChild(new Spacer(1));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (leadingSpacer) {
|
|
container.addChild(new Spacer(1));
|
|
}
|
|
for (const widget of widgets.values()) {
|
|
container.addChild(widget);
|
|
}
|
|
}
|
|
|
|
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();
|
|
this.clearHookWidgets();
|
|
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 };
|
|
}
|
|
this.clearHookWidgets();
|
|
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);
|
|
};
|
|
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);
|
|
},
|
|
{
|
|
onLeft: dialogOptions?.onLeft
|
|
? () => {
|
|
this.hideHookSelector();
|
|
dialogOptions.onLeft?.();
|
|
finish(undefined);
|
|
}
|
|
: undefined,
|
|
onRight: dialogOptions?.onRight
|
|
? () => {
|
|
this.hideHookSelector();
|
|
dialogOptions.onRight?.();
|
|
finish(undefined);
|
|
}
|
|
: undefined,
|
|
helpText: dialogOptions?.helpText,
|
|
initialIndex: dialogOptions?.initialIndex,
|
|
timeout: dialogOptions?.timeout,
|
|
onTimeout: dialogOptions?.onTimeout,
|
|
tui: this.ctx.ui,
|
|
outline: dialogOptions?.outline,
|
|
maxVisible,
|
|
},
|
|
);
|
|
this.ctx.editorContainer.clear();
|
|
this.ctx.editorContainer.addChild(this.ctx.hookSelector);
|
|
this.ctx.ui.setFocus(this.ctx.hookSelector);
|
|
this.ctx.ui.requestRender();
|
|
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.ctx.editorContainer.clear();
|
|
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
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.ctx.hookInput = new HookInputComponent(
|
|
title,
|
|
placeholder,
|
|
value => {
|
|
this.hideHookInput();
|
|
finish(value);
|
|
},
|
|
() => {
|
|
this.hideHookInput();
|
|
finish(undefined);
|
|
},
|
|
{
|
|
timeout: dialogOptions?.timeout,
|
|
onTimeout: dialogOptions?.onTimeout,
|
|
tui: this.ctx.ui,
|
|
},
|
|
);
|
|
this.ctx.editorContainer.clear();
|
|
this.ctx.editorContainer.addChild(this.ctx.hookInput);
|
|
this.ctx.ui.setFocus(this.ctx.hookInput);
|
|
this.ctx.ui.requestRender();
|
|
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.ctx.editorContainer.clear();
|
|
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
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,
|
|
dialogOptions?: ExtensionUIDialogOptions,
|
|
editorOptions?: { promptStyle?: boolean },
|
|
): Promise<string | undefined> {
|
|
const { promise, resolve } = Promise.withResolvers<string | undefined>();
|
|
let settled = false;
|
|
const onAbort = () => {
|
|
this.hideHookEditor();
|
|
if (!settled) {
|
|
settled = true;
|
|
resolve(undefined);
|
|
}
|
|
};
|
|
const finish = (value: string | undefined) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
dialogOptions?.signal?.removeEventListener("abort", onAbort);
|
|
resolve(value);
|
|
};
|
|
this.ctx.hookEditor = new HookEditorComponent(
|
|
this.ctx.ui,
|
|
title,
|
|
prefill,
|
|
value => {
|
|
this.hideHookEditor();
|
|
finish(value);
|
|
},
|
|
() => {
|
|
this.hideHookEditor();
|
|
finish(undefined);
|
|
},
|
|
editorOptions,
|
|
);
|
|
|
|
this.ctx.editorContainer.clear();
|
|
this.ctx.editorContainer.addChild(this.ctx.hookEditor);
|
|
this.ctx.ui.setFocus(this.ctx.hookEditor);
|
|
this.ctx.ui.requestRender();
|
|
if (dialogOptions?.signal) {
|
|
if (dialogOptions.signal.aborted) {
|
|
onAbort();
|
|
} else {
|
|
dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
}
|
|
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);
|
|
};
|
|
}
|
|
|
|
clearHookWidgets(): void {
|
|
for (const widget of this.#hookWidgetsAbove.values()) {
|
|
widget.dispose?.();
|
|
}
|
|
for (const widget of this.#hookWidgetsBelow.values()) {
|
|
widget.dispose?.();
|
|
}
|
|
this.#hookWidgetsAbove.clear();
|
|
this.#hookWidgetsBelow.clear();
|
|
this.#rebuildHookWidgets();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|