Files
oh-my-pi/packages/coding-agent/src/modes/interactive/interactive-mode.ts
T
2026-01-03 18:27:32 +01:00

2816 lines
87 KiB
TypeScript

/**
* Interactive mode for the coding agent.
* Handles TUI rendering and user interaction, delegating business logic to AgentSession.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { basename } from "node:path";
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@oh-my-pi/pi-ai";
import type { SlashCommand } from "@oh-my-pi/pi-tui";
import {
CombinedAutocompleteProvider,
type Component,
Container,
Input,
Loader,
Markdown,
ProcessTerminal,
Spacer,
Text,
TruncatedText,
TUI,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { contextFileCapability } from "../../capability/context-file";
import { instructionCapability } from "../../capability/instruction";
import { promptCapability } from "../../capability/prompt";
import { ruleCapability } from "../../capability/rule";
import { getAuthPath, getDebugLogPath } from "../../config";
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index";
import type { HookUIContext } from "../../core/hooks/index";
import { createCompactionSummaryMessage } from "../../core/messages";
import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager";
import { loadSkills } from "../../core/skills";
import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator";
import type { TruncationResult } from "../../core/tools/truncate";
import {
type ContextFile,
disableProvider,
enableProvider,
type Instruction,
loadSync,
type Prompt,
type Rule,
} from "../../discovery";
import { getChangelogPath, parseChangelog } from "../../utils/changelog";
import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
import { ArminComponent } from "./components/armin";
import { AssistantMessageComponent } from "./components/assistant-message";
import { BashExecutionComponent } from "./components/bash-execution";
import { BorderedLoader } from "./components/bordered-loader";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message";
import { CustomEditor } from "./components/custom-editor";
import { DynamicBorder } from "./components/dynamic-border";
import { FooterComponent } from "./components/footer";
import { HookEditorComponent } from "./components/hook-editor";
import { HookInputComponent } from "./components/hook-input";
import { HookMessageComponent } from "./components/hook-message";
import { HookSelectorComponent } from "./components/hook-selector";
import { ModelSelectorComponent } from "./components/model-selector";
import { OAuthSelectorComponent } from "./components/oauth-selector";
import { SessionSelectorComponent } from "./components/session-selector";
import { SettingsSelectorComponent } from "./components/settings-selector";
import { ToolExecutionComponent } from "./components/tool-execution";
import { TreeSelectorComponent } from "./components/tree-selector";
import { UserMessageComponent } from "./components/user-message";
import { UserMessageSelectorComponent } from "./components/user-message-selector";
import { WelcomeComponent } from "./components/welcome";
import {
getAvailableThemes,
getEditorTheme,
getMarkdownTheme,
onThemeChange,
setTheme,
type Theme,
theme,
} from "./theme/theme";
/** Interface for components that can be expanded/collapsed */
interface Expandable {
setExpanded(expanded: boolean): void;
}
function isExpandable(obj: unknown): obj is Expandable {
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
}
export class InteractiveMode {
private session: AgentSession;
private ui: TUI;
private chatContainer: Container;
private pendingMessagesContainer: Container;
private statusContainer: Container;
private editor: CustomEditor;
private editorContainer: Container;
private footer: FooterComponent;
private version: string;
private isInitialized = false;
private onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void;
private loadingAnimation: Loader | undefined = undefined;
private lastSigintTime = 0;
private lastEscapeTime = 0;
private changelogMarkdown: string | undefined = undefined;
// Status line tracking (for mutating immediately-sequential status updates)
private lastStatusSpacer: Spacer | undefined = undefined;
private lastStatusText: Text | undefined = undefined;
// Streaming message tracking
private streamingComponent: AssistantMessageComponent | undefined = undefined;
private streamingMessage: AssistantMessage | undefined = undefined;
// Tool execution tracking: toolCallId -> component
private pendingTools = new Map<string, ToolExecutionComponent>();
// Tool output expansion state
private toolOutputExpanded = false;
// Thinking block visibility state
private hideThinkingBlock = false;
// Agent subscription unsubscribe function
private unsubscribe?: () => void;
// Track if editor is in bash mode (text starts with !)
private isBashMode = false;
// Track current bash execution component
private bashComponent: BashExecutionComponent | undefined = undefined;
// Track pending bash components (shown in pending area, moved to chat on submit)
private pendingBashComponents: BashExecutionComponent[] = [];
// Track pending images from clipboard paste (attached to next message)
private pendingImages: ImageContent[] = [];
// Auto-compaction state
private autoCompactionLoader: Loader | undefined = undefined;
private autoCompactionEscapeHandler?: () => void;
// Auto-retry state
private retryLoader: Loader | undefined = undefined;
private retryEscapeHandler?: () => void;
// Hook UI state
private hookSelector: HookSelectorComponent | undefined = undefined;
private hookInput: HookInputComponent | undefined = undefined;
private hookEditor: HookEditorComponent | undefined = undefined;
// Custom tools for custom rendering
private customTools: Map<string, LoadedCustomTool>;
// Title generation state
private titleGenerationAttempted = false;
// Convenience accessors
private get agent() {
return this.session.agent;
}
private get sessionManager() {
return this.session.sessionManager;
}
private get settingsManager() {
return this.session.settingsManager;
}
constructor(
session: AgentSession,
version: string,
changelogMarkdown: string | undefined = undefined,
customTools: LoadedCustomTool[] = [],
private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {},
private lspServers:
| Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }>
| undefined = undefined,
fdPath: string | undefined = undefined,
) {
this.session = session;
this.version = version;
this.changelogMarkdown = changelogMarkdown;
this.customTools = new Map(customTools.map((ct) => [ct.tool.name, ct]));
this.ui = new TUI(new ProcessTerminal());
this.chatContainer = new Container();
this.pendingMessagesContainer = new Container();
this.statusContainer = new Container();
this.editor = new CustomEditor(getEditorTheme());
this.editorContainer = new Container();
this.editorContainer.addChild(this.editor);
this.footer = new FooterComponent(session);
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
// Define slash commands for autocomplete
const slashCommands: SlashCommand[] = [
{ name: "settings", description: "Open settings menu" },
{ name: "model", description: "Select model (opens selector UI)" },
{ name: "export", description: "Export session to HTML file or clipboard (--copy)" },
{ name: "share", description: "Share session as a secret GitHub gist" },
{ name: "copy", description: "Copy last agent message to clipboard" },
{ name: "session", description: "Show session info and stats" },
{ name: "status", description: "Show loaded extensions (context, skills, tools, hooks)" },
{ name: "changelog", description: "Show changelog entries" },
{ name: "hotkeys", description: "Show all keyboard shortcuts" },
{ name: "branch", description: "Create a new branch from a previous message" },
{ name: "tree", description: "Navigate session tree (switch branches)" },
{ name: "login", description: "Login with OAuth provider" },
{ name: "logout", description: "Logout from OAuth provider" },
{ name: "new", description: "Start a new session" },
{ name: "compact", description: "Manually compact the session context" },
{ name: "resume", description: "Resume a different session" },
];
// Load hide thinking block setting
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
// Convert file commands to SlashCommand format
const fileSlashCommands: SlashCommand[] = this.session.fileCommands.map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
// Convert hook commands to SlashCommand format
const hookCommands: SlashCommand[] = (this.session.hookRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
name: cmd.name,
description: cmd.description ?? "(hook command)",
}));
// Convert custom commands (TypeScript) to SlashCommand format
const customCommands: SlashCommand[] = this.session.customCommands.map((loaded) => ({
name: loaded.command.name,
description: `${loaded.command.description} (${loaded.source})`,
}));
// Setup autocomplete
const autocompleteProvider = new CombinedAutocompleteProvider(
[...slashCommands, ...fileSlashCommands, ...hookCommands, ...customCommands],
process.cwd(),
fdPath,
);
this.editor.setAutocompleteProvider(autocompleteProvider);
}
async init(): Promise<void> {
if (this.isInitialized) return;
// Get current model info for welcome screen
const modelName = this.session.model?.name ?? "Unknown";
const providerName = this.session.model?.provider ?? "Unknown";
// Get recent sessions
const recentSessions = getRecentSessions(this.sessionManager.getSessionDir()).map((s) => ({
name: s.name,
timeAgo: s.timeAgo,
}));
// Convert LSP servers to welcome format
const lspServerInfo =
this.lspServers?.map((s) => ({
name: s.name,
status: s.status as "ready" | "error" | "connecting",
fileTypes: s.fileTypes,
})) ?? [];
// Add welcome header
const welcome = new WelcomeComponent(this.version, modelName, providerName, recentSessions, lspServerInfo);
// Set terminal title if session already has one (resumed session)
const existingTitle = this.sessionManager.getSessionTitle();
if (existingTitle) {
setTerminalTitle(`pi: ${existingTitle}`);
this.titleGenerationAttempted = true; // Don't try to generate again
}
// Setup UI layout
this.ui.addChild(new Spacer(1));
this.ui.addChild(welcome);
this.ui.addChild(new Spacer(1));
// Add changelog if provided
if (this.changelogMarkdown) {
this.ui.addChild(new DynamicBorder());
if (this.settingsManager.getCollapseChangelog()) {
const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
const latestVersion = versionMatch ? versionMatch[1] : this.version;
const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
this.ui.addChild(new Text(condensedText, 1, 0));
} else {
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.ui.addChild(new Spacer(1));
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
this.ui.addChild(new Spacer(1));
}
this.ui.addChild(new DynamicBorder());
}
this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.ui.addChild(new Spacer(1));
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.footer);
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
// Start the UI
this.ui.start();
this.isInitialized = true;
// Initialize hooks with TUI-based UI context
await this.initHooksAndCustomTools();
// Subscribe to agent events
this.subscribeToAgent();
// Set up theme file watcher
onThemeChange(() => {
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
});
// Set up git branch watcher
this.footer.watchBranch(() => {
this.ui.requestRender();
});
}
// =========================================================================
// Hook System
// =========================================================================
/**
* Initialize the hook system with TUI-based UI context.
*/
private async initHooksAndCustomTools(): Promise<void> {
// Create and set hook & tool UI context
const uiContext: HookUIContext = {
select: (title, options) => this.showHookSelector(title, options),
confirm: (title, message) => this.showHookConfirm(title, message),
input: (title, placeholder) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
setStatus: (key, text) => this.setHookStatus(key, text),
custom: (factory) => this.showHookCustom(factory),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),
editor: (title, prefill) => this.showHookEditor(title, prefill),
get theme() {
return theme;
},
};
this.setToolUIContext(uiContext, true);
// Notify custom tools of session start
await this.emitCustomToolSessionEvent({
reason: "start",
previousSessionFile: undefined,
});
const hookRunner = this.session.hookRunner;
if (!hookRunner) {
return; // No hooks loaded
}
hookRunner.initialize({
getModel: () => this.session.model,
sendMessageHandler: (message, triggerTurn) => {
const wasStreaming = this.session.isStreaming;
this.session
.sendHookMessage(message, triggerTurn)
.then(() => {
// For non-streaming cases with display=true, update UI
// (streaming cases update via message_end event)
if (!wasStreaming && message.display) {
this.rebuildChatFromMessages();
}
})
.catch((err) => {
this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
});
},
appendEntryHandler: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
},
newSessionHandler: async (options) => {
// Stop any loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Create new session
const success = await this.session.newSession({ parentSession: options?.parentSession });
if (!success) {
return { cancelled: true };
}
// Call setup callback if provided
if (options?.setup) {
await options.setup(this.sessionManager);
}
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
return { cancelled: false };
},
branchHandler: async (entryId) => {
const result = await this.session.branch(entryId);
if (result.cancelled) {
return { cancelled: true };
}
// Update UI
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Branched to new session");
return { cancelled: false };
},
navigateTreeHandler: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
if (result.cancelled) {
return { cancelled: true };
}
// Update UI
this.chatContainer.clear();
this.renderInitialMessages();
if (result.editorText) {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
return { cancelled: false };
},
isIdle: () => !this.session.isStreaming,
waitForIdle: () => this.session.agent.waitForIdle(),
abort: () => {
this.session.abort();
},
hasQueuedMessages: () => this.session.queuedMessageCount > 0,
uiContext,
hasUI: true,
});
// Subscribe to hook errors
hookRunner.onError((error) => {
this.showHookError(error.hookPath, error.error);
});
// Emit session_start event
await hookRunner.emit({
type: "session_start",
});
}
/**
* Emit session event to all custom tools.
*/
private async emitCustomToolSessionEvent(event: CustomToolSessionEvent): Promise<void> {
for (const { tool } of this.customTools.values()) {
if (tool.onSession) {
try {
await tool.onSession(event, {
sessionManager: this.session.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,
isIdle: () => !this.session.isStreaming,
hasQueuedMessages: () => this.session.queuedMessageCount > 0,
abort: () => {
this.session.abort();
},
});
} catch (err) {
this.showToolError(tool.name, err instanceof Error ? err.message : String(err));
}
}
}
}
/**
* Show a tool error in the chat.
*/
private showToolError(toolName: string, error: string): void {
const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
this.chatContainer.addChild(errorText);
this.ui.requestRender();
}
/**
* Set hook status text in the footer.
*/
private setHookStatus(key: string, text: string | undefined): void {
this.footer.setHookStatus(key, text);
this.ui.requestRender();
}
/**
* Show a selector for hooks.
*/
private showHookSelector(title: string, options: string[]): Promise<string | undefined> {
return new Promise((resolve) => {
this.hookSelector = new HookSelectorComponent(
title,
options,
(option) => {
this.hideHookSelector();
resolve(option);
},
() => {
this.hideHookSelector();
resolve(undefined);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(this.hookSelector);
this.ui.setFocus(this.hookSelector);
this.ui.requestRender();
});
}
/**
* Hide the hook selector.
*/
private hideHookSelector(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookSelector = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
/**
* Show a confirmation dialog for hooks.
*/
private 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.
*/
private showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
return new Promise((resolve) => {
this.hookInput = new HookInputComponent(
title,
placeholder,
(value) => {
this.hideHookInput();
resolve(value);
},
() => {
this.hideHookInput();
resolve(undefined);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(this.hookInput);
this.ui.setFocus(this.hookInput);
this.ui.requestRender();
});
}
/**
* Hide the hook input.
*/
private hideHookInput(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookInput = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
/**
* Show a multi-line editor for hooks (with Ctrl+G support).
*/
private showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
return new Promise((resolve) => {
this.hookEditor = new HookEditorComponent(
this.ui,
title,
prefill,
(value) => {
this.hideHookEditor();
resolve(value);
},
() => {
this.hideHookEditor();
resolve(undefined);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(this.hookEditor);
this.ui.setFocus(this.hookEditor);
this.ui.requestRender();
});
}
/**
* Hide the hook editor.
*/
private hideHookEditor(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookEditor = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
/**
* Show a notification for hooks.
*/
private showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
if (type === "error") {
this.showError(message);
} else if (type === "warning") {
this.showWarning(message);
} else {
this.showStatus(message);
}
}
/**
* Show a custom component with keyboard focus.
*/
private async showHookCustom<T>(
factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T> {
const savedText = this.editor.getText();
return new Promise((resolve) => {
let component: Component & { dispose?(): void };
const close = (result: T) => {
component.dispose?.();
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.editor.setText(savedText);
this.ui.setFocus(this.editor);
this.ui.requestRender();
resolve(result);
};
Promise.resolve(factory(this.ui, theme, close)).then((c) => {
component = c;
this.editorContainer.clear();
this.editorContainer.addChild(component);
this.ui.setFocus(component);
this.ui.requestRender();
});
});
}
/**
* Show a hook error in the UI.
*/
private showHookError(hookPath: string, error: string): void {
const errorText = new Text(theme.fg("error", `Hook "${hookPath}" error: ${error}`), 1, 0);
this.chatContainer.addChild(errorText);
this.ui.requestRender();
}
/**
* Handle pi.send() from hooks.
* If streaming, queue the message. Otherwise, start a new agent loop.
*/
// =========================================================================
// Key Handlers
// =========================================================================
private setupKeyHandlers(): void {
this.editor.onEscape = () => {
if (this.loadingAnimation) {
// Abort and restore queued messages to editor
const queuedMessages = this.session.clearQueue();
const queuedText = queuedMessages.join("\n\n");
const currentText = this.editor.getText();
const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
this.editor.setText(combinedText);
this.updatePendingMessagesDisplay();
this.agent.abort();
} else if (this.session.isBashRunning) {
this.session.abortBash();
} else if (this.isBashMode) {
this.editor.setText("");
this.isBashMode = false;
this.updateEditorBorderColor();
} else if (!this.editor.getText().trim()) {
// Double-escape with empty editor triggers /branch
const now = Date.now();
if (now - this.lastEscapeTime < 500) {
this.showUserMessageSelector();
this.lastEscapeTime = 0;
} else {
this.lastEscapeTime = now;
}
}
};
this.editor.onCtrlC = () => this.handleCtrlC();
this.editor.onCtrlD = () => this.handleCtrlD();
this.editor.onCtrlZ = () => this.handleCtrlZ();
this.editor.onShiftTab = () => this.cycleThinkingLevel();
this.editor.onCtrlP = () => this.cycleModel("forward");
this.editor.onShiftCtrlP = () => this.cycleModel("backward");
// Global debug handler on TUI (works regardless of focus)
this.ui.onDebug = () => this.handleDebugCommand();
this.editor.onCtrlL = () => this.showModelSelector();
this.editor.onCtrlO = () => this.toggleToolOutputExpansion();
this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility();
this.editor.onCtrlG = () => this.openExternalEditor();
this.editor.onQuestionMark = () => this.handleHotkeysCommand();
this.editor.onCtrlV = () => this.handleImagePaste();
this.editor.onChange = (text: string) => {
const wasBashMode = this.isBashMode;
this.isBashMode = text.trimStart().startsWith("!");
if (wasBashMode !== this.isBashMode) {
this.updateEditorBorderColor();
}
};
}
private setupEditorSubmitHandler(): void {
this.editor.onSubmit = async (text: string) => {
text = text.trim();
if (!text) return;
// Handle slash commands
if (text === "/settings") {
this.showSettingsSelector();
this.editor.setText("");
return;
}
if (text === "/model") {
this.showModelSelector();
this.editor.setText("");
return;
}
if (text.startsWith("/export")) {
await this.handleExportCommand(text);
this.editor.setText("");
return;
}
if (text === "/share") {
await this.handleShareCommand();
this.editor.setText("");
return;
}
if (text === "/copy") {
await this.handleCopyCommand();
this.editor.setText("");
return;
}
if (text === "/session") {
this.handleSessionCommand();
this.editor.setText("");
return;
}
if (text === "/changelog") {
this.handleChangelogCommand();
this.editor.setText("");
return;
}
if (text === "/hotkeys") {
this.handleHotkeysCommand();
this.editor.setText("");
return;
}
if (text === "/status") {
this.handleStatusCommand();
this.editor.setText("");
return;
}
if (text === "/branch") {
this.showUserMessageSelector();
this.editor.setText("");
return;
}
if (text === "/tree") {
this.showTreeSelector();
this.editor.setText("");
return;
}
if (text === "/login") {
this.showOAuthSelector("login");
this.editor.setText("");
return;
}
if (text === "/logout") {
this.showOAuthSelector("logout");
this.editor.setText("");
return;
}
if (text === "/new") {
this.editor.setText("");
await this.handleClearCommand();
return;
}
if (text === "/compact" || text.startsWith("/compact ")) {
const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
this.editor.setText("");
this.editor.disableSubmit = true;
try {
await this.handleCompactCommand(customInstructions);
} finally {
this.editor.disableSubmit = false;
}
return;
}
if (text === "/debug") {
this.handleDebugCommand();
this.editor.setText("");
return;
}
if (text === "/arminsayshi") {
this.handleArminSaysHi();
this.editor.setText("");
return;
}
if (text === "/resume") {
this.showSessionSelector();
this.editor.setText("");
return;
}
// Handle bash command
if (text.startsWith("!")) {
const command = text.slice(1).trim();
if (command) {
if (this.session.isBashRunning) {
this.showWarning("A bash command is already running. Press Esc to cancel it first.");
this.editor.setText(text);
return;
}
this.editor.addToHistory(text);
await this.handleBashCommand(command);
this.isBashMode = false;
this.updateEditorBorderColor();
return;
}
}
// Block input during compaction
if (this.session.isCompacting) {
return;
}
// Hook commands always run immediately, even during streaming
// (if they need to interact with LLM, they use pi.sendMessage which handles queueing)
if (text.startsWith("/") && this.session.hookRunner) {
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const command = this.session.hookRunner.getCommand(commandName);
if (command) {
this.editor.addToHistory(text);
this.editor.setText("");
await this.session.prompt(text);
return;
}
}
// Custom commands (TypeScript slash commands) - route through session.prompt()
if (text.startsWith("/") && this.session.customCommands.length > 0) {
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const hasCustomCommand = this.session.customCommands.some((c) => c.command.name === commandName);
if (hasCustomCommand) {
this.editor.addToHistory(text);
this.editor.setText("");
await this.session.prompt(text);
return;
}
}
// Queue regular messages if agent is streaming
if (this.session.isStreaming) {
await this.session.queueMessage(text);
this.updatePendingMessagesDisplay();
this.editor.addToHistory(text);
this.editor.setText("");
this.ui.requestRender();
return;
}
// Normal message submission
// First, move any pending bash components to chat
this.flushPendingBashComponents();
if (this.onInputCallback) {
// Include any pending images from clipboard paste
const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;
this.pendingImages = [];
this.onInputCallback({ text, images });
}
this.editor.addToHistory(text);
};
}
private subscribeToAgent(): void {
this.unsubscribe = this.session.subscribe(async (event) => {
await this.handleEvent(event);
});
}
private async handleEvent(event: AgentSessionEvent): Promise<void> {
if (!this.isInitialized) {
await this.init();
}
this.footer.invalidate();
switch (event.type) {
case "agent_start":
if (this.loadingAnimation) {
this.loadingAnimation.stop();
}
this.statusContainer.clear();
this.loadingAnimation = new Loader(
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
"Working... (esc to interrupt)",
);
this.statusContainer.addChild(this.loadingAnimation);
this.ui.requestRender();
break;
case "message_start":
if (event.message.role === "hookMessage") {
this.addMessageToChat(event.message);
this.ui.requestRender();
} else if (event.message.role === "user") {
this.addMessageToChat(event.message);
this.editor.setText("");
this.updatePendingMessagesDisplay();
this.ui.requestRender();
} else if (event.message.role === "assistant") {
this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);
this.streamingMessage = event.message;
this.chatContainer.addChild(this.streamingComponent);
this.streamingComponent.updateContent(this.streamingMessage);
this.ui.requestRender();
}
break;
case "message_update":
if (this.streamingComponent && event.message.role === "assistant") {
this.streamingMessage = event.message;
this.streamingComponent.updateContent(this.streamingMessage);
for (const content of this.streamingMessage.content) {
if (content.type === "toolCall") {
if (!this.pendingTools.has(content.id)) {
this.chatContainer.addChild(new Text("", 0, 0));
const component = new ToolExecutionComponent(
content.name,
content.arguments,
{
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(content.name)?.tool,
this.ui,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
this.pendingTools.set(content.id, component);
} else {
const component = this.pendingTools.get(content.id);
if (component) {
component.updateArgs(content.arguments);
}
}
}
}
this.ui.requestRender();
}
break;
case "message_end":
if (event.message.role === "user") break;
if (this.streamingComponent && event.message.role === "assistant") {
this.streamingMessage = event.message;
this.streamingComponent.updateContent(this.streamingMessage);
if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
const errorMessage =
this.streamingMessage.stopReason === "aborted"
? "Operation aborted"
: this.streamingMessage.errorMessage || "Error";
for (const [, component] of this.pendingTools.entries()) {
component.updateResult({
content: [{ type: "text", text: errorMessage }],
isError: true,
});
}
this.pendingTools.clear();
} else {
// Args are now complete - trigger diff computation for edit tools
for (const [, component] of this.pendingTools.entries()) {
component.setArgsComplete();
}
}
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.footer.invalidate();
}
this.ui.requestRender();
break;
case "tool_execution_start": {
if (!this.pendingTools.has(event.toolCallId)) {
const component = new ToolExecutionComponent(
event.toolName,
event.args,
{
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(event.toolName)?.tool,
this.ui,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
this.pendingTools.set(event.toolCallId, component);
this.ui.requestRender();
}
break;
}
case "tool_execution_update": {
const component = this.pendingTools.get(event.toolCallId);
if (component) {
component.updateResult({ ...event.partialResult, isError: false }, true);
this.ui.requestRender();
}
break;
}
case "tool_execution_end": {
const component = this.pendingTools.get(event.toolCallId);
if (component) {
component.updateResult({ ...event.result, isError: event.isError });
this.pendingTools.delete(event.toolCallId);
this.ui.requestRender();
}
break;
}
case "agent_end":
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
this.statusContainer.clear();
}
if (this.streamingComponent) {
this.chatContainer.removeChild(this.streamingComponent);
this.streamingComponent = undefined;
this.streamingMessage = undefined;
}
this.pendingTools.clear();
this.ui.requestRender();
// Generate session title after first turn (if not already titled)
if (!this.titleGenerationAttempted && !this.sessionManager.getSessionTitle()) {
this.titleGenerationAttempted = true;
this.maybeGenerateTitle();
}
break;
case "auto_compaction_start": {
// Disable submit to preserve editor text during compaction
this.editor.disableSubmit = true;
// Set up escape to abort auto-compaction
this.autoCompactionEscapeHandler = this.editor.onEscape;
this.editor.onEscape = () => {
this.session.abortCompaction();
};
// Show compacting indicator with reason
this.statusContainer.clear();
const reasonText = event.reason === "overflow" ? "Context overflow detected, " : "";
this.autoCompactionLoader = new Loader(
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
`${reasonText}Auto-compacting... (esc to cancel)`,
);
this.statusContainer.addChild(this.autoCompactionLoader);
this.ui.requestRender();
break;
}
case "auto_compaction_end": {
// Re-enable submit
this.editor.disableSubmit = false;
// Restore escape handler
if (this.autoCompactionEscapeHandler) {
this.editor.onEscape = this.autoCompactionEscapeHandler;
this.autoCompactionEscapeHandler = undefined;
}
// Stop loader
if (this.autoCompactionLoader) {
this.autoCompactionLoader.stop();
this.autoCompactionLoader = undefined;
this.statusContainer.clear();
}
// Handle result
if (event.aborted) {
this.showStatus("Auto-compaction cancelled");
} else if (event.result) {
// Rebuild chat to show compacted state
this.chatContainer.clear();
this.rebuildChatFromMessages();
// Add compaction component at bottom so user sees it without scrolling
this.addMessageToChat({
role: "compactionSummary",
tokensBefore: event.result.tokensBefore,
summary: event.result.summary,
timestamp: Date.now(),
});
this.footer.invalidate();
}
this.ui.requestRender();
break;
}
case "auto_retry_start": {
// Set up escape to abort retry
this.retryEscapeHandler = this.editor.onEscape;
this.editor.onEscape = () => {
this.session.abortRetry();
};
// Show retry indicator
this.statusContainer.clear();
const delaySeconds = Math.round(event.delayMs / 1000);
this.retryLoader = new Loader(
this.ui,
(spinner) => theme.fg("warning", spinner),
(text) => theme.fg("muted", text),
`Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s... (esc to cancel)`,
);
this.statusContainer.addChild(this.retryLoader);
this.ui.requestRender();
break;
}
case "auto_retry_end": {
// Restore escape handler
if (this.retryEscapeHandler) {
this.editor.onEscape = this.retryEscapeHandler;
this.retryEscapeHandler = undefined;
}
// Stop loader
if (this.retryLoader) {
this.retryLoader.stop();
this.retryLoader = undefined;
this.statusContainer.clear();
}
// Show error only on final failure (success shows normal response)
if (!event.success) {
this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
}
this.ui.requestRender();
break;
}
}
}
/** Extract text content from a user message */
private getUserMessageText(message: Message): string {
if (message.role !== "user") return "";
const textBlocks =
typeof message.content === "string"
? [{ type: "text", text: message.content }]
: message.content.filter((c: { type: string }) => c.type === "text");
return textBlocks.map((c) => (c as { text: string }).text).join("");
}
/**
* Show a status message in the chat.
*
* If multiple status messages are emitted back-to-back (without anything else being added to the chat),
* we update the previous status line instead of appending new ones to avoid log spam.
*/
private showStatus(message: string): void {
const children = this.chatContainer.children;
const last = children.length > 0 ? children[children.length - 1] : undefined;
const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
this.lastStatusText.setText(theme.fg("dim", message));
this.ui.requestRender();
return;
}
const spacer = new Spacer(1);
const text = new Text(theme.fg("dim", message), 1, 0);
this.chatContainer.addChild(spacer);
this.chatContainer.addChild(text);
this.lastStatusSpacer = spacer;
this.lastStatusText = text;
this.ui.requestRender();
}
private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
switch (message.role) {
case "bashExecution": {
const component = new BashExecutionComponent(message.command, this.ui);
if (message.output) {
component.appendOutput(message.output);
}
component.setComplete(
message.exitCode,
message.cancelled,
message.truncated ? ({ truncated: true } as TruncationResult) : undefined,
message.fullOutputPath,
);
this.chatContainer.addChild(component);
break;
}
case "hookMessage": {
if (message.display) {
const renderer = this.session.hookRunner?.getMessageRenderer(message.customType);
this.chatContainer.addChild(new HookMessageComponent(message, renderer));
}
break;
}
case "compactionSummary": {
this.chatContainer.addChild(new Spacer(1));
const component = new CompactionSummaryMessageComponent(message);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
break;
}
case "branchSummary": {
this.chatContainer.addChild(new Spacer(1));
const component = new BranchSummaryMessageComponent(message);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
break;
}
case "fileMention": {
// Render compact file mention display
for (const file of message.files) {
const text = `${theme.fg("dim", "⎿ ")}${theme.fg("muted", "Read")} ${theme.fg("accent", file.path)} ${theme.fg("dim", `(${file.lineCount} lines)`)}`;
this.chatContainer.addChild(new Text(text, 0, 0));
}
break;
}
case "user": {
const textContent = this.getUserMessageText(message);
if (textContent) {
const userComponent = new UserMessageComponent(textContent);
this.chatContainer.addChild(userComponent);
if (options?.populateHistory) {
this.editor.addToHistory(textContent);
}
}
break;
}
case "assistant": {
const assistantComponent = new AssistantMessageComponent(message, this.hideThinkingBlock);
this.chatContainer.addChild(assistantComponent);
break;
}
case "toolResult": {
// Tool results are rendered inline with tool calls, handled separately
break;
}
default: {
const _exhaustive: never = message;
}
}
}
/**
* Render session context to chat. Used for initial load and rebuild after compaction.
* @param sessionContext Session context to render
* @param options.updateFooter Update footer state
* @param options.populateHistory Add user messages to editor history
*/
private renderSessionContext(
sessionContext: SessionContext,
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
): void {
this.pendingTools.clear();
if (options.updateFooter) {
this.footer.invalidate();
this.updateEditorBorderColor();
}
for (const message of sessionContext.messages) {
// Assistant messages need special handling for tool calls
if (message.role === "assistant") {
this.addMessageToChat(message);
// Render tool call components
for (const content of message.content) {
if (content.type === "toolCall") {
const component = new ToolExecutionComponent(
content.name,
content.arguments,
{ showImages: this.settingsManager.getShowImages() },
this.customTools.get(content.name)?.tool,
this.ui,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
if (message.stopReason === "aborted" || message.stopReason === "error") {
const errorMessage =
message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error";
component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
} else {
this.pendingTools.set(content.id, component);
}
}
}
} else if (message.role === "toolResult") {
// Match tool results to pending tool components
const component = this.pendingTools.get(message.toolCallId);
if (component) {
component.updateResult(message);
this.pendingTools.delete(message.toolCallId);
}
} else {
// All other messages use standard rendering
this.addMessageToChat(message, options);
}
}
this.pendingTools.clear();
this.ui.requestRender();
}
renderInitialMessages(): void {
// Get aligned messages and entries from session context
const context = this.sessionManager.buildSessionContext();
this.renderSessionContext(context, {
updateFooter: true,
populateHistory: true,
});
// Show compaction info if session was compacted
const allEntries = this.sessionManager.getEntries();
const compactionCount = allEntries.filter((e) => e.type === "compaction").length;
if (compactionCount > 0) {
const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
this.showStatus(`Session compacted ${times}`);
}
}
async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> {
return new Promise((resolve) => {
this.onInputCallback = (input) => {
this.onInputCallback = undefined;
resolve(input);
};
});
}
private rebuildChatFromMessages(): void {
this.chatContainer.clear();
const context = this.sessionManager.buildSessionContext();
this.renderSessionContext(context);
}
// =========================================================================
// Key handlers
// =========================================================================
private handleCtrlC(): void {
const now = Date.now();
if (now - this.lastSigintTime < 500) {
void this.shutdown();
} else {
this.clearEditor();
this.lastSigintTime = now;
}
}
private handleCtrlD(): void {
// Only called when editor is empty (enforced by CustomEditor)
void this.shutdown();
}
/**
* Gracefully shutdown the agent.
* Emits shutdown event to hooks and tools, then exits.
*/
private async shutdown(): Promise<void> {
// Emit shutdown event to hooks
const hookRunner = this.session.hookRunner;
if (hookRunner?.hasHandlers("session_shutdown")) {
await hookRunner.emit({
type: "session_shutdown",
});
}
// Emit shutdown event to custom tools
await this.session.emitCustomToolSessionEvent("shutdown");
this.stop();
process.exit(0);
}
private handleCtrlZ(): void {
// Set up handler to restore TUI when resumed
process.once("SIGCONT", () => {
this.ui.start();
this.ui.requestRender(true);
});
// Stop the TUI (restore terminal to normal mode)
this.ui.stop();
// Send SIGTSTP to process group (pid=0 means all processes in group)
process.kill(0, "SIGTSTP");
}
/**
* Handle Ctrl+V for image paste from clipboard.
* Returns true if an image was found and added, false otherwise.
*/
private async handleImagePaste(): Promise<boolean> {
try {
const image = await readImageFromClipboard();
if (image) {
this.pendingImages.push({
type: "image",
data: image.data,
mimeType: image.mimeType,
});
// Insert styled placeholder at cursor like Claude does
const imageNum = this.pendingImages.length;
const placeholder = theme.bold(theme.underline(`[Image #${imageNum}]`));
this.editor.insertText(`${placeholder} `);
this.ui.requestRender();
return true;
}
// No image in clipboard - show hint
this.showStatus("No image in clipboard (use terminal paste for text)");
return false;
} catch {
this.showStatus("Failed to read clipboard");
return false;
}
}
private updateEditorBorderColor(): void {
if (this.isBashMode) {
this.editor.borderColor = theme.getBashModeBorderColor();
} else {
const level = this.session.thinkingLevel || "off";
this.editor.borderColor = theme.getThinkingBorderColor(level);
}
this.ui.requestRender();
}
private cycleThinkingLevel(): void {
const newLevel = this.session.cycleThinkingLevel();
if (newLevel === undefined) {
this.showStatus("Current model does not support thinking");
} else {
this.footer.invalidate();
this.updateEditorBorderColor();
this.showStatus(`Thinking level: ${newLevel}`);
}
}
private async cycleModel(direction: "forward" | "backward"): Promise<void> {
try {
const result = await this.session.cycleModel(direction);
if (result === undefined) {
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
this.showStatus(msg);
} else {
this.footer.invalidate();
this.updateEditorBorderColor();
const thinkingStr =
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
this.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`);
}
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
}
private toggleToolOutputExpansion(): void {
this.toolOutputExpanded = !this.toolOutputExpanded;
for (const child of this.chatContainer.children) {
if (isExpandable(child)) {
child.setExpanded(this.toolOutputExpanded);
}
}
this.ui.requestRender();
}
private toggleThinkingBlockVisibility(): void {
this.hideThinkingBlock = !this.hideThinkingBlock;
this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
// Rebuild chat from session messages
this.chatContainer.clear();
this.rebuildChatFromMessages();
// If streaming, re-add the streaming component with updated visibility and re-render
if (this.streamingComponent && this.streamingMessage) {
this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock);
this.streamingComponent.updateContent(this.streamingMessage);
this.chatContainer.addChild(this.streamingComponent);
}
this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
}
private openExternalEditor(): void {
// Determine editor (respect $VISUAL, then $EDITOR)
const editorCmd = process.env.VISUAL || process.env.EDITOR;
if (!editorCmd) {
this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
return;
}
const currentText = this.editor.getText();
const tmpFile = path.join(os.tmpdir(), `omp-editor-${Date.now()}.omp.md`);
try {
// Write current content to temp file
fs.writeFileSync(tmpFile, currentText, "utf-8");
// Stop TUI to release terminal
this.ui.stop();
// Split by space to support editor arguments (e.g., "code --wait")
const [editor, ...editorArgs] = editorCmd.split(" ");
// Spawn editor synchronously with inherited stdio for interactive editing
const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
// On successful exit (exitCode 0), replace editor content
if (result.exitCode === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
}
// On non-zero exit, keep original text (no action needed)
} finally {
// Clean up temp file
try {
fs.unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
// Restart TUI
this.ui.start();
this.ui.requestRender();
}
}
// =========================================================================
// UI helpers
// =========================================================================
clearEditor(): void {
this.editor.setText("");
this.pendingImages = [];
this.ui.requestRender();
}
showError(errorMessage: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
this.ui.requestRender();
}
showWarning(warningMessage: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
this.ui.requestRender();
}
showNewVersionNotification(newVersion: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.chatContainer.addChild(
new Text(
theme.bold(theme.fg("warning", "Update Available")) +
"\n" +
theme.fg("muted", `New version ${newVersion} is available. Run: `) +
theme.fg("accent", "npm install -g @oh-my-pi/pi-coding-agent"),
1,
0,
),
);
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.requestRender();
}
/**
* Generate a title for the session based on the first user message.
* Runs in background, doesn't block UI.
*/
private maybeGenerateTitle(): void {
// Find the first user message
const messages = this.agent.state.messages;
const firstUserMessage = messages.find((m) => m.role === "user");
if (!firstUserMessage) return;
// Extract text content
let messageText = "";
for (const content of firstUserMessage.content) {
if (typeof content === "string") {
messageText += content;
} else if (content.type === "text") {
messageText += content.text;
}
}
if (!messageText.trim()) return;
// Generate title in background
const registry = this.session.modelRegistry;
const smolModel = this.settingsManager.getModelRole("smol");
generateSessionTitle(messageText, registry, smolModel)
.then((title) => {
if (title) {
this.sessionManager.setSessionTitle(title);
setTerminalTitle(`pi: ${title}`);
}
})
.catch(() => {
// Ignore title generation errors
});
}
private updatePendingMessagesDisplay(): void {
this.pendingMessagesContainer.clear();
const queuedMessages = this.session.getQueuedMessages();
if (queuedMessages.length > 0) {
this.pendingMessagesContainer.addChild(new Spacer(1));
for (const message of queuedMessages) {
const queuedText = theme.fg("dim", `Queued: ${message}`);
this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
}
}
}
/** Move pending bash components from pending area to chat */
private flushPendingBashComponents(): void {
for (const component of this.pendingBashComponents) {
this.pendingMessagesContainer.removeChild(component);
this.chatContainer.addChild(component);
}
this.pendingBashComponents = [];
}
// =========================================================================
// Selectors
// =========================================================================
/**
* Shows a selector component in place of the editor.
* @param create Factory that receives a `done` callback and returns the component and focus target
*/
private showSelector(create: (done: () => void) => { component: Component; focus: Component }): void {
const done = () => {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
};
const { component, focus } = create(done);
this.editorContainer.clear();
this.editorContainer.addChild(component);
this.ui.setFocus(focus);
this.ui.requestRender();
}
private showSettingsSelector(): void {
this.showSelector((done) => {
const selector = new SettingsSelectorComponent(
this.settingsManager,
{
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
thinkingLevel: this.session.thinkingLevel,
availableThemes: getAvailableThemes(),
cwd: process.cwd(),
},
{
onChange: (id, value) => this.handleSettingChange(id, value),
onThemePreview: (themeName) => {
const result = setTheme(themeName, true);
if (result.success) {
this.ui.invalidate();
this.ui.requestRender();
}
},
onPluginsChanged: () => {
this.ui.requestRender();
},
onCancel: () => {
done();
this.ui.requestRender();
},
},
);
return { component: selector, focus: selector };
});
}
/**
* Handle setting changes from the settings selector.
* Most settings are saved directly via SettingsManager in the definitions.
* This handles side effects and session-specific settings.
*/
private handleSettingChange(id: string, value: string | boolean): void {
// Discovery provider toggles
if (id.startsWith("discovery.")) {
const providerId = id.replace("discovery.", "");
if (value) {
enableProvider(providerId);
} else {
disableProvider(providerId);
}
return;
}
switch (id) {
// Session-managed settings (not in SettingsManager)
case "autoCompact":
this.session.setAutoCompactionEnabled(value as boolean);
this.footer.setAutoCompactEnabled(value as boolean);
break;
case "queueMode":
this.session.setQueueMode(value as "all" | "one-at-a-time");
break;
case "interruptMode":
this.session.setInterruptMode(value as "immediate" | "wait");
break;
case "thinkingLevel":
this.session.setThinkingLevel(value as ThinkingLevel);
this.footer.invalidate();
this.updateEditorBorderColor();
break;
// Settings with UI side effects
case "showImages":
for (const child of this.chatContainer.children) {
if (child instanceof ToolExecutionComponent) {
child.setShowImages(value as boolean);
}
}
break;
case "hideThinking":
this.hideThinkingBlock = value as boolean;
for (const child of this.chatContainer.children) {
if (child instanceof AssistantMessageComponent) {
child.setHideThinkingBlock(value as boolean);
}
}
this.chatContainer.clear();
this.rebuildChatFromMessages();
break;
case "theme": {
const result = setTheme(value as string, true);
this.ui.invalidate();
if (!result.success) {
this.showError(`Failed to load theme "${value}": ${result.error}\nFell back to dark theme.`);
}
break;
}
// All other settings are handled by the definitions (get/set on SettingsManager)
// No additional side effects needed
}
}
private showModelSelector(): void {
this.showSelector((done) => {
const selector = new ModelSelectorComponent(
this.ui,
this.session.model,
this.settingsManager,
this.session.modelRegistry,
this.session.scopedModels,
async (model, role) => {
try {
// Only update agent state for default role
if (role === "default") {
await this.session.setModel(model, role);
this.footer.invalidate();
this.updateEditorBorderColor();
}
// For other roles (small), just show status - settings already updated by selector
const roleLabel = role === "default" ? "Default" : role === "smol" ? "Smol" : role;
this.showStatus(`${roleLabel} model: ${model.id}`);
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
// Don't call done() - selector stays open
},
() => {
done();
this.ui.requestRender();
},
);
return { component: selector, focus: selector };
});
}
private showUserMessageSelector(): void {
const userMessages = this.session.getUserMessagesForBranching();
if (userMessages.length === 0) {
this.showStatus("No messages to branch from");
return;
}
this.showSelector((done) => {
const selector = new UserMessageSelectorComponent(
userMessages.map((m) => ({ id: m.entryId, text: m.text })),
async (entryId) => {
const result = await this.session.branch(entryId);
if (result.cancelled) {
// Hook cancelled the branch
done();
this.ui.requestRender();
return;
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
done();
this.showStatus("Branched to new session");
},
() => {
done();
this.ui.requestRender();
},
);
return { component: selector, focus: selector.getMessageList() };
});
}
private showTreeSelector(): void {
const tree = this.sessionManager.getTree();
const realLeafId = this.sessionManager.getLeafId();
// Find the visible leaf for display (skip metadata entries like labels)
let visibleLeafId = realLeafId;
while (visibleLeafId) {
const entry = this.sessionManager.getEntry(visibleLeafId);
if (!entry) break;
if (entry.type !== "label" && entry.type !== "custom") break;
visibleLeafId = entry.parentId ?? null;
}
if (tree.length === 0) {
this.showStatus("No entries in session");
return;
}
this.showSelector((done) => {
const selector = new TreeSelectorComponent(
tree,
visibleLeafId,
this.ui.terminal.rows,
async (entryId) => {
// Selecting the visible leaf is a no-op (already there)
if (entryId === visibleLeafId) {
done();
this.showStatus("Already at this point");
return;
}
// Ask about summarization
done(); // Close selector first
const wantsSummary = await this.showHookConfirm(
"Summarize branch?",
"Create a summary of the branch you're leaving?",
);
// Set up escape handler and loader if summarizing
let summaryLoader: Loader | undefined;
const originalOnEscape = this.editor.onEscape;
if (wantsSummary) {
this.editor.onEscape = () => {
this.session.abortBranchSummary();
};
this.chatContainer.addChild(new Spacer(1));
summaryLoader = new Loader(
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
"Summarizing branch... (esc to cancel)",
);
this.statusContainer.addChild(summaryLoader);
this.ui.requestRender();
}
try {
const result = await this.session.navigateTree(entryId, { summarize: wantsSummary });
if (result.aborted) {
// Summarization aborted - re-show tree selector
this.showStatus("Branch summarization cancelled");
this.showTreeSelector();
return;
}
if (result.cancelled) {
this.showStatus("Navigation cancelled");
return;
}
// Update UI
this.chatContainer.clear();
this.renderInitialMessages();
if (result.editorText) {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
} finally {
if (summaryLoader) {
summaryLoader.stop();
this.statusContainer.clear();
}
this.editor.onEscape = originalOnEscape;
}
},
() => {
done();
this.ui.requestRender();
},
(entryId, label) => {
this.sessionManager.appendLabelChange(entryId, label);
this.ui.requestRender();
},
);
return { component: selector, focus: selector };
});
}
private showSessionSelector(): void {
this.showSelector((done) => {
const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir());
const selector = new SessionSelectorComponent(
sessions,
async (sessionPath) => {
done();
await this.handleResumeSession(sessionPath);
},
() => {
done();
this.ui.requestRender();
},
() => {
void this.shutdown();
},
);
return { component: selector, focus: selector.getSessionList() };
});
}
private async handleResumeSession(sessionPath: string): Promise<void> {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Clear UI state
this.pendingMessagesContainer.clear();
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
// Switch session via AgentSession (emits hook and tool session events)
await this.session.switchSession(sessionPath);
// Clear and re-render the chat
this.chatContainer.clear();
this.renderInitialMessages();
this.showStatus("Resumed session");
}
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
if (mode === "logout") {
const providers = this.session.modelRegistry.authStorage.list();
const loggedInProviders = providers.filter(
(p) => this.session.modelRegistry.authStorage.get(p)?.type === "oauth",
);
if (loggedInProviders.length === 0) {
this.showStatus("No OAuth providers logged in. Use /login first.");
return;
}
}
this.showSelector((done) => {
const selector = new OAuthSelectorComponent(
mode,
this.session.modelRegistry.authStorage,
async (providerId: string) => {
done();
if (mode === "login") {
this.showStatus(`Logging in to ${providerId}...`);
try {
await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
onAuth: (info: { url: string; instructions?: string }) => {
this.chatContainer.addChild(new Spacer(1));
// Use OSC 8 hyperlink escape sequence for clickable link
const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
this.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
if (info.instructions) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
}
this.ui.requestRender();
const openCmd =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
Bun.spawn([openCmd, info.url], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
},
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
if (prompt.placeholder) {
this.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
}
this.ui.requestRender();
return new Promise<string>((resolve) => {
const codeInput = new Input();
codeInput.onSubmit = () => {
const code = codeInput.getValue();
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
resolve(code);
};
this.editorContainer.clear();
this.editorContainer.addChild(codeInput);
this.ui.setFocus(codeInput);
this.ui.requestRender();
});
},
onProgress: (message: string) => {
this.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
this.ui.requestRender();
},
});
// Refresh models to pick up new baseUrl (e.g., github-copilot)
this.session.modelRegistry.refresh();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(theme.fg("success", `✓ Successfully logged in to ${providerId}`), 1, 0),
);
this.chatContainer.addChild(
new Text(theme.fg("dim", `Credentials saved to ${getAuthPath()}`), 1, 0),
);
this.ui.requestRender();
} catch (error: unknown) {
this.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
try {
this.session.modelRegistry.authStorage.logout(providerId);
// Refresh models to reset baseUrl
this.session.modelRegistry.refresh();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(theme.fg("success", `✓ Successfully logged out of ${providerId}`), 1, 0),
);
this.chatContainer.addChild(
new Text(theme.fg("dim", `Credentials removed from ${getAuthPath()}`), 1, 0),
);
this.ui.requestRender();
} catch (error: unknown) {
this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
},
() => {
done();
this.ui.requestRender();
},
);
return { component: selector, focus: selector };
});
}
// =========================================================================
// Command handlers
// =========================================================================
private async handleExportCommand(text: string): Promise<void> {
const parts = text.split(/\s+/);
const arg = parts.length > 1 ? parts[1] : undefined;
// Check for clipboard export
if (arg === "--copy" || arg === "clipboard" || arg === "copy") {
try {
const formatted = this.session.formatSessionAsText();
if (!formatted) {
this.showError("No messages to export yet.");
return;
}
await copyToClipboard(formatted);
this.showStatus("Session copied to clipboard");
} catch (error: unknown) {
this.showError(`Failed to copy session: ${error instanceof Error ? error.message : "Unknown error"}`);
}
return;
}
// HTML file export
try {
const filePath = await this.session.exportToHtml(arg);
this.showStatus(`Session exported to: ${filePath}`);
} catch (error: unknown) {
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
private async handleShareCommand(): Promise<void> {
// Check if gh is available and logged in
try {
const authResult = Bun.spawnSync(["gh", "auth", "status"]);
if (authResult.exitCode !== 0) {
this.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
return;
}
} catch {
this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
return;
}
// Export to a temp file
const tmpFile = path.join(os.tmpdir(), "session.html");
try {
await this.session.exportToHtml(tmpFile);
} catch (error: unknown) {
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
return;
}
// Show cancellable loader, replacing the editor
const loader = new BorderedLoader(this.ui, theme, "Creating gist...");
this.editorContainer.clear();
this.editorContainer.addChild(loader);
this.ui.setFocus(loader);
this.ui.requestRender();
const restoreEditor = () => {
loader.dispose();
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
try {
fs.unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
};
// Create a secret gist asynchronously
let proc: ReturnType<typeof Bun.spawn> | null = null;
loader.onAbort = () => {
proc?.kill();
restoreEditor();
this.showStatus("Share cancelled");
};
try {
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], {
stdout: "pipe",
stderr: "pipe",
});
let stdout = "";
let stderr = "";
const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();
(async () => {
try {
while (true) {
const { done, value } = await stdoutReader.read();
if (done) break;
stdout += decoder.decode(value);
}
} catch {}
})();
(async () => {
try {
while (true) {
const { done, value } = await stderrReader.read();
if (done) break;
stderr += decoder.decode(value);
}
} catch {}
})();
proc.exited.then((code) => resolve({ stdout, stderr, code }));
});
if (loader.signal.aborted) return;
restoreEditor();
if (result.code !== 0) {
const errorMsg = result.stderr?.trim() || "Unknown error";
this.showError(`Failed to create gist: ${errorMsg}`);
return;
}
// Extract gist ID from the URL returned by gh
// gh returns something like: https://gist.github.com/username/GIST_ID
const gistUrl = result.stdout?.trim();
const gistId = gistUrl?.split("/").pop();
if (!gistId) {
this.showError("Failed to parse gist ID from gh output");
return;
}
// Create the preview URL
const previewUrl = `https://gistpreview.github.io/?${gistId}`;
this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
} catch (error: unknown) {
if (!loader.signal.aborted) {
restoreEditor();
this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
}
private async handleCopyCommand(): Promise<void> {
const text = this.session.getLastAssistantText();
if (!text) {
this.showError("No agent messages to copy yet.");
return;
}
try {
await copyToClipboard(text);
this.showStatus("Copied last agent message to clipboard");
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
}
private handleSessionCommand(): void {
const stats = this.session.getSessionStats();
let info = `${theme.bold("Session Info")}\n\n`;
info += `${theme.fg("dim", "File:")} ${stats.sessionFile}\n`;
info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
info += `${theme.bold("Messages")}\n`;
info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`;
info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;
info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;
info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`;
info += `${theme.bold("Tokens")}\n`;
info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
if (stats.tokens.cacheRead > 0) {
info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`;
}
if (stats.tokens.cacheWrite > 0) {
info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`;
}
info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
if (stats.cost > 0) {
info += `\n${theme.bold("Cost")}\n`;
info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`;
}
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(info, 1, 0));
this.ui.requestRender();
}
private handleChangelogCommand(): void {
const changelogPath = getChangelogPath();
const allEntries = parseChangelog(changelogPath);
const changelogMarkdown =
allEntries.length > 0
? allEntries
.reverse()
.map((e) => e.content)
.join("\n\n")
: "No changelog entries found.";
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder());
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.ui.addChild(new Spacer(1));
this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme()));
this.chatContainer.addChild(new DynamicBorder());
this.ui.requestRender();
}
private handleHotkeysCommand(): void {
const hotkeys = `
**Navigation**
| Key | Action |
|-----|--------|
| \`Arrow keys\` | Move cursor / browse history (Up when empty) |
| \`Option+Left/Right\` | Move by word |
| \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line |
| \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line |
**Editing**
| Key | Action |
|-----|--------|
| \`Enter\` | Send message |
| \`Shift+Enter\` / \`Alt+Enter\` | New line |
| \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards |
| \`Ctrl+U\` | Delete to start of line |
| \`Ctrl+K\` | Delete to end of line |
**Other**
| Key | Action |
|-----|--------|
| \`Tab\` | Path completion / accept autocomplete |
| \`Escape\` | Cancel autocomplete / abort streaming |
| \`Ctrl+C\` | Clear editor (first) / exit (second) |
| \`Ctrl+D\` | Exit (when editor is empty) |
| \`Ctrl+Z\` | Suspend to background |
| \`Shift+Tab\` | Cycle thinking level |
| \`Ctrl+P\` | Cycle models |
| \`Ctrl+O\` | Toggle tool output expansion |
| \`Ctrl+T\` | Toggle thinking block visibility |
| \`Ctrl+G\` | Edit message in external editor |
| \`/\` | Slash commands |
| \`!\` | Run bash command |
`;
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder());
this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, getMarkdownTheme()));
this.chatContainer.addChild(new DynamicBorder());
this.ui.requestRender();
}
private handleStatusCommand(): void {
const sections: string[] = [];
type StatusSource =
| { provider: string; level: string }
| { mcpServer: string; provider?: string }
| "builtin"
| "unknown";
const capitalize = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1);
const resolveSourceText = (source: StatusSource): string => {
if (source === "builtin") return "builtin";
if (source === "unknown") return "unknown";
if ("mcpServer" in source) {
if (!source.provider) return `mcp:${source.mcpServer}`;
return `${source.mcpServer} via ${source.provider}`;
}
const levelLabel = capitalize(source.level);
return `via ${source.provider} (${levelLabel})`;
};
const renderSourceText = (text: string): string => text.replace(/\bvia\b/, theme.italic("via"));
const truncateText = (text: string, maxLen: number): string => {
if (text.length <= maxLen) return text;
if (maxLen <= 3) return text.slice(0, Math.max(0, maxLen));
return `${text.slice(0, maxLen - 3)}...`;
};
// Helper to format a section with consistent column alignment
const formatSection = <T>(
title: string,
items: readonly T[],
getName: (item: T) => string,
getDesc: (item: T) => string | undefined,
getSource: (item: T) => StatusSource,
): string => {
if (items.length === 0) return "";
const lineItems = items.map((item) => {
const name = getName(item);
const desc = getDesc(item);
const sourceText = resolveSourceText(getSource(item));
const nameWithSource = sourceText ? `${name} ${sourceText}` : name;
return { name, sourceText, nameWithSource, desc };
});
const maxNameWidth = Math.min(60, Math.max(...lineItems.map((line) => line.nameWithSource.length)));
const formattedLines = lineItems.map((line) => {
let nameText = line.name;
let sourceText = line.sourceText;
if (sourceText) {
let availableForName = maxNameWidth - sourceText.length - 1;
if (availableForName < 1) {
sourceText = truncateText(sourceText, Math.max(0, maxNameWidth - 4));
availableForName = maxNameWidth - sourceText.length - 1;
}
nameText = truncateText(nameText, Math.max(1, availableForName));
} else {
nameText = truncateText(nameText, maxNameWidth);
}
const nameWithSourcePlain = sourceText ? `${nameText} ${sourceText}` : nameText;
const sourceRendered = sourceText ? renderSourceText(sourceText) : "";
const nameRendered = sourceText ? `${theme.bold(nameText)} ${sourceRendered}` : theme.bold(nameText);
const pad = Math.max(0, maxNameWidth - nameWithSourcePlain.length);
const desc = line.desc?.trim();
const descPart = desc ? ` ${theme.fg("dim", desc.slice(0, 50) + (desc.length > 50 ? "..." : ""))}` : "";
return ` ${nameRendered}${" ".repeat(pad)}${descPart}`;
});
return `${theme.bold(theme.fg("accent", title))}\n${formattedLines.join("\n")}`;
};
// Loaded context files
const contextFilesResult = loadSync(contextFileCapability.id, { cwd: process.cwd() });
const contextFiles = contextFilesResult.items as ContextFile[];
if (contextFiles.length > 0) {
sections.push(
formatSection(
"Context Files",
contextFiles,
(f) => basename(f.path),
() => undefined,
(f) => ({ provider: f._source.providerName, level: f.level }),
),
);
}
// Loaded skills
const skillsSettings = this.session.skillsSettings;
if (skillsSettings?.enabled !== false) {
const { skills, warnings: skillWarnings } = loadSkills(skillsSettings ?? {});
if (skills.length > 0) {
sections.push(
formatSection(
"Skills",
skills,
(s) => s.name,
(s) => s.description,
(s) => (s._source ? { provider: s._source.providerName, level: s._source.level } : "unknown"),
),
);
}
if (skillWarnings.length > 0) {
sections.push(
theme.bold(theme.fg("warning", "Skill Warnings")) +
"\n" +
skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n"),
);
}
}
// Loaded rules
const rulesResult = loadSync<Rule>(ruleCapability.id, { cwd: process.cwd() });
if (rulesResult.items.length > 0) {
sections.push(
formatSection(
"Rules",
rulesResult.items,
(r) => r.name,
(r) => r.description,
(r) => ({ provider: r._source.providerName, level: r._source.level }),
),
);
}
// Loaded prompts
const promptsResult = loadSync<Prompt>(promptCapability.id, { cwd: process.cwd() });
if (promptsResult.items.length > 0) {
sections.push(
formatSection(
"Prompts",
promptsResult.items,
(p) => p.name,
() => undefined,
(p) => ({ provider: p._source.providerName, level: p._source.level }),
),
);
}
// Loaded instructions
const instructionsResult = loadSync<Instruction>(instructionCapability.id, { cwd: process.cwd() });
if (instructionsResult.items.length > 0) {
sections.push(
formatSection(
"Instructions",
instructionsResult.items,
(i) => i.name,
(i) => (i.applyTo ? `applies to: ${i.applyTo}` : undefined),
(i) => ({ provider: i._source.providerName, level: i._source.level }),
),
);
}
// Loaded custom tools - split MCP from non-MCP
if (this.customTools.size > 0) {
const allTools = Array.from(this.customTools.values());
const mcpTools = allTools.filter((ct) => ct.path.startsWith("mcp:"));
const customTools = allTools.filter((ct) => !ct.path.startsWith("mcp:"));
// MCP Tools section
if (mcpTools.length > 0) {
sections.push(
formatSection(
"MCP Tools",
mcpTools,
(ct) => ct.tool.label || ct.tool.name,
() => undefined,
(ct) => {
const match = ct.path.match(/^mcp:(.+?) via (.+)$/);
if (match) {
const [, serverName, providerName] = match;
return { mcpServer: serverName, provider: providerName };
}
return ct.path.startsWith("mcp:") ? { mcpServer: ct.path.slice(4) } : "unknown";
},
),
);
}
// Custom Tools section
if (customTools.length > 0) {
sections.push(
formatSection(
"Custom Tools",
customTools,
(ct) => ct.tool.label || ct.tool.name,
(ct) => ct.tool.description,
(ct) => {
if (ct.source?.provider === "builtin") return "builtin";
if (ct.path === "<exa>") return "builtin";
return ct.source ? { provider: ct.source.providerName, level: ct.source.level } : "unknown";
},
),
);
}
}
// Loaded slash commands (file-based)
const fileCommands = this.session.fileCommands;
if (fileCommands.length > 0) {
sections.push(
formatSection(
"Slash Commands",
fileCommands,
(cmd) => `/${cmd.name}`,
(cmd) => cmd.description,
(cmd) => (cmd._source ? { provider: cmd._source.providerName, level: cmd._source.level } : "unknown"),
),
);
}
// Loaded hooks
const hookRunner = this.session.hookRunner;
if (hookRunner) {
const hookPaths = hookRunner.getHookPaths();
if (hookPaths.length > 0) {
sections.push(
`${theme.bold(theme.fg("accent", "Hooks"))}\n${hookPaths.map((p) => ` ${theme.bold(basename(p))} ${theme.fg("dim", "hook")}`).join("\n")}`,
);
}
}
if (sections.length === 0) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("muted", "No extensions loaded."), 1, 0));
} else {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder());
this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Loaded Extensions")), 1, 0));
this.chatContainer.addChild(new Spacer(1));
for (const section of sections) {
this.chatContainer.addChild(new Text(section, 1, 0));
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(new DynamicBorder());
}
this.ui.requestRender();
}
private async handleClearCommand(): Promise<void> {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// New session via session (emits hook and tool session events)
await this.session.newSession();
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
}
private handleDebugCommand(): void {
const width = this.ui.terminal.columns;
const allLines = this.ui.render(width);
const debugLogPath = getDebugLogPath();
const debugData = [
`Debug output at ${new Date().toISOString()}`,
`Terminal width: ${width}`,
`Total lines: ${allLines.length}`,
"",
"=== All rendered lines with visible widths ===",
...allLines.map((line, idx) => {
const vw = visibleWidth(line);
const escaped = JSON.stringify(line);
return `[${idx}] (w=${vw}) ${escaped}`;
}),
"",
"=== Agent messages (JSONL) ===",
...this.session.messages.map((msg) => JSON.stringify(msg)),
"",
].join("\n");
fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
fs.writeFileSync(debugLogPath, debugData);
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(`${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`, 1, 1),
);
this.ui.requestRender();
}
private handleArminSaysHi(): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new ArminComponent(this.ui));
this.ui.requestRender();
}
private async handleBashCommand(command: string): Promise<void> {
const isDeferred = this.session.isStreaming;
this.bashComponent = new BashExecutionComponent(command, this.ui);
if (isDeferred) {
// Show in pending area when agent is streaming
this.pendingMessagesContainer.addChild(this.bashComponent);
this.pendingBashComponents.push(this.bashComponent);
} else {
// Show in chat immediately when agent is idle
this.chatContainer.addChild(this.bashComponent);
}
this.ui.requestRender();
try {
const result = await this.session.executeBash(command, (chunk) => {
if (this.bashComponent) {
this.bashComponent.appendOutput(chunk);
this.ui.requestRender();
}
});
if (this.bashComponent) {
this.bashComponent.setComplete(
result.exitCode,
result.cancelled,
result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined,
result.fullOutputPath,
);
}
} catch (error) {
if (this.bashComponent) {
this.bashComponent.setComplete(undefined, false);
}
this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
}
this.bashComponent = undefined;
this.ui.requestRender();
}
private async handleCompactCommand(customInstructions?: string): Promise<void> {
const entries = this.sessionManager.getEntries();
const messageCount = entries.filter((e) => e.type === "message").length;
if (messageCount < 2) {
this.showWarning("Nothing to compact (no messages yet)");
return;
}
await this.executeCompaction(customInstructions, false);
}
private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Set up escape handler during compaction
const originalOnEscape = this.editor.onEscape;
this.editor.onEscape = () => {
this.session.abortCompaction();
};
// Show compacting status
this.chatContainer.addChild(new Spacer(1));
const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
const compactingLoader = new Loader(
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
label,
);
this.statusContainer.addChild(compactingLoader);
this.ui.requestRender();
try {
const result = await this.session.compact(customInstructions);
// Rebuild UI
this.rebuildChatFromMessages();
// Add compaction component at bottom so user sees it without scrolling
const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString());
this.addMessageToChat(msg);
this.footer.invalidate();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
this.showError("Compaction cancelled");
} else {
this.showError(`Compaction failed: ${message}`);
}
} finally {
compactingLoader.stop();
this.statusContainer.clear();
this.editor.onEscape = originalOnEscape;
}
}
stop(): void {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.footer.dispose();
if (this.unsubscribe) {
this.unsubscribe();
}
if (this.isInitialized) {
this.ui.stop();
this.isInitialized = false;
}
}
}