import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { getEnvApiKey, getProviderDetails, type ProviderDetails, type ToolCall, type UsageLimit, type UsageReport, } from "@f5-sales-demo/pi-ai"; import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@f5-sales-demo/pi-tui"; import { formatDuration, Snowflake, setProjectDir, setShellPwd, t } from "@f5-sales-demo/pi-utils"; import { $ } from "bun"; import { reset as resetCapabilities } from "../../capability"; import { clearXcshPluginRootsCache } from "../../discovery/helpers"; import { loadCustomShare } from "../../export/custom-share"; import type { CompactOptions } from "../../extensibility/extensions/types"; import { getGatewayStatus } from "../../ipy/gateway-coordinator"; import { buildMemoryToolDeveloperInstructions, clearMemoryData, enqueueMemoryConsolidation } from "../../memories"; import { BashExecutionComponent } from "../../modes/components/bash-execution"; import { BorderedLoader } from "../../modes/components/bordered-loader"; import { DynamicBorder } from "../../modes/components/dynamic-border"; import { createToolGutter } from "../../modes/components/gutter-block"; import { controlMediaPlayback, type MediaPlaybackAction } from "../../modes/components/media-message"; import { PythonExecutionComponent } from "../../modes/components/python-execution"; import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown"; import { buildToolsMarkdown } from "../../modes/utils/tools-markdown"; import type { AsyncJobSnapshotItem } from "../../session/agent-session"; import type { AuthStorage } from "../../session/auth-storage"; import { outputMeta } from "../../tools/output-meta"; import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils"; import { replaceTabs } from "../../tools/render-utils"; import { getChangelogPath, parseChangelog } from "../../utils/changelog"; import { copyToClipboard } from "../../utils/clipboard"; import { openPath } from "../../utils/open"; import { setSessionTerminalTitle } from "../../utils/title-generator"; function showMarkdownPanel(ctx: InteractiveModeContext, title: string, markdown: string): void { ctx.chatContainer.addChild(new Spacer(1)); ctx.chatContainer.addChild(new DynamicBorder()); ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("contentAccent", title)), 1, 0)); ctx.chatContainer.addChild(new Spacer(1)); ctx.chatContainer.addChild(new Markdown(markdown.trim(), 1, 1, getMarkdownTheme())); ctx.chatContainer.addChild(new DynamicBorder()); ctx.ui.requestRender(); } export class CommandController { constructor(private readonly ctx: InteractiveModeContext) {} openInBrowser(urlOrPath: string): void { openPath(urlOrPath); } async handleExportCommand(text: string): Promise { const parts = text.split(/\s+/); const arg = parts.length > 1 ? parts[1] : undefined; if (arg === "--copy" || arg === "clipboard" || arg === "copy") { this.ctx.showWarning(t("controller.export.warnings.useDump")); return; } try { const filePath = await this.ctx.session.exportToHtml(arg); this.ctx.showStatus(t("controller.export.status.exported", { path: filePath })); this.openInBrowser(filePath); } catch (error: unknown) { this.ctx.showError( t("controller.export.errors.failed", { message: error instanceof Error ? error.message : "Unknown error" }), ); } } handleDumpCommand() { try { const formatted = this.ctx.session.formatSessionAsText(); if (!formatted) { this.ctx.showError(t("controller.dump.errors.noMessages")); return; } copyToClipboard(formatted); this.ctx.showStatus(t("controller.dump.status.copied")); } catch (error: unknown) { this.ctx.showError( t("controller.dump.errors.failed", { message: error instanceof Error ? error.message : "Unknown error" }), ); } } async handleDebugTranscriptCommand(): Promise { try { const width = Math.max(1, this.ctx.ui.terminal.columns); const renderedLines = this.ctx.chatContainer.render(width).map(line => replaceTabs(Bun.stripANSI(line))); const rendered = renderedLines.join("\n").trimEnd(); if (!rendered) { this.ctx.showError(t("controller.debug.errors.noMessages")); return; } const tmpPath = path.join(os.tmpdir(), `${Snowflake.next()}-tmp.txt`); await Bun.write(tmpPath, `${rendered}\n`); this.ctx.showStatus(t("controller.debug.status.written", { path: tmpPath })); } catch (error: unknown) { this.ctx.showError( t("controller.debug.errors.failed", { message: error instanceof Error ? error.message : "Unknown error" }), ); } } async handleShareCommand(): Promise { const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`); const cleanupTempFile = async () => { try { await fs.rm(tmpFile, { force: true }); } catch { // Ignore cleanup errors } }; try { await this.ctx.session.exportToHtml(tmpFile); } catch (error: unknown) { this.ctx.showError( t("controller.export.errors.failed", { message: error instanceof Error ? error.message : "Unknown error" }), ); return; } try { const customShare = await loadCustomShare(); if (customShare) { const loader = new BorderedLoader(this.ctx.ui, theme, t("controller.share.sharing")); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(loader); this.ctx.ui.setFocus(loader); this.ctx.ui.requestRender(); const restoreEditor = async () => { loader.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(this.ctx.editor); await cleanupTempFile(); }; try { const result = await customShare.fn(tmpFile); await restoreEditor(); if (typeof result === "string") { this.ctx.showStatus(`Share URL: ${result}`); this.openInBrowser(result); } else if (result) { const parts: string[] = []; if (result.url) parts.push(`Share URL: ${result.url}`); if (result.message) parts.push(result.message); if (parts.length > 0) this.ctx.showStatus(parts.join("\n")); if (result.url) this.openInBrowser(result.url); } else { this.ctx.showStatus(t("controller.share.sessionShared")); } return; } catch (err) { await restoreEditor(); this.ctx.showError( t("controller.share.customFailed", { message: err instanceof Error ? err.message : String(err) }), ); return; } } } catch (err) { await cleanupTempFile(); this.ctx.showError(err instanceof Error ? err.message : String(err)); return; } try { const authResult = await $`gh auth status`.quiet().nothrow(); if (authResult.exitCode !== 0) { await cleanupTempFile(); this.ctx.showError(t("controller.share.ghNotLoggedIn")); return; } } catch { await cleanupTempFile(); this.ctx.showError(t("controller.share.ghNotInstalled")); return; } const loader = new BorderedLoader(this.ctx.ui, theme, t("controller.share.creatingGist")); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(loader); this.ctx.ui.setFocus(loader); this.ctx.ui.requestRender(); const restoreEditor = async () => { loader.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(this.ctx.editor); await cleanupTempFile(); }; loader.onAbort = () => { void restoreEditor(); this.ctx.showStatus(t("controller.share.cancelled")); }; try { const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow(); if (loader.signal.aborted) return; await restoreEditor(); if (result.exitCode !== 0) { const errorMsg = result.stderr.toString("utf-8").trim() || "Unknown error"; this.ctx.showError(t("controller.share.gistFailed", { message: errorMsg })); return; } const gistUrl = result.stdout.toString("utf-8").trim(); const gistId = gistUrl.split("/").pop(); if (!gistId) { this.ctx.showError(t("controller.share.gistParseFailed")); return; } const previewUrl = `https://gistpreview.github.io/?${gistId}`; this.ctx.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); this.openInBrowser(previewUrl); } catch (error: unknown) { if (!loader.signal.aborted) { await restoreEditor(); this.ctx.showError( t("controller.share.gistFailed", { message: error instanceof Error ? error.message : "Unknown error" }), ); } } } handleMediaCommand(text: string): void { const [, action, target = "latest", ...extra] = text.trim().split(/\s+/u); if (!["play", "pause", "stop"].includes(action ?? "") || extra.length > 0) { this.ctx.showError("Usage: /media play|pause|stop "); return; } const result = controlMediaPlayback(action as MediaPlaybackAction, target); if (!result) { this.ctx.showError( target === "latest" ? "No media is available in this transcript." : `Media not found: ${target}`, ); return; } this.ctx.showStatus(`Media ${result.id}: ${result.state}`); } handleCopyCommand(sub?: string) { switch (sub) { case "code": return this.#copyCode(); case "all": return this.#copyAllCode(); case "cmd": return this.#copyLastCommand(); case "last": case undefined: return this.#copyLastMessage(); default: this.ctx.showError(t("controller.copy.errors.unknownSub", { sub: sub! })); } } #copyLastMessage() { const text = this.ctx.session.getLastAssistantText(); if (!text) { this.ctx.showError(t("controller.copy.errors.noMessages")); return; } this.#doCopy(text, t("controller.copy.status.lastMessage")); } #copyCode() { const text = this.ctx.session.getLastAssistantText(); if (!text) { this.ctx.showError(t("controller.copy.errors.noMessages")); return; } const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)]; const lastMatch = matches.at(-1); if (!lastMatch) { this.ctx.showWarning(t("controller.copy.warnings.noCodeBlock")); return; } this.#doCopy(lastMatch[1].replace(/\n$/, ""), t("controller.copy.status.codeBlock")); } #copyAllCode() { const text = this.ctx.session.getLastAssistantText(); if (!text) { this.ctx.showError(t("controller.copy.errors.noMessages")); return; } const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)]; if (matches.length === 0) { this.ctx.showWarning(t("controller.copy.warnings.noCodeBlocks")); return; } const combined = matches.map(m => m[1].replace(/\n$/, "")).join("\n\n"); this.#doCopy(combined, t("controller.copy.status.codeBlocks", { count: matches.length })); } #copyLastCommand() { const messages = this.ctx.session.messages; // Walk backwards to find the last bash/python tool call for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role !== "assistant") continue; const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall"); for (let j = toolCalls.length - 1; j >= 0; j--) { const tc = toolCalls[j]; if (tc.name === "bash" && typeof tc.arguments.command === "string") { this.#doCopy(tc.arguments.command, t("controller.copy.status.bashCommand")); return; } if (tc.name === "python" && typeof tc.arguments.code === "string") { this.#doCopy(tc.arguments.code, t("controller.copy.status.pythonCode")); return; } } } this.ctx.showWarning(t("controller.copy.warnings.noCommand")); } #doCopy(content: string, label: string) { try { copyToClipboard(content); this.ctx.showStatus(label); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } } async handleSessionCommand(): Promise { const stats = this.ctx.session.getSessionStats(); const premiumRequests = "premiumRequests" in stats && typeof stats.premiumRequests === "number" ? stats.premiumRequests : this.ctx.session.sessionManager.getUsageStatistics().premiumRequests; const normalizedPremiumRequests = Math.round((premiumRequests + Number.EPSILON) * 100) / 100; let info = `${theme.bold(t("controller.session.title"))}\n\n`; info += `${theme.fg("dim", t("controller.session.file"))} ${stats.sessionFile ?? t("controller.session.inMemory")}\n`; info += `${theme.fg("dim", t("controller.session.id"))} ${stats.sessionId}\n\n`; info += `\n${theme.bold(t("controller.session.provider"))}\n`; const model = this.ctx.session.model; if (!model) { info += `${theme.fg("dim", t("controller.session.noModel"))}\n`; } else { const authMode = resolveProviderAuthMode(this.ctx.session.modelRegistry.authStorage, model.provider); const openaiWebsocketSetting = this.ctx.settings.get("providers.openaiWebsockets") ?? "auto"; const preferOpenAICodexWebsockets = openaiWebsocketSetting === "on" ? true : openaiWebsocketSetting === "off" ? false : undefined; const providerDetails = getProviderDetails({ model, sessionId: stats.sessionId, authMode, preferWebsockets: preferOpenAICodexWebsockets, providerSessionState: this.ctx.session.providerSessionState, }); info += renderProviderSection(providerDetails, theme); } info += `\n`; info += `${theme.bold(t("controller.session.messages"))}\n`; info += `${theme.fg("dim", t("controller.session.user"))} ${stats.userMessages}\n`; info += `${theme.fg("dim", t("controller.session.assistant"))} ${stats.assistantMessages}\n`; info += `${theme.fg("dim", t("controller.session.toolCalls"))} ${stats.toolCalls}\n`; info += `${theme.fg("dim", t("controller.session.toolResults"))} ${stats.toolResults}\n`; info += `${theme.fg("dim", t("controller.session.total"))} ${stats.totalMessages}\n\n`; info += `${theme.bold(t("controller.session.tokens"))}\n`; info += `${theme.fg("dim", t("controller.session.input"))} ${stats.tokens.input.toLocaleString()}\n`; info += `${theme.fg("dim", t("controller.session.output"))} ${stats.tokens.output.toLocaleString()}\n`; if (stats.tokens.cacheRead > 0) { info += `${theme.fg("dim", t("controller.session.cacheRead"))} ${stats.tokens.cacheRead.toLocaleString()}\n`; } if (stats.tokens.cacheWrite > 0) { info += `${theme.fg("dim", t("controller.session.cacheWrite"))} ${stats.tokens.cacheWrite.toLocaleString()}\n`; } info += `${theme.fg("dim", t("controller.session.total"))} ${stats.tokens.total.toLocaleString()}\n`; if (stats.cost > 0 || normalizedPremiumRequests > 0) { info += `\n${theme.bold(t("controller.session.cost"))}\n`; if (stats.cost > 0) { info += `${theme.fg("dim", t("controller.session.total"))} ${stats.cost.toFixed(4)}\n`; } if (normalizedPremiumRequests > 0) { info += `${theme.fg("dim", t("controller.session.premiumRequests"))} ${normalizedPremiumRequests.toLocaleString()}\n`; } } const gateway = await getGatewayStatus(); info += `\n${theme.bold(t("controller.session.pythonGateway"))}\n`; if (gateway.active) { info += `${theme.fg("dim", t("controller.session.status"))} ${theme.fg("success", t("controller.session.gatewayActive"))}\n`; info += `${theme.fg("dim", t("controller.session.url"))} ${gateway.url}\n`; info += `${theme.fg("dim", t("controller.session.pid"))} ${gateway.pid}\n`; if (gateway.pythonPath) { info += `${theme.fg("dim", t("controller.session.python"))} ${gateway.pythonPath}\n`; } if (gateway.venvPath) { info += `${theme.fg("dim", t("controller.session.venv"))} ${gateway.venvPath}\n`; } if (gateway.uptime !== null) { const uptimeSec = Math.floor(gateway.uptime / 1000); const mins = Math.floor(uptimeSec / 60); const secs = uptimeSec % 60; info += `${theme.fg("dim", t("controller.session.uptime"))} ${mins}m ${secs}s\n`; } } else { info += `${theme.fg("dim", t("controller.session.status"))} ${theme.fg("dim", t("controller.session.gatewayInactive"))}\n`; } if (this.ctx.lspServers && this.ctx.lspServers.length > 0) { info += `\n${theme.bold(t("controller.session.lspServers"))}\n`; for (const server of this.ctx.lspServers) { const statusColor = server.status === "ready" ? "success" : server.status === "connecting" ? "warning" : "error"; const statusText = server.status === "error" && server.error ? `${server.status}: ${server.error}` : server.status; info += `${theme.fg("dim", `${server.name}:`)} ${theme.fg(statusColor, statusText)} ${theme.fg("dim", `(${server.fileTypes.join(", ")})`)}\n`; } } if (this.ctx.mcpManager) { const mcpServers = this.ctx.mcpManager.getConnectedServers(); info += `\n${theme.bold(t("controller.session.mcpServers"))}\n`; if (mcpServers.length === 0) { info += `${theme.fg("dim", t("controller.session.noneConnected"))}\n`; } else { for (const name of mcpServers) { const conn = this.ctx.mcpManager.getConnection(name); const toolCount = conn?.tools?.length ?? 0; info += `${theme.fg("dim", `${name}:`)} ${theme.fg("success", "connected")} ${theme.fg("dim", `(${toolCount} tools)`)}\n`; } } } this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new Text(info, 1, 0)); this.ctx.ui.requestRender(); } async handleJobsCommand(): Promise { const snapshot = this.ctx.session.getAsyncJobSnapshot({ recentLimit: 5 }); if (!snapshot) { this.ctx.showWarning(t("controller.jobs.unavailable")); return; } const now = Date.now(); const lineWidth = Math.max(24, (this.ctx.ui.terminal.columns ?? 100) - 24); let info = `${theme.bold(t("controller.jobs.title"))}\n\n`; info += `${theme.fg("dim", t("controller.jobs.running"))} ${snapshot.running.length}\n`; if (snapshot.running.length === 0 && snapshot.recent.length === 0) { info += `\n${theme.fg("dim", t("controller.jobs.noJobs"))}\n`; this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new Text(info, 1, 0)); this.ctx.ui.requestRender(); return; } if (snapshot.running.length > 0) { info += `\n${theme.bold(t("controller.jobs.runningJobs"))}\n`; for (const job of snapshot.running) { info += `${renderJobLine(job, now)}\n`; info += ` ${theme.fg("dim", truncateJobLabel(job.label, lineWidth))}\n`; } } if (snapshot.recent.length > 0) { info += `\n${theme.bold(t("controller.jobs.recentJobs"))}\n`; for (const job of snapshot.recent) { info += `${renderJobLine(job, now)}\n`; info += ` ${theme.fg("dim", truncateJobLabel(job.label, lineWidth))}\n`; } } this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new Text(info.trimEnd(), 1, 0)); this.ctx.ui.requestRender(); } async handleUsageCommand(reports?: UsageReport[] | null): Promise { let usageReports = reports ?? null; if (!usageReports) { const provider = this.ctx.session as { fetchUsageReports?: () => Promise }; if (!provider.fetchUsageReports) { this.ctx.showWarning(t("controller.usage.notConfigured")); return; } try { usageReports = await provider.fetchUsageReports(); } catch (error) { this.ctx.showError( t("controller.usage.fetchFailed", { message: error instanceof Error ? error.message : String(error) }), ); return; } } if (!usageReports || usageReports.length === 0) { this.ctx.showWarning(t("controller.usage.noData")); return; } const output = renderUsageReports(usageReports, theme, Date.now()); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new Text(output, 1, 0)); this.ctx.ui.requestRender(); } async handleChangelogCommand(showFull = false): Promise { const changelogPath = getChangelogPath(); const allEntries = await parseChangelog(changelogPath); // Default to showing only the latest 3 versions unless --full is specified // allEntries comes from parseChangelog with newest first, reverse to show oldest->newest const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3); const changelogMarkdown = entriesToShow.length > 0 ? [...entriesToShow] .reverse() .map(e => e.content) .join("\n\n") : t("controller.changelog.noEntries"); const title = showFull ? t("controller.changelog.fullTitle") : t("controller.changelog.recentTitle"); const hint = showFull ? "" : `\n\n${theme.fg("dim", "Use")} ${theme.bold("/changelog full")} ${theme.fg("dim", "to view the complete changelog.")}`; this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new DynamicBorder()); this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("contentAccent", title)), 1, 0)); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new Markdown(changelogMarkdown + hint, 1, 1, getMarkdownTheme())); this.ctx.chatContainer.addChild(new DynamicBorder()); this.ctx.ui.requestRender(); } handleHotkeysCommand(): void { const hotkeys = buildHotkeysMarkdown({ keybindings: this.ctx.keybindings }); showMarkdownPanel(this.ctx, t("controller.hotkeys.title"), hotkeys); } handleToolsCommand(): void { const tools = buildToolsMarkdown({ tools: this.ctx.session.agent.state.tools }); showMarkdownPanel(this.ctx, t("controller.tools.title"), tools); } async handleMemoryCommand(text: string): Promise { const argumentText = text.slice(7).trim(); const action = argumentText.split(/\s+/, 1)[0]?.toLowerCase() || "view"; const agentDir = this.ctx.settings.getAgentDir(); if (action === "view") { const payload = await buildMemoryToolDeveloperInstructions(agentDir, this.ctx.settings); if (!payload) { this.ctx.showWarning(t("controller.memory.empty")); return; } this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new DynamicBorder()); this.ctx.chatContainer.addChild( new Text(theme.bold(theme.fg("contentAccent", t("controller.memory.title"))), 1, 0), ); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new Markdown(payload, 1, 1, getMarkdownTheme())); this.ctx.chatContainer.addChild(new DynamicBorder()); this.ctx.ui.requestRender(); return; } if (action === "reset" || action === "clear") { try { await clearMemoryData(agentDir, this.ctx.sessionManager.getCwd()); await this.ctx.session.refreshBaseSystemPrompt(); this.ctx.showStatus(t("controller.memory.cleared")); } catch (error) { this.ctx.showError( t("controller.memory.clearFailed", { message: error instanceof Error ? error.message : String(error) }), ); } return; } if (action === "enqueue" || action === "rebuild") { try { enqueueMemoryConsolidation(agentDir, this.ctx.sessionManager.getCwd()); this.ctx.showStatus(t("controller.memory.enqueued")); } catch (error) { this.ctx.showError( t("controller.memory.enqueueFailed", { message: error instanceof Error ? error.message : String(error), }), ); } return; } this.ctx.showError(t("controller.memory.usage")); } async handleClearCommand(): Promise { if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } this.ctx.statusContainer.clear(); if (this.ctx.session.isCompacting) { this.ctx.session.abortCompaction(); while (this.ctx.session.isCompacting) { await Bun.sleep(10); } } await this.ctx.session.newSession(); this.ctx.resetObserverRegistry(); setSessionTerminalTitle( this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd(), this.ctx.sessionManager.titleSource, ); this.ctx.statusLine.invalidate(); this.ctx.statusLine.setSessionStartTime(Date.now()); this.ctx.updateEditorTopBorder(); this.ctx.updateEditorBorderColor(); this.ctx.ui.requestRender(); this.ctx.chatContainer.clear(); this.ctx.pendingMessagesContainer.clear(); this.ctx.compactionQueuedMessages = []; this.ctx.streamingComponent = undefined; this.ctx.streamingMessage = undefined; this.ctx.pendingTools.clear(); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild( new Text(`${theme.fg("contentAccent", `${theme.status.success} ${t("controller.newSession")}`)}`, 1, 1), ); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(); } async handleForkCommand(): Promise { if (this.ctx.session.isStreaming) { this.ctx.showWarning(t("controller.fork.streaming")); return; } if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } this.ctx.statusContainer.clear(); const success = await this.ctx.session.fork(); if (!success) { this.ctx.showError(t("controller.fork.failed")); return; } this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); const sessionFile = this.ctx.session.sessionFile; const shortPath = sessionFile ? sessionFile.split("/").pop() : "new session"; this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild( new Text( `${theme.fg("contentAccent", `${theme.status.success} ${t("controller.fork.success", { path: shortPath! })}`)}`, 1, 1, ), ); this.ctx.ui.requestRender(); } async handleMoveCommand(targetPath: string): Promise { if (this.ctx.session.isStreaming) { this.ctx.showWarning(t("controller.move.streaming")); return; } const unquoted = stripOuterDoubleQuotes(targetPath); if (!unquoted) { this.ctx.showError(t("controller.move.usage")); return; } const cwd = this.ctx.sessionManager.getCwd(); const resolvedPath = resolveToCwd(unquoted, cwd); try { const stat = await fs.stat(resolvedPath); if (!stat.isDirectory()) { this.ctx.showError(t("controller.move.notDirectory", { path: resolvedPath })); return; } } catch { this.ctx.showError(t("controller.move.notExists", { path: resolvedPath })); return; } try { await this.ctx.sessionManager.flush(); await this.ctx.sessionManager.moveTo(resolvedPath); setProjectDir(resolvedPath); clearXcshPluginRootsCache(); // re-warms preloadedPluginRoots with new project dir (async) resetCapabilities(); await this.ctx.refreshSlashCommandState(resolvedPath); setShellPwd(resolvedPath); this.ctx.statusLine.setCwd(resolvedPath); this.ctx.updateEditorTopBorder(); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild( new Text( `${theme.fg("contentAccent", `${theme.status.success} ${t("controller.move.success", { path: resolvedPath })}`)}`, 1, 1, ), ); this.ctx.ui.requestRender(); } catch (err) { this.ctx.showError(t("controller.move.failed", { message: err instanceof Error ? err.message : String(err) })); } } async handleRenameCommand(title: string): Promise { try { const stored = await this.ctx.sessionManager.setSessionName(title, "user"); if (!stored) { this.ctx.showError(t("controller.rename.empty")); return; } const name = this.ctx.sessionManager.getSessionName()!; setSessionTerminalTitle(name, this.ctx.sessionManager.getCwd(), this.ctx.sessionManager.titleSource); this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); this.ctx.showStatus(t("controller.rename.success", { name })); } catch (err) { this.ctx.showError( t("controller.rename.failed", { message: err instanceof Error ? err.message : String(err) }), ); } } async handleBashCommand(command: string, excludeFromContext = false): Promise { const isDeferred = this.ctx.session.isStreaming; this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext); let bashGutter: ReturnType | undefined; if (isDeferred) { this.ctx.pendingMessagesContainer.addChild(this.ctx.bashComponent); this.ctx.pendingBashComponents.push(this.ctx.bashComponent); } else { bashGutter = createToolGutter(this.ctx.ui, this.ctx.bashComponent); this.ctx.chatContainer.addChild(bashGutter); } this.ctx.ui.requestRender(); let failed = false; try { const result = await this.ctx.session.executeBash( command, chunk => { if (this.ctx.bashComponent) { this.ctx.bashComponent.appendOutput(chunk); } }, { excludeFromContext }, ); // Update CWD if the shell changed directory (e.g. via cd) if (result.newCwd && result.newCwd !== this.ctx.sessionManager.getCwd()) { setShellPwd(result.newCwd); this.ctx.statusLine.setCwd(result.newCwd); this.ctx.ui.requestRender(); } if (this.ctx.bashComponent) { const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get(); this.ctx.bashComponent.setComplete(result.exitCode, result.cancelled, { output: result.output, truncation: meta?.truncation, }); } failed = result.cancelled || (typeof result.exitCode === "number" && result.exitCode !== 0); } catch (error) { failed = true; if (this.ctx.bashComponent) { this.ctx.bashComponent.setError(error instanceof Error ? error : String(error)); } this.ctx.showError( t("controller.bash.failed", { message: error instanceof Error ? error.message : "Unknown error" }), ); } bashGutter?.setDone(failed ? "error" : "success"); this.ctx.bashComponent = undefined; this.ctx.ui.requestRender(); } async handlePythonCommand(code: string, excludeFromContext = false): Promise { const isDeferred = this.ctx.session.isStreaming; this.ctx.pythonComponent = new PythonExecutionComponent(code, this.ctx.ui, excludeFromContext); let pythonGutter: ReturnType | undefined; if (isDeferred) { this.ctx.pendingMessagesContainer.addChild(this.ctx.pythonComponent); this.ctx.pendingPythonComponents.push(this.ctx.pythonComponent); } else { pythonGutter = createToolGutter(this.ctx.ui, this.ctx.pythonComponent); this.ctx.chatContainer.addChild(pythonGutter); } this.ctx.ui.requestRender(); let failed = false; try { const result = await this.ctx.session.executePython( code, chunk => { if (this.ctx.pythonComponent) { this.ctx.pythonComponent.appendOutput(chunk); } }, { excludeFromContext }, ); if (this.ctx.pythonComponent) { const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get(); this.ctx.pythonComponent.setComplete(result.exitCode, result.cancelled, { output: result.output, truncation: meta?.truncation, }); } failed = result.cancelled || (typeof result.exitCode === "number" && result.exitCode !== 0); } catch (error) { failed = true; if (this.ctx.pythonComponent) { this.ctx.pythonComponent.setError(error instanceof Error ? error : String(error)); } this.ctx.showError(`Python execution failed: ${error instanceof Error ? error.message : "Unknown error"}`); } pythonGutter?.setDone(failed ? "error" : "success"); this.ctx.pythonComponent = undefined; this.ctx.ui.requestRender(); } async handleCompactCommand(customInstructions?: string): Promise { const entries = this.ctx.sessionManager.getEntries(); const messageCount = entries.filter(e => e.type === "message").length; if (messageCount < 2) { this.ctx.showWarning("Nothing to compact (no messages yet)"); return; } await this.executeCompaction(customInstructions, false); } async handleSkillCommand(skillPath: string, args: string): Promise { 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")}`; await this.ctx.session.prompt(message); } catch (err) { this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`); } } async executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto = false): Promise { if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } this.ctx.statusContainer.clear(); const originalOnEscape = this.ctx.editor.onEscape; this.ctx.editor.onEscape = () => { this.ctx.session.abortCompaction(); }; this.ctx.chatContainer.addChild(new Spacer(1)); const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)"; const compactingLoader = new Loader( this.ctx.ui, spinner => theme.fg("spinnerAccent", spinner), text => theme.fg("muted", text), label, getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(compactingLoader); this.ctx.ui.requestRender(); try { const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined; const options = customInstructionsOrOptions && typeof customInstructionsOrOptions === "object" ? customInstructionsOrOptions : undefined; await this.ctx.session.compact(instructions, options); this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) { this.ctx.showError("Compaction cancelled"); } else { this.ctx.showError(`Compaction failed: ${message}`); } } finally { compactingLoader.stop(); this.ctx.statusContainer.clear(); this.ctx.editor.onEscape = originalOnEscape; } await this.ctx.flushCompactionQueue({ willRetry: false }); } async handleHandoffCommand(customInstructions?: string): Promise { const entries = this.ctx.sessionManager.getEntries(); const messageCount = entries.filter(e => e.type === "message").length; if (messageCount < 2) { this.ctx.showWarning("Nothing to hand off (no messages yet)"); return; } try { // The agent will visibly generate the handoff document in chat const result = await this.ctx.session.handoff(customInstructions); if (!result) { this.ctx.showError("Handoff cancelled"); return; } // Rebuild chat from the new session (which now contains the handoff document) this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); this.ctx.updateEditorBorderColor(); await this.ctx.reloadTodos(); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild( new Text( `${theme.fg("contentAccent", `${theme.status.success} New session started with handoff context`)}`, 1, 1, ), ); if (result.savedPath) { this.ctx.showStatus(`Handoff document saved to: ${result.savedPath}`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message === "Handoff cancelled" || (error instanceof Error && error.name === "AbortError")) { this.ctx.showError("Handoff cancelled"); } else { this.ctx.showError(`Handoff failed: ${message}`); } } this.ctx.ui.requestRender(); } } const BAR_WIDTH = 24; const COLUMN_WIDTH = BAR_WIDTH + 2; function renderJobLine(job: AsyncJobSnapshotItem, now: number): string { const duration = formatDuration(Math.max(0, now - job.startTime)); const status = formatJobStatus(job.status); return `${theme.fg("dim", job.id)} ${theme.fg("dim", `[${job.type}]`)} ${status} ${theme.fg("dim", `(${duration})`)}`; } function formatJobStatus(status: AsyncJobSnapshotItem["status"]): string { if (status === "running") return theme.fg("warning", "running"); if (status === "completed") return theme.fg("success", "completed"); if (status === "cancelled") return theme.fg("dim", "cancelled"); return theme.fg("error", "failed"); } function truncateJobLabel(label: string, maxWidth: number): string { if (visibleWidth(label) <= maxWidth) return label; if (maxWidth <= 1) return "…"; let out = ""; for (const char of label) { const next = `${out}${char}`; if (visibleWidth(`${next}…`) > maxWidth) break; out = next; } return `${out}…`; } function formatProviderName(provider: string): string { return provider .split(/[-_]/g) .map(part => (part ? part[0].toUpperCase() + part.slice(1) : "")) .join(" "); } function formatNumber(value: number, maxFractionDigits = 1): string { return new Intl.NumberFormat("en-US", { maximumFractionDigits: maxFractionDigits }).format(value); } function formatUsedAccounts(value: number): string { return `${value.toFixed(2)} used`; } function resolveProviderAuthMode(authStorage: AuthStorage, provider: string): string { if (authStorage.hasOAuth(provider)) { return "oauth"; } if (authStorage.has(provider)) { return "api key"; } if (getEnvApiKey(provider)) { return "env api key"; } if (authStorage.hasAuth(provider)) { return "runtime/fallback"; } return "unknown"; } export function renderProviderSection(details: ProviderDetails, uiTheme: Pick): string { const lines: string[] = []; lines.push(`${uiTheme.fg("dim", "Name:")} ${details.provider}`); for (const field of details.fields) { lines.push(`${uiTheme.fg("dim", `${field.label}:`)} ${field.value}`); } return `${lines.join("\n")}\n`; } function resolveFraction(limit: UsageLimit): number | undefined { const amount = limit.amount; if (amount.usedFraction !== undefined) return amount.usedFraction; if (amount.used !== undefined && amount.limit !== undefined && amount.limit > 0) { return amount.used / amount.limit; } if (amount.unit === "percent" && amount.used !== undefined) { return amount.used / 100; } return undefined; } function resolveProviderUsageTotal(reports: UsageReport[]): number { return reports .flatMap(report => report.limits) .map(limit => resolveFraction(limit) ?? 0) .reduce((sum, value) => sum + value, 0); } function formatLimitTitle(limit: UsageLimit): string { const tier = limit.scope.tier; if (tier && !limit.label.toLowerCase().includes(tier.toLowerCase())) { return `${limit.label} (${tier})`; } return limit.label; } function formatWindowSuffix(label: string, windowLabel: string, uiTheme: typeof theme): string { const normalizedLabel = label.toLowerCase(); const normalizedWindow = windowLabel.toLowerCase(); if (normalizedWindow === "quota window") return ""; if (normalizedLabel.includes(normalizedWindow)) return ""; return uiTheme.fg("dim", `(${windowLabel})`); } function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: number): string { const email = (report.metadata?.email as string | undefined) ?? limit.scope.accountId; if (email) return email; const accountId = (report.metadata?.accountId as string | undefined) ?? limit.scope.accountId; if (accountId) return accountId; return `account ${index + 1}`; } function formatUnlimitedReportLabel(report: UsageReport, index: number): string { const email = report.metadata?.email as string | undefined; if (email) return email; const accountId = report.metadata?.accountId as string | undefined; if (accountId) return accountId; return `account ${index + 1}`; } function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined { if (limit.window?.resetsAt !== undefined) { return formatDuration(limit.window.resetsAt - nowMs); } return undefined; } function formatAccountHeader(limit: UsageLimit, report: UsageReport, index: number, nowMs: number): string { const label = formatAccountLabel(limit, report, index); const reset = formatResetShort(limit, nowMs); if (!reset) return label; return `${label} (${reset})`; } function padColumn(text: string, width: number): string { const visible = visibleWidth(text); if (visible >= width) return text; return `${text}${padding(width - visible)}`; } function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] { const hasOk = limits.some(limit => limit.status === "ok"); const hasWarning = limits.some(limit => limit.status === "warning"); const hasExhausted = limits.some(limit => limit.status === "exhausted"); if (!hasOk && !hasWarning && !hasExhausted) return "unknown"; if (hasOk) { return hasWarning || hasExhausted ? "warning" : "ok"; } if (hasWarning) return "warning"; return "exhausted"; } function formatAggregateAmount(limits: UsageLimit[]): string { const fractions = limits .map(limit => resolveFraction(limit)) .filter((value): value is number => value !== undefined); if (fractions.length === limits.length && fractions.length > 0) { const sum = fractions.reduce((total, value) => total + value, 0); const usedPct = Math.max(sum * 100, 0); const remainingPct = Math.max(0, limits.length * 100 - usedPct); const avgRemaining = limits.length > 0 ? remainingPct / limits.length : remainingPct; return `${formatUsedAccounts(sum)} (${formatNumber(avgRemaining)}% left)`; } const amounts = limits .map(limit => limit.amount) .filter(amount => amount.used !== undefined && amount.limit !== undefined && amount.limit > 0); if (amounts.length === limits.length && amounts.length > 0) { const totalUsed = amounts.reduce((sum, amount) => sum + (amount.used ?? 0), 0); const totalLimit = amounts.reduce((sum, amount) => sum + (amount.limit ?? 0), 0); const usedPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0; const remainingPct = Math.max(0, 100 - usedPct); const usedAccounts = totalLimit > 0 ? (usedPct / 100) * limits.length : 0; return `${formatUsedAccounts(usedAccounts)} (${formatNumber(remainingPct)}% left)`; } return `Accounts: ${limits.length}`; } function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null { const absolute = limits .map(limit => limit.window?.resetsAt) .filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs); if (absolute.length === 0) return null; const offsets = absolute.map(value => value - nowMs); const minReset = Math.min(...offsets); const maxReset = Math.max(...offsets); if (maxReset - minReset > 60_000) { return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`; } return `resets in ${formatDuration(minReset)}`; } export function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string { if (status === "exhausted") return uiTheme.fg("error", uiTheme.status.error); if (status === "warning") return uiTheme.fg("warning", uiTheme.status.warning); if (status === "ok") return uiTheme.fg("success", uiTheme.status.success); return ""; } function resolveStatusColor(status: UsageLimit["status"]): "success" | "warning" | "error" | "dim" { if (status === "exhausted") return "error"; if (status === "warning") return "warning"; if (status === "ok") return "success"; return "dim"; } function renderUsageBar(limit: UsageLimit, uiTheme: typeof theme): string { const fraction = resolveFraction(limit); if (fraction === undefined) { return uiTheme.fg("dim", `[${"·".repeat(BAR_WIDTH)}]`); } const clamped = Math.min(Math.max(fraction, 0), 1); const filled = Math.round(clamped * BAR_WIDTH); const filledBar = "█".repeat(filled); const emptyBar = "░".repeat(Math.max(0, BAR_WIDTH - filled)); const color = resolveStatusColor(limit.status); return `${uiTheme.fg("dim", "[")}${uiTheme.fg(color, filledBar)}${uiTheme.fg("dim", emptyBar)}${uiTheme.fg("dim", "]")}`; } function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs: number): string { const lines: string[] = []; const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0)); const headerSuffix = latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : ""; lines.push(uiTheme.bold(uiTheme.fg("contentAccent", `Usage${headerSuffix}`))); const grouped = new Map(); for (const report of reports) { const list = grouped.get(report.provider) ?? []; list.push(report); grouped.set(report.provider, list); } const providerEntries = Array.from(grouped.entries()) .map(([provider, providerReports]) => ({ provider, providerReports, totalUsage: resolveProviderUsageTotal(providerReports), })) .sort((a, b) => { if (a.totalUsage !== b.totalUsage) return a.totalUsage - b.totalUsage; return a.provider.localeCompare(b.provider); }); for (const { provider, providerReports } of providerEntries) { lines.push(""); const providerName = formatProviderName(provider); const limitGroups = new Map< string, { label: string; windowLabel: string; limits: UsageLimit[]; reports: UsageReport[] } >(); for (const report of providerReports) { for (const limit of report.limits) { const windowId = limit.window?.id ?? limit.scope.windowId ?? "default"; const key = `${formatLimitTitle(limit)}|${windowId}`; const windowLabel = limit.window?.label ?? windowId; const entry = limitGroups.get(key) ?? { label: formatLimitTitle(limit), windowLabel, limits: [], reports: [], }; entry.limits.push(limit); entry.reports.push(report); limitGroups.set(key, entry); } } lines.push(uiTheme.bold(uiTheme.fg("contentAccent", providerName))); for (const group of limitGroups.values()) { const entries = group.limits.map((limit, index) => ({ limit, report: group.reports[index], fraction: resolveFraction(limit), index, })); entries.sort((a, b) => { const aFraction = a.fraction ?? -1; const bFraction = b.fraction ?? -1; if (aFraction !== bFraction) return bFraction - aFraction; return a.index - b.index; }); const sortedLimits = entries.map(entry => entry.limit); const sortedReports = entries.map(entry => entry.report); const status = resolveAggregateStatus(sortedLimits); const statusIcon = resolveStatusIcon(status, uiTheme); const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme); lines.push(`${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix}`.trim()); const accountLabels = sortedLimits.map((limit, index) => padColumn(formatAccountHeader(limit, sortedReports[index], index, nowMs), COLUMN_WIDTH), ); lines.push(` ${accountLabels.join(" ")}`.trimEnd()); const bars = sortedLimits.map(limit => padColumn(renderUsageBar(limit, uiTheme), COLUMN_WIDTH)); lines.push(` ${bars.join(" ")} ${formatAggregateAmount(sortedLimits)}`.trimEnd()); const resetText = sortedLimits.length <= 1 ? resolveResetRange(sortedLimits, nowMs) : null; if (resetText) { lines.push(` ${uiTheme.fg("dim", resetText)}`.trimEnd()); } const notes = sortedLimits.flatMap(limit => limit.notes ?? []); if (notes.length > 0) { lines.push(` ${uiTheme.fg("dim", notes.join(" • "))}`.trimEnd()); } } // Render accounts with no rate limits (e.g. business/enterprise plans). const unlimitedReports = providerReports.filter(report => report.limits.length === 0); for (const report of unlimitedReports) { const label = formatUnlimitedReportLabel(report, 0); const tier = report.metadata?.planType as string | undefined; const tierSuffix = tier ? ` ${uiTheme.fg("dim", `(${tier})`)}` : ""; lines.push( `${uiTheme.fg("success", uiTheme.status.success)} ${label}${tierSuffix} ${uiTheme.fg("dim", "-- no limits")}`, ); } // No per-provider footer; global header shows last check. } return lines.join("\n"); }