import * as fs from "node:fs/promises";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
import { $env, sanitizeText } from "@oh-my-pi/pi-utils";
import { getRoleInfo } from "../../config/model-registry";
import { isSettingsInitialized, settings } from "../../config/settings";
import { renderSegmentTrack } from "../../modes/components/segment-track";
import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
import { expandEmoticons } from "../../modes/emoji-autocomplete";
import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
import type { InteractiveModeContext } from "../../modes/types";
import type { AgentSessionEvent } from "../../session/agent-session";
import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
import { isTinyTitleLocalModelKey } from "../../tiny/models";
import { tinyTitleClient } from "../../tiny/title-client";
import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
import { ensureSupportedImageInput } from "../../utils/image-loading";
import { resizeImage } from "../../utils/image-resize";
import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
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";
}
const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
export class InputController {
constructor(private ctx: InteractiveModeContext) {}
#showTinyTitleDownloadProgress(modelKey: string): void {
if (!isTinyTitleLocalModelKey(modelKey) || this.ctx.isBackgrounded) return;
const component = new TinyTitleDownloadProgressComponent(modelKey);
let added = false;
let disposed = false;
let removeTimer: NodeJS.Timeout | undefined;
const remove = (): void => {
if (disposed) return;
disposed = true;
unsubscribe();
if (removeTimer) {
clearTimeout(removeTimer);
removeTimer = undefined;
}
if (added) {
this.ctx.chatContainer.removeChild(component);
this.ctx.ui.requestRender();
}
};
const scheduleRemove = (): void => {
if (removeTimer) clearTimeout(removeTimer);
removeTimer = setTimeout(remove, TINY_TITLE_PROGRESS_DONE_TTL_MS);
removeTimer.unref?.();
};
let revealAt = 0;
const update = (event: TinyTitleProgressEvent): void => {
if (disposed || event.modelKey !== modelKey) return;
component.update(event);
if (revealAt === 0) revealAt = performance.now() + TINY_TITLE_PROGRESS_REVEAL_DELAY_MS;
const complete = component.isComplete();
if (!added && !complete && performance.now() >= revealAt) {
this.ctx.chatContainer.addChild(component);
added = true;
}
if (added) this.ctx.ui.requestRender();
if (complete) {
if (added) scheduleRemove();
else remove();
}
};
const unsubscribe = tinyTitleClient.onProgress(update);
}
setupKeyHandlers(): void {
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
Boolean(
this.ctx.loadingAnimation ||
this.ctx.hasActiveBtw() ||
this.ctx.hasActiveOmfg() ||
this.ctx.session.isStreaming ||
this.ctx.session.isCompacting ||
this.ctx.session.isGeneratingHandoff ||
this.ctx.session.isBashRunning ||
this.ctx.session.isEvalRunning ||
this.ctx.autoCompactionLoader ||
this.ctx.retryLoader ||
this.ctx.autoCompactionEscapeHandler ||
this.ctx.retryEscapeHandler,
);
this.ctx.editor.onEscape = () => {
if (this.ctx.loopModeEnabled) {
this.ctx.pauseLoop();
if (this.ctx.session.isStreaming) {
void this.ctx.session.abort();
} else {
this.ctx.cancelPendingSubmission();
}
return;
}
if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
return;
}
if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
return;
}
if (this.ctx.loadingAnimation) {
if (this.ctx.cancelPendingSubmission()) {
return;
}
this.restoreQueuedMessagesToEditor({ abort: true });
} else if (this.ctx.session.isBashRunning) {
this.ctx.session.abortBash();
} else if (this.ctx.isBashMode) {
this.ctx.editor.setText("");
this.ctx.isBashMode = false;
this.ctx.updateEditorBorderColor();
} else if (this.ctx.session.isEvalRunning) {
this.ctx.session.abortEval();
} else if (this.ctx.isPythonMode) {
this.ctx.editor.setText("");
this.ctx.isPythonMode = false;
this.ctx.updateEditorBorderColor();
} else if (this.ctx.session.isStreaming) {
void this.ctx.session.abort();
} else if (!this.ctx.editor.getText().trim()) {
const action = settings.get("doubleEscapeAction");
if (action !== "none") {
const now = Date.now();
if (now - this.ctx.lastEscapeTime < 500) {
if (action === "tree") {
this.ctx.showTreeSelector();
} else {
this.ctx.showUserMessageSelector();
}
this.ctx.lastEscapeTime = 0;
} else {
this.ctx.lastEscapeTime = now;
}
}
}
};
this.ctx.editor.setActionKeys("app.clear", this.ctx.keybindings.getKeys("app.clear"));
this.ctx.editor.onClear = () => this.handleCtrlC();
this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit"));
this.ctx.editor.onExit = () => this.handleCtrlD();
this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend"));
this.ctx.editor.onSuspend = () => this.handleCtrlZ();
this.ctx.editor.setActionKeys("app.thinking.cycle", this.ctx.keybindings.getKeys("app.thinking.cycle"));
this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel();
this.ctx.editor.setActionKeys("app.model.cycleForward", this.ctx.keybindings.getKeys("app.model.cycleForward"));
this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel("forward");
this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel("backward");
this.ctx.editor.setActionKeys(
"app.model.selectTemporary",
this.ctx.keybindings.getKeys("app.model.selectTemporary"),
);
this.ctx.editor.onSelectModelTemporary = () => this.ctx.showModelSelector({ temporaryOnly: true });
this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
this.ctx.editor.setActionKeys("app.model.select", this.ctx.keybindings.getKeys("app.model.select"));
this.ctx.editor.onSelectModel = () => this.ctx.showModelSelector();
this.ctx.editor.setActionKeys("app.history.search", this.ctx.keybindings.getKeys("app.history.search"));
this.ctx.editor.onHistorySearch = () => this.ctx.showHistorySearch();
this.ctx.editor.setActionKeys("app.thinking.toggle", this.ctx.keybindings.getKeys("app.thinking.toggle"));
this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility();
this.ctx.editor.setActionKeys("app.editor.external", this.ctx.keybindings.getKeys("app.editor.external"));
this.ctx.editor.onExternalEditor = () => void this.openExternalEditor();
this.ctx.editor.setActionKeys(
"app.clipboard.pasteImage",
this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
);
this.ctx.editor.onPasteImage = () => this.handleImagePaste();
this.ctx.editor.setActionKeys(
"app.clipboard.copyPrompt",
this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
);
this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand"));
this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue"));
this.ctx.editor.onDequeue = () => this.handleDequeue();
this.ctx.editor.clearCustomKeyHandlers();
this.registerExtensionShortcuts();
const planModeKeys = this.ctx.keybindings.getKeys("app.plan.toggle");
for (const key of planModeKeys) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
}
for (const key of this.ctx.keybindings.getKeys("app.session.new")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand());
}
for (const key of this.ctx.keybindings.getKeys("app.session.tree")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector());
}
for (const key of this.ctx.keybindings.getKeys("app.session.fork")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector());
}
for (const key of this.ctx.keybindings.getKeys("app.session.resume")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector());
}
for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
}
for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
}
for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
}
for (const key of this.ctx.keybindings.getKeys("app.session.observe")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionObserver());
}
this.ctx.editor.onChange = (text: string) => {
const wasBashMode = this.ctx.isBashMode;
const wasPythonMode = this.ctx.isPythonMode;
const trimmed = text.trimStart();
this.ctx.isBashMode = text.trimStart().startsWith("!");
this.ctx.isPythonMode = trimmed.startsWith("$") && !trimmed.startsWith("${");
if (wasBashMode !== this.ctx.isBashMode || wasPythonMode !== this.ctx.isPythonMode) {
this.ctx.updateEditorBorderColor();
}
};
}
setupEditorSubmitHandler(): void {
this.ctx.editor.onSubmit = async (text: string) => {
text = text.trim();
if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
if (!text && this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount > 0) {
await this.ctx.session.abort();
return;
}
if (!text) return;
if (text === "." || text === "c") {
if (this.ctx.onInputCallback) {
this.ctx.editor.setText("");
this.ctx.pendingImages = [];
this.ctx.onInputCallback({ text: "", cancelled: false, started: true });
}
return;
}
const runner = this.ctx.session.extensionRunner;
let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
if (runner?.hasHandlers("input")) {
const result = await runner.emitInput(text, inputImages, "interactive");
if (result?.handled) {
this.ctx.editor.setText("");
this.ctx.pendingImages = [];
return;
}
if (result?.text !== undefined) {
text = result.text.trim();
}
if (result?.images !== undefined) {
inputImages = result.images;
}
}
if (!text) return;
const slashResult = await executeBuiltinSlashCommand(text, {
ctx: this.ctx,
handleBackgroundCommand: () => this.handleBackgroundCommand(),
});
if (slashResult === true) {
return;
}
if (typeof slashResult === "string") {
text = slashResult;
}
if (await this.#invokeSkillCommand(text, "steer")) {
return;
}
if (text.startsWith("!")) {
const isExcluded = text.startsWith("!!");
const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
if (command) {
if (this.ctx.session.isBashRunning) {
this.ctx.showWarning("A bash command is already running. Press Esc to cancel it first.");
this.ctx.editor.setText(text);
return;
}
this.ctx.editor.addToHistory(text);
await this.ctx.handleBashCommand(command, isExcluded);
this.ctx.isBashMode = false;
this.ctx.updateEditorBorderColor();
return;
}
}
if (text.startsWith("$")) {
const isExcluded = text.startsWith("$$");
const code = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
if (code) {
if (this.ctx.session.isEvalRunning) {
this.ctx.showWarning("A Python execution is already running. Press Esc to cancel it first.");
this.ctx.editor.setText(text);
return;
}
this.ctx.editor.addToHistory(text);
await this.ctx.handlePythonCommand(code, isExcluded);
this.ctx.isPythonMode = false;
this.ctx.updateEditorBorderColor();
return;
}
}
if (this.ctx.loopModeEnabled) {
this.ctx.loopPrompt = text;
}
if (this.ctx.session.isCompacting) {
if (this.ctx.pendingImages.length > 0) {
this.ctx.showStatus("Compaction in progress. Retry after it completes to send images.");
return;
}
this.ctx.queueCompactionMessage(text, "steer");
return;
}
if (this.ctx.session.isStreaming) {
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
this.ctx.pendingImages = [];
await this.ctx.withLocalSubmission(
text,
() => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
{ imageCount: images?.length ?? 0 },
);
this.ctx.updatePendingMessagesDisplay();
this.ctx.ui.requestRender();
return;
}
this.ctx.flushPendingBashComponents();
const hasUserMessages = this.ctx.session.messages.some((m: AgentMessage) => m.role === "user");
if (!hasUserMessages && !this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE) {
this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
const registry = this.ctx.session.modelRegistry;
generateSessionTitle(
text,
registry,
this.ctx.settings,
this.ctx.session.sessionId,
this.ctx.session.model,
provider => this.ctx.session.agent.metadataForProvider(provider),
)
.then(async title => {
if (title) {
const applied = await this.ctx.sessionManager.setSessionName(title, "auto");
if (applied) {
setSessionTerminalTitle(
this.ctx.sessionManager.getSessionName()!,
this.ctx.sessionManager.getCwd(),
);
this.ctx.updateEditorBorderColor();
}
}
})
.catch(() => {});
}
if (this.ctx.onInputCallback) {
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
this.ctx.pendingImages = [];
const submission = this.ctx.startPendingSubmission({ text, images });
this.ctx.onInputCallback(submission);
}
this.ctx.editor.addToHistory(text);
};
}
handleCtrlC(): void {
const now = Date.now();
if (now - this.ctx.lastSigintTime < 500) {
void this.ctx.shutdown();
} else {
this.ctx.clearEditor();
this.ctx.lastSigintTime = now;
}
}
handleCtrlD(): void {
void this.ctx.shutdown();
}
handleCtrlZ(): void {
process.once("SIGCONT", () => {
this.ctx.ui.start();
this.ctx.ui.requestRender(true);
});
this.ctx.ui.stop();
process.kill(0, "SIGTSTP");
}
handleDequeue(): void {
const restored = this.restoreQueuedMessagesToEditor();
if (restored === 0) {
this.ctx.showStatus("No queued messages to restore");
} else {
this.ctx.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`);
}
}
* Dispatch a `/skill:<name> [args]` invocation through `promptCustomMessage`
* using the supplied `streamingBehavior`. Returns true if the text was a
* recognised skill command and was dispatched. A failure to load the skill
* file is surfaced via `showError` but still returns true — the editor was
* already cleared on the success path, so falling through to plain-text
* handling at that point would double-submit. Returns false when the text
* isn't a `/skill:` prefix or the command name isn't a registered skill,
* so the caller can fall through to plain-text handling (this branch
* leaves the editor state untouched). `streamingBehavior` is only consulted
* while the agent is streaming; the idle path of `promptCustomMessage`
* ignores it.
*/
async #invokeSkillCommand(text: string, streamingBehavior: "steer" | "followUp"): Promise<boolean> {
if (!text.startsWith("/skill:")) return false;
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
const skillPath = this.ctx.skillCommands?.get(commandName);
if (!skillPath) return false;
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
try {
const content = await Bun.file(skillPath).text();
const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
const metaLines = [`Skill: ${skillPath}`];
if (args) {
metaLines.push(`User: ${args}`);
}
const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
const skillName = commandName.slice("skill:".length);
const details: SkillPromptDetails = {
name: skillName || commandName,
path: skillPath,
args: args || undefined,
lineCount: body ? body.split("\n").length : 0,
};
if (this.ctx.session.isStreaming) {
const tag = this.ctx.session.enqueueCustomMessageDisplay(text, streamingBehavior);
details.__pendingDisplayTag = tag;
}
await this.ctx.session.promptCustomMessage(
{
customType: SKILL_PROMPT_MESSAGE_TYPE,
content: message,
display: true,
details,
attribution: "user",
},
{ streamingBehavior },
);
if (this.ctx.session.isStreaming) {
this.ctx.updatePendingMessagesDisplay();
this.ctx.ui.requestRender();
}
} catch (err) {
this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
}
return true;
}
async handleFollowUp(): Promise<void> {
const text = this.ctx.editor.getText().trim();
if (!text) return;
if (this.ctx.session.isCompacting) {
this.ctx.queueCompactionMessage(text, "followUp");
return;
}
if (await this.#invokeSkillCommand(text, "followUp")) {
return;
}
if (this.ctx.session.isStreaming) {
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
await this.ctx.withLocalSubmission(text, () =>
this.ctx.session.prompt(text, { streamingBehavior: "followUp" }),
);
this.ctx.updatePendingMessagesDisplay();
this.ctx.ui.requestRender();
return;
}
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text));
}
restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
this.ctx.locallySubmittedUserSignatures.clear();
const { steering, followUp } = this.ctx.session.clearQueue();
const allQueued = [...steering, ...followUp];
if (allQueued.length === 0) {
this.ctx.updatePendingMessagesDisplay();
if (options?.abort) {
this.ctx.session.abort();
}
return 0;
}
const queuedText = allQueued.join("\n\n");
const currentText = options?.currentText ?? this.ctx.editor.getText();
const combinedText = [queuedText, currentText].filter(t => t.trim()).join("\n\n");
this.ctx.editor.setText(combinedText);
this.ctx.updatePendingMessagesDisplay();
if (options?.abort) {
this.ctx.session.abort();
}
return allQueued.length;
}
handleBackgroundCommand(): void {
if (this.ctx.isBackgrounded) {
this.ctx.showStatus("Background mode already enabled");
return;
}
if (!this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount === 0) {
this.ctx.showWarning("Agent is idle; nothing to background");
return;
}
if (this.ctx.hasActiveBtw()) {
this.ctx.handleBtwEscape();
}
if (this.ctx.hasActiveOmfg()) {
this.ctx.handleOmfgEscape();
}
this.ctx.isBackgrounded = true;
const backgroundUiContext = this.ctx.createBackgroundUiContext();
this.ctx.setToolUIContext(backgroundUiContext, false);
this.ctx.initializeHookRunner(backgroundUiContext, false);
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
if (this.ctx.autoCompactionLoader) {
this.ctx.autoCompactionLoader.stop();
this.ctx.autoCompactionLoader = undefined;
}
if (this.ctx.retryLoader) {
this.ctx.retryLoader.stop();
this.ctx.retryLoader = undefined;
}
this.ctx.statusContainer.clear();
this.ctx.statusLine.dispose();
if (this.ctx.unsubscribe) {
this.ctx.unsubscribe();
}
this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
await this.ctx.handleBackgroundEvent(event);
});
if (this.ctx.isInitialized) {
this.ctx.ui.stop();
this.ctx.isInitialized = false;
}
process.stdout.write("Background mode enabled. Run `bg` to continue in background.\n");
if (process.platform === "win32" || !process.stdout.isTTY) {
process.stdout.write("Backgrounding requires POSIX job control; continuing in foreground.\n");
return;
}
process.kill(0, "SIGTSTP");
}
async handleImagePaste(): Promise<boolean> {
try {
const image = await readImageFromClipboard();
if (image) {
const base64Data = image.data.toBase64();
let imageData = await ensureSupportedImageInput({
type: "image",
data: base64Data,
mimeType: image.mimeType,
});
if (!imageData) {
this.ctx.showStatus(`Unsupported clipboard image format: ${image.mimeType}`);
return false;
}
if (settings.get("images.autoResize")) {
try {
const resized = await resizeImage({
type: "image",
data: imageData.data,
mimeType: imageData.mimeType,
});
imageData = { type: "image", data: resized.data, mimeType: resized.mimeType };
} catch {
}
}
this.ctx.pendingImages.push({
type: "image",
data: imageData.data,
mimeType: imageData.mimeType,
});
const imageNum = this.ctx.pendingImages.length;
const placeholder = `[Image #${imageNum}]`;
this.ctx.editor.insertText(`${placeholder} `);
this.ctx.ui.requestRender();
return true;
}
this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
return false;
} catch {
this.ctx.showStatus("Failed to read clipboard");
return false;
}
}
createAutocompleteProvider(commands: SlashCommand[], basePath: string): AutocompleteProvider {
return createPromptActionAutocompleteProvider({
commands,
basePath,
keybindings: this.ctx.keybindings,
copyCurrentLine: () => this.handleCopyCurrentLine(),
copyPrompt: () => this.handleCopyPrompt(),
undo: prefix => this.ctx.editor.undoPastTransientText(prefix),
moveCursorToMessageEnd: () => this.ctx.editor.moveToMessageEnd(),
moveCursorToMessageStart: () => this.ctx.editor.moveToMessageStart(),
moveCursorToLineStart: () => this.ctx.editor.moveToLineStart(),
moveCursorToLineEnd: () => this.ctx.editor.moveToLineEnd(),
});
}
handleCopyCurrentLine(): void {
const { line } = this.ctx.editor.getCursor();
const text = this.ctx.editor.getLines()[line] || "";
if (!text) {
this.ctx.showStatus("Nothing to copy");
return;
}
try {
copyToClipboard(text);
const sanitized = sanitizeText(text);
const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized;
this.ctx.showStatus(`Copied line: ${preview}`);
} catch {
this.ctx.showWarning("Failed to copy to clipboard");
}
}
handleCopyPrompt(): void {
const text = this.ctx.editor.getText();
if (!text) {
this.ctx.showStatus("Nothing to copy");
return;
}
try {
copyToClipboard(text);
const sanitized = sanitizeText(text);
const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized;
this.ctx.showStatus(`Copied: ${preview}`);
} catch {
this.ctx.showWarning("Failed to copy to clipboard");
}
}
cycleThinkingLevel(): void {
const newLevel = this.ctx.session.cycleThinkingLevel();
if (newLevel === undefined) {
this.ctx.showStatus("Current model does not support thinking");
} else {
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
}
}
async cycleRoleModel(direction: "forward" | "backward" = "forward"): Promise<void> {
try {
const cycleOrder = settings.get("cycleOrder");
const result = await this.ctx.session.cycleRoleModels(cycleOrder, direction);
if (!result) {
this.ctx.showStatus("Only one role model available");
return;
}
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
const track = renderSegmentTrack(
cycleOrder.map(role => ({ label: role, color: getRoleInfo(role, settings).color })),
cycleOrder.indexOf(result.role),
);
this.ctx.showStatus(track, { dim: false });
} catch (error) {
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
}
toggleToolOutputExpansion(): void {
this.setToolsExpanded(!this.ctx.toolOutputExpanded);
}
setToolsExpanded(expanded: boolean): void {
this.ctx.toolOutputExpanded = expanded;
for (const child of this.ctx.chatContainer.children) {
if (isExpandable(child)) {
child.setExpanded(expanded);
}
}
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
}
toggleThinkingBlockVisibility(): void {
this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock;
settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock);
this.ctx.session.agent.hideThinkingSummary = this.ctx.hideThinkingBlock;
this.ctx.chatContainer.clear();
this.ctx.rebuildChatFromMessages();
if (this.ctx.streamingComponent && this.ctx.streamingMessage) {
this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.hideThinkingBlock);
this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
}
this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`);
}
#getEditorTerminalPath(): string | null {
if (process.platform === "win32") {
return null;
}
return "/dev/tty";
}
async #openEditorTerminalHandle(): Promise<fs.FileHandle | null> {
const terminalPath = this.#getEditorTerminalPath();
if (!terminalPath) {
return null;
}
try {
return await fs.open(terminalPath, "r+");
} catch {
return null;
}
}
async openExternalEditor(): Promise<void> {
const editorCmd = getEditorCommand();
if (!editorCmd) {
this.ctx.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
return;
}
const currentText = this.ctx.editor.getExpandedText?.() ?? this.ctx.editor.getText();
let ttyHandle: fs.FileHandle | null = null;
try {
ttyHandle = await this.#openEditorTerminalHandle();
this.ctx.ui.stop();
const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle
? [ttyHandle.fd, ttyHandle.fd, ttyHandle.fd]
: ["inherit", "inherit", "inherit"];
const result = await openInEditor(editorCmd, currentText, { extension: ".omp.md", stdio });
if (result !== null) {
this.ctx.editor.setText(result);
}
} catch (error) {
this.ctx.showWarning(
`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
if (ttyHandle) {
await ttyHandle.close();
}
this.ctx.ui.start();
this.ctx.ui.requestRender();
}
}
registerExtensionShortcuts(): void {
const runner = this.ctx.session.extensionRunner;
if (!runner) return;
const shortcuts = runner.getShortcuts();
for (const [keyId, shortcut] of shortcuts) {
this.ctx.editor.setCustomKeyHandler(keyId, () => {
const ctx = runner.createCommandContext();
try {
shortcut.handler(ctx);
} catch (err) {
runner.emitError({
extensionPath: shortcut.extensionPath,
event: "shortcut",
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
}
});
}
}
}