5503560d68
- Added interactive `/debug` command with menu-driven interface for bug report triage and system diagnostics. - Implemented CPU profiling workflow allowing users to capture performance profiles during issue reproduction. - Added heap snapshot generation for memory issue analysis and debugging. - Implemented debug report bundle creation with session data, logs, system info, and optional profiling artifacts. - Added system information collection and display including OS, CPU, memory, and version details. - Implemented artifact cache management with size statistics and cleanup functionality.
772 lines
24 KiB
TypeScript
772 lines
24 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
import { nanoid } from "nanoid";
|
|
import { theme } from "../../modes/theme/theme";
|
|
import type { InteractiveModeContext } from "../../modes/types";
|
|
import type { AgentSessionEvent } from "../../session/agent-session";
|
|
import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
|
|
import { readImageFromClipboard } from "../../utils/clipboard";
|
|
import { resizeImage } from "../../utils/image-resize";
|
|
import { generateSessionTitle, setTerminalTitle } 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";
|
|
}
|
|
|
|
export class InputController {
|
|
constructor(private ctx: InteractiveModeContext) {}
|
|
|
|
setupKeyHandlers(): void {
|
|
this.ctx.editor.onEscape = () => {
|
|
if (this.ctx.loadingAnimation) {
|
|
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.isPythonRunning) {
|
|
this.ctx.session.abortPython();
|
|
} else if (this.ctx.isPythonMode) {
|
|
this.ctx.editor.setText("");
|
|
this.ctx.isPythonMode = false;
|
|
this.ctx.updateEditorBorderColor();
|
|
} else if (!this.ctx.editor.getText().trim()) {
|
|
// Double-escape with empty editor triggers /tree or /branch based on setting
|
|
const now = Date.now();
|
|
if (now - this.ctx.lastEscapeTime < 500) {
|
|
if (this.ctx.settingsManager.getDoubleEscapeAction() === "tree") {
|
|
this.ctx.showTreeSelector();
|
|
} else {
|
|
this.ctx.showUserMessageSelector();
|
|
}
|
|
this.ctx.lastEscapeTime = 0;
|
|
} else {
|
|
this.ctx.lastEscapeTime = now;
|
|
}
|
|
}
|
|
};
|
|
|
|
this.ctx.editor.onCtrlC = () => this.handleCtrlC();
|
|
this.ctx.editor.onCtrlD = () => this.handleCtrlD();
|
|
this.ctx.editor.onCtrlZ = () => this.handleCtrlZ();
|
|
this.ctx.editor.onShiftTab = () => this.cycleThinkingLevel();
|
|
this.ctx.editor.onCtrlP = () => this.cycleRoleModel();
|
|
this.ctx.editor.onShiftCtrlP = () => this.cycleRoleModel({ temporary: true });
|
|
this.ctx.editor.onAltP = () => this.ctx.showModelSelector({ temporaryOnly: true });
|
|
|
|
// Global debug handler on TUI (works regardless of focus)
|
|
this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
|
|
this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector();
|
|
this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch();
|
|
this.ctx.editor.onCtrlT = () => this.ctx.toggleTodoExpansion();
|
|
this.ctx.editor.onCtrlG = () => void this.openExternalEditor();
|
|
this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
|
|
this.ctx.editor.onCtrlV = () => this.handleImagePaste();
|
|
|
|
// Wire up extension shortcuts
|
|
this.registerExtensionShortcuts();
|
|
|
|
const expandToolsKeys = this.ctx.keybindings.getKeys("expandTools");
|
|
this.ctx.editor.onCtrlO = expandToolsKeys.includes("ctrl+o") ? () => this.toggleToolOutputExpansion() : undefined;
|
|
for (const key of expandToolsKeys) {
|
|
if (key === "ctrl+o") continue;
|
|
this.ctx.editor.setCustomKeyHandler(key, () => this.toggleToolOutputExpansion());
|
|
}
|
|
|
|
const dequeueKeys = this.ctx.keybindings.getKeys("dequeue");
|
|
this.ctx.editor.onAltUp = dequeueKeys.includes("alt+up") ? () => this.handleDequeue() : undefined;
|
|
for (const key of dequeueKeys) {
|
|
if (key === "alt+up") continue;
|
|
this.ctx.editor.setCustomKeyHandler(key, () => this.handleDequeue());
|
|
}
|
|
|
|
const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode");
|
|
for (const key of planModeKeys) {
|
|
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
|
|
}
|
|
|
|
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();
|
|
}
|
|
};
|
|
|
|
this.ctx.editor.onAltEnter = async (text: string) => {
|
|
const trimmedText = text.trim();
|
|
|
|
// Queue follow-up messages while compaction is running
|
|
if (this.ctx.session.isCompacting) {
|
|
if (!trimmedText) {
|
|
this.ctx.editor.handleInput("\n");
|
|
return;
|
|
}
|
|
this.ctx.queueCompactionMessage(trimmedText, "followUp");
|
|
return;
|
|
}
|
|
|
|
// Alt+Enter queues a follow-up message while streaming
|
|
if (this.ctx.session.isStreaming) {
|
|
if (!trimmedText) {
|
|
this.ctx.editor.handleInput("\n");
|
|
return;
|
|
}
|
|
this.ctx.editor.addToHistory(trimmedText);
|
|
this.ctx.editor.setText("");
|
|
await this.ctx.session.prompt(trimmedText, { streamingBehavior: "followUp" });
|
|
this.ctx.updatePendingMessagesDisplay();
|
|
this.ctx.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
// Default behavior: insert a new line
|
|
this.ctx.editor.handleInput("\n");
|
|
};
|
|
}
|
|
|
|
setupEditorSubmitHandler(): void {
|
|
this.ctx.editor.onSubmit = async (text: string) => {
|
|
text = text.trim();
|
|
|
|
// Empty submit while streaming with queued messages: flush queues immediately
|
|
if (!text && this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount > 0) {
|
|
// Abort current stream and let queued messages be processed
|
|
await this.ctx.session.abort();
|
|
return;
|
|
}
|
|
|
|
if (!text) return;
|
|
|
|
// Continue shortcuts: "." or "c" sends empty message (agent continues, no visible message)
|
|
if (text === "." || text === "c") {
|
|
if (this.ctx.onInputCallback) {
|
|
this.ctx.editor.setText("");
|
|
this.ctx.pendingImages = [];
|
|
this.ctx.onInputCallback({ text: "" });
|
|
}
|
|
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;
|
|
|
|
// Handle slash commands
|
|
if (text === "/settings") {
|
|
this.ctx.showSettingsSelector();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/plan") {
|
|
await this.ctx.handlePlanModeCommand();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/model" || text === "/models") {
|
|
this.ctx.showModelSelector();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text.startsWith("/export")) {
|
|
await this.ctx.handleExportCommand(text);
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/dump") {
|
|
await this.ctx.handleDumpCommand();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/share") {
|
|
await this.ctx.handleShareCommand();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/copy") {
|
|
await this.ctx.handleCopyCommand();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/session") {
|
|
await this.ctx.handleSessionCommand();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/usage") {
|
|
await this.ctx.handleUsageCommand();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/changelog") {
|
|
await this.ctx.handleChangelogCommand();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/hotkeys") {
|
|
this.ctx.handleHotkeysCommand();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/extensions" || text === "/status") {
|
|
this.ctx.showExtensionsDashboard();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/branch") {
|
|
if (this.ctx.settingsManager.getDoubleEscapeAction() === "tree") {
|
|
this.ctx.showTreeSelector();
|
|
} else {
|
|
this.ctx.showUserMessageSelector();
|
|
}
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/tree") {
|
|
this.ctx.showTreeSelector();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/login") {
|
|
this.ctx.showOAuthSelector("login");
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/logout") {
|
|
this.ctx.showOAuthSelector("logout");
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/new") {
|
|
this.ctx.editor.setText("");
|
|
await this.ctx.handleClearCommand();
|
|
return;
|
|
}
|
|
if (text === "/fork") {
|
|
this.ctx.editor.setText("");
|
|
await this.ctx.handleForkCommand();
|
|
return;
|
|
}
|
|
if (text === "/compact" || text.startsWith("/compact ")) {
|
|
const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
|
|
this.ctx.editor.setText("");
|
|
await this.ctx.handleCompactCommand(customInstructions);
|
|
return;
|
|
}
|
|
if (text === "/handoff" || text.startsWith("/handoff ")) {
|
|
const customInstructions = text.startsWith("/handoff ") ? text.slice(9).trim() : undefined;
|
|
this.ctx.editor.setText("");
|
|
await this.ctx.handleHandoffCommand(customInstructions);
|
|
return;
|
|
}
|
|
if (text === "/background" || text === "/bg") {
|
|
this.ctx.editor.setText("");
|
|
this.handleBackgroundCommand();
|
|
return;
|
|
}
|
|
if (text === "/debug") {
|
|
this.ctx.showDebugSelector();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/arminsayshi") {
|
|
this.ctx.handleArminSaysHi();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/resume") {
|
|
this.ctx.showSessionSelector();
|
|
this.ctx.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/exit") {
|
|
this.ctx.editor.setText("");
|
|
void this.ctx.shutdown();
|
|
return;
|
|
}
|
|
|
|
// Handle skill commands (/skill:name [args])
|
|
if (text.startsWith("/skill:")) {
|
|
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) {
|
|
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,
|
|
};
|
|
await this.ctx.session.promptCustomMessage(
|
|
{
|
|
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
|
content: message,
|
|
display: true,
|
|
details,
|
|
},
|
|
{ streamingBehavior: "followUp" },
|
|
);
|
|
} catch (err) {
|
|
this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Handle bash command (! for normal, !! for excluded from context)
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Handle python command ($ for normal, $$ for excluded from context)
|
|
if (text.startsWith("$")) {
|
|
const isExcluded = text.startsWith("$$");
|
|
const code = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
|
|
if (code) {
|
|
if (this.ctx.session.isPythonRunning) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Queue input during compaction
|
|
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 streaming, use prompt() with steer behavior
|
|
// This handles extension commands (execute immediately), prompt template expansion, and queueing
|
|
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.session.prompt(text, { streamingBehavior: "steer", images });
|
|
this.ctx.updatePendingMessagesDisplay();
|
|
this.ctx.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
// Normal message submission
|
|
// First, move any pending bash components to chat
|
|
this.ctx.flushPendingBashComponents();
|
|
|
|
// Generate session title on first message
|
|
const hasUserMessages = this.ctx.agent.state.messages.some((m: AgentMessage) => m.role === "user");
|
|
if (!hasUserMessages && !this.ctx.sessionManager.getSessionTitle() && !process.env.OMP_NO_TITLE) {
|
|
const registry = this.ctx.session.modelRegistry;
|
|
const smolModel = this.ctx.settingsManager.getModelRole("smol");
|
|
generateSessionTitle(text, registry, smolModel, this.ctx.session.sessionId)
|
|
.then(async title => {
|
|
if (title) {
|
|
await this.ctx.sessionManager.setSessionTitle(title);
|
|
setTerminalTitle(`π: ${title}`);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
if (this.ctx.onInputCallback) {
|
|
// Include any pending images from clipboard paste
|
|
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
|
|
this.ctx.pendingImages = [];
|
|
this.ctx.onInputCallback({ text, images });
|
|
}
|
|
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 {
|
|
// Only called when editor is empty (enforced by CustomEditor)
|
|
void this.ctx.shutdown();
|
|
}
|
|
|
|
handleCtrlZ(): void {
|
|
// Set up handler to restore TUI when resumed
|
|
process.once("SIGCONT", () => {
|
|
this.ctx.ui.start();
|
|
this.ctx.ui.requestRender(true);
|
|
});
|
|
|
|
// Stop the TUI (restore terminal to normal mode)
|
|
this.ctx.ui.stop();
|
|
|
|
// Send SIGTSTP to process group (pid=0 means all processes in group)
|
|
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`);
|
|
}
|
|
}
|
|
|
|
restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
|
|
const { steering, followUp } = this.ctx.session.clearQueue();
|
|
const allQueued = [...steering, ...followUp];
|
|
if (allQueued.length === 0) {
|
|
this.ctx.updatePendingMessagesDisplay();
|
|
if (options?.abort) {
|
|
this.ctx.agent.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.agent.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;
|
|
}
|
|
|
|
this.ctx.isBackgrounded = true;
|
|
const backgroundUiContext = this.ctx.createBackgroundUiContext();
|
|
|
|
// Background mode disables interactive UI so tools like ask fail fast.
|
|
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);
|
|
});
|
|
|
|
// Backgrounding keeps the current process to preserve in-flight agent state.
|
|
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) {
|
|
let imageData = image;
|
|
if (this.ctx.settingsManager.getImageAutoResize()) {
|
|
try {
|
|
const resized = await resizeImage({
|
|
type: "image",
|
|
data: image.data,
|
|
mimeType: image.mimeType,
|
|
});
|
|
imageData = { data: resized.data, mimeType: resized.mimeType };
|
|
} catch {
|
|
imageData = image;
|
|
}
|
|
}
|
|
|
|
this.ctx.pendingImages.push({
|
|
type: "image",
|
|
data: imageData.data,
|
|
mimeType: imageData.mimeType,
|
|
});
|
|
// Insert placeholder at cursor like Claude does
|
|
const imageNum = this.ctx.pendingImages.length;
|
|
const placeholder = `[Image #${imageNum}]`;
|
|
this.ctx.editor.insertText(`${placeholder} `);
|
|
this.ctx.ui.requestRender();
|
|
return true;
|
|
}
|
|
// No image in clipboard - show hint
|
|
this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
|
|
return false;
|
|
} catch {
|
|
this.ctx.showStatus("Failed to read clipboard");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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(options?: { temporary?: boolean }): Promise<void> {
|
|
try {
|
|
const roleOrder = ["slow", "default", "smol"];
|
|
const result = await this.ctx.session.cycleRoleModels(roleOrder, options);
|
|
if (!result) {
|
|
this.ctx.showStatus("Only one role model available");
|
|
return;
|
|
}
|
|
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.updateEditorBorderColor();
|
|
const roleLabel = result.role === "default" ? "default" : result.role;
|
|
const roleLabelStyled = theme.bold(theme.fg("accent", roleLabel));
|
|
const thinkingStr =
|
|
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
|
|
const tempLabel = options?.temporary ? " (temporary)" : "";
|
|
const cycleSeparator = theme.fg("dim", " > ");
|
|
const cycleLabel = roleOrder
|
|
.map(role => {
|
|
if (role === result.role) {
|
|
return theme.bold(theme.fg("accent", role));
|
|
}
|
|
return theme.fg("muted", role);
|
|
})
|
|
.join(cycleSeparator);
|
|
const orderLabel = ` (cycle: ${cycleLabel})`;
|
|
this.ctx.showStatus(
|
|
`Switched to ${roleLabelStyled}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}${orderLabel}`,
|
|
{ dim: false },
|
|
);
|
|
} catch (error) {
|
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
toggleToolOutputExpansion(): void {
|
|
this.ctx.toolOutputExpanded = !this.ctx.toolOutputExpanded;
|
|
for (const child of this.ctx.chatContainer.children) {
|
|
if (isExpandable(child)) {
|
|
child.setExpanded(this.ctx.toolOutputExpanded);
|
|
}
|
|
}
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
toggleThinkingBlockVisibility(): void {
|
|
this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock;
|
|
this.ctx.settingsManager.setHideThinkingBlock(this.ctx.hideThinkingBlock);
|
|
|
|
// Rebuild chat from session messages
|
|
this.ctx.chatContainer.clear();
|
|
this.ctx.rebuildChatFromMessages();
|
|
|
|
// If streaming, re-add the streaming component with updated visibility and re-render
|
|
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"}`);
|
|
}
|
|
|
|
private getEditorTerminalPath(): string | null {
|
|
if (process.platform === "win32") {
|
|
return null;
|
|
}
|
|
return "/dev/tty";
|
|
}
|
|
|
|
private 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> {
|
|
// Determine editor (respect $VISUAL, then $EDITOR)
|
|
const editorCmd = process.env.VISUAL || process.env.EDITOR;
|
|
if (!editorCmd) {
|
|
this.ctx.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
|
|
return;
|
|
}
|
|
|
|
const currentText = this.ctx.editor.getText();
|
|
const tmpFile = path.join(os.tmpdir(), `omp-editor-${nanoid()}.omp.md`);
|
|
|
|
let ttyHandle: fs.FileHandle | null = null;
|
|
try {
|
|
// Write current content to temp file
|
|
await Bun.write(tmpFile, currentText);
|
|
|
|
// Stop TUI to release terminal
|
|
ttyHandle = await this.openEditorTerminalHandle();
|
|
this.ctx.ui.stop();
|
|
|
|
// Split by space to support editor arguments (e.g., "code --wait")
|
|
const [editor, ...editorArgs] = editorCmd.split(" ");
|
|
|
|
const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle
|
|
? [ttyHandle.fd, ttyHandle.fd, ttyHandle.fd]
|
|
: ["inherit", "inherit", "inherit"];
|
|
|
|
const child = spawn(editor, [...editorArgs, tmpFile], { stdio });
|
|
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0)));
|
|
child.once("error", error => reject(error));
|
|
});
|
|
|
|
// On successful exit (exitCode 0), replace editor content
|
|
if (exitCode === 0) {
|
|
const newContent = (await Bun.file(tmpFile).text()).replace(/\n$/, "");
|
|
this.ctx.editor.setText(newContent);
|
|
}
|
|
// On non-zero exit, keep original text (no action needed)
|
|
} catch (error) {
|
|
this.ctx.showWarning(
|
|
`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
} finally {
|
|
// Clean up temp file
|
|
try {
|
|
await fs.rm(tmpFile, { force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
|
|
if (ttyHandle) {
|
|
await ttyHandle.close();
|
|
}
|
|
|
|
// Restart TUI
|
|
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,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|