import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { CompactionCancelledError, type CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; import { getEnvApiKey, getProviderDetails, type Model, type ProviderDetails, resolveUsedFraction, type UsageLimit, type UsageReport, } from "@oh-my-pi/pi-ai"; import { type Component, Loader, Markdown, padding, parseSgrMouse, Spacer, Text, visibleWidth, wrapTextWithAnsi, } from "@oh-my-pi/pi-tui"; import { formatDuration, logger, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils"; import { getActiveProfile, getConfigDirName } from "@oh-my-pi/pi-utils/dirs"; import { classifyTerminalMultiplexer } from "@oh-my-pi/pi-tui/terminal-multiplexer"; import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode"; import { type BashResult, isPersistentShellCdCommand } from "../../exec/bash-executor"; import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share"; import { parseExportArgs } from "../../export/html/args"; import { shareSession } from "../../export/share"; import type { CompactOptions } from "../../extensibility/extensions/types"; import { diffMentalModelContent, type HindsightClientApi, type HindsightSessionState, loadHindsightConfig, reloadMentalModelsForSession, resolveSeedsForScope, seedAlreadyExists, summarizeMentalModel, } from "../../hindsight"; import { memoryStatsUnavailableMessage, resolveMemoryBackend } from "../../memory-backend"; import { BashExecutionComponent, bashPtyViewport } from "@oh-my-pi/pi-tui/chat/bash-execution"; import { appKey } from "@oh-my-pi/pi-tui/chrome/keybinding-hints"; import { BorderedLoader } from "@oh-my-pi/pi-tui/overlays/bordered-loader"; import { EvalExecutionComponent } from "@oh-my-pi/pi-tui/chat/eval-execution"; import { promptCacheDetails } from "@oh-my-pi/pi-tui/status-line/prompt-cache"; import { messageBlock, PresentedBlock } from "@oh-my-pi/pi-tui/chat/transcript-presentation"; import { MoveOverlay, type MoveOverlayResult } from "@oh-my-pi/pi-tui/overlays/move-overlay"; import { moveDirectorySource } from "../move-directory-source"; import { getMarkdownTheme, getSymbolTheme, theme, type Theme } from "@oh-my-pi/pi-tui/theme"; import type { InteractiveModeContext } from "../../modes/types"; import { continuesConversation } from "../utils/conversation-position"; import { ContextViewer } from "@oh-my-pi/pi-tui/overlays/context-viewer"; import type { OverlayHandle } from "@oh-my-pi/pi-tui"; import { ReportPanel } from "@oh-my-pi/pi-tui/overlays/report-panel"; import type { TspText } from "@oh-my-pi/pi-wire"; import { computeSessionContextBreakdown } from "../../session/context-usage-runtime"; import { buildSessionContextView } from "../../session/context-view"; import { buildHotkeysMarkdown, HotkeysSheetComponent } from "@oh-my-pi/pi-tui/hotkeys-markdown"; import { isNativeRendering } from "@oh-my-pi/pi-tui/native/state"; import { buildToolsMarkdown } from "@oh-my-pi/pi-tui/prompt/tools-markdown"; import type { AsyncJobSnapshotItem } from "../../session/agent-session"; import { buildJobTree, jobTreeGuide, jobTreeIndent } from "@oh-my-pi/pi-tui/overlays/job-tree"; import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage"; import type { CompactMode } from "../../session/compact-modes"; import { currentRuntimeIdentity, formatRuntimeIdentity, runtimeIdentityHistory, sameRuntimeIdentity, } from "../../session/runtime-identity"; import type { NewSessionOptions } from "../../session/session-entries"; import { cleanSourceCheckoutIfConfigured, createSessionWorktree, defaultSessionWorktreeBranch, formatSessionWorktreeSummary, type SessionWorktree, } from "../../session/session-worktree"; import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types"; import { codexUsagePlan, formatActiveAccountLabel, formatCodexUsageReportLabel, limitMatchesActiveAccount, } from "../../slash-commands/helpers/active-oauth-account"; import { formatProviderName } from "@oh-my-pi/pi-tui/chrome/format"; import { formatCompactQuota } from "@oh-my-pi/pi-tui/overlays/advisor-config"; import { resolveTernPane } from "../../tools/browser/tern/kind"; import { TernError, type TernErrorKind, TernSocketClient } from "../../tools/browser/tern/wire"; import { outputMeta } from "../../tools/output-meta"; import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils"; import { replaceTabs, shortenEmbeddedPaths, truncateToWidth } from "@oh-my-pi/pi-tui/render/render-utils"; import { getChangelogPath, parseChangelog, parseChangelogView, renderChangelogEntries, selectChangelogEntries, } from "../../utils/changelog"; import { copyToClipboard } from "../../utils/clipboard"; import { formatDumpArchiveReport, type SessionDumpArchive } from "../../session/session-dump-format"; import { openPath } from "../../utils/open"; import { resumeCommand } from "../../utils/resume-command"; import { setSessionTerminalTitle } from "../../utils/title-generator"; import { collapseSharedUsageReports, formatLimitTitle, summarizeUsageResetCredits, } from "@oh-my-pi/pi-tui/overlays/usage-display"; import { formatRemainingOnlyTotal, isUsedOnlyAbsoluteAmount } from "@oh-my-pi/pi-tui/prompt/usage-amounts"; import type { UnavailableUsageAccount } from "@oh-my-pi/pi-tui/overlays/usage-dashboard"; import { cfgTerminalShowImages } from "../settings"; import { cfgProviderAppendOnlyContext } from "../../session/settings"; import { cfgShareRedactSecrets, cfgShareServerUrl, cfgShareStore } from "../../commands/settings"; import { flagConsumesValue, restartArgv } from "../../cli/flag-tables"; import { createDefaultTerminalLaunchRequest, getTerminalLaunchPlacement, launchTerminal, TerminalLaunchError, type TerminalLaunchRequest, type TerminalLaunchResult, } from "../../subprocess/terminal-launch"; import { resolveCliEntryCmd } from "../../subprocess/worker-client"; /** How long `/fork` waits for Tern to open the fork's pane. */ const TERN_FORK_TIMEOUT_MS = 10_000; /** Fork failures after which Tern may still open the pane, so `/fork` must not also fork in place. */ const TERN_FORK_UNCONFIRMED: Partial> = { closed: true, timeout: true, protocol: true }; /** Rows `/session` spends on the builds that wrote the session, including the elision row. */ const MAX_LISTED_BUILDS = 5; function formatCreditValue(value: number): string { return value.toLocaleString(undefined, { maximumFractionDigits: 4 }); } interface ForkTerminalDependencies { classifyTerminalMultiplexer: typeof classifyTerminalMultiplexer; environment: () => NodeJS.ProcessEnv; launchTerminal: (request: TerminalLaunchRequest) => Promise; resolveCliEntryCmd: typeof resolveCliEntryCmd; getActiveProfile: typeof getActiveProfile; argv: () => string[]; } /** * Startup flags the fork child re-supplies from the parent's live state * (`--profile`, `--model`, `--thinking`, `--prompt-cache-key`) or that would * re-point it at a stale scope (`--config`/`--cwd`/`--provider`; the child * gets the effective overlays via `PI_CONFIG_FILES` and the session cwd). */ const FORK_LAUNCH_OVERRIDES: Readonly> = { "--config": true, "--cwd": true, "--model": true, "--profile": true, "--prompt-cache-key": true, "--provider": true, "--thinking": true, }; /** * `env` prefix pinning each scope variable for the fork child. Multiplexer * servers keep stale environment snapshots, so every variable is pinned: * defined values are assigned, absent (or empty) ones are unset with `-u` * (which must precede the assignments) rather than exported as empty strings * that `??`-style consumers would treat as a set, relative path. */ function forkScopeEnvCommand(scope: Readonly>): string[] { const unset: string[] = []; const assignments: string[] = []; for (const [name, value] of Object.entries(scope)) { if (value) assignments.push(`${name}=${value}`); else unset.push("-u", name); } return ["env", ...unset, ...assignments]; } function hasForkApiKeyOverride(argv: string[]): boolean { for (let i = 0; i < argv.length; i++) { const arg = argv[i]!; if (arg === "--api-key" || arg.startsWith("--api-key=")) return true; if (flagConsumesValue(arg, argv[i + 1])) i++; } return false; } export class CommandController { /** The open native report sheet and the component it shows. */ #reportSheet: { handle: OverlayHandle; component: Component } | undefined; #forkTerminalDependencies: ForkTerminalDependencies; /** The watcher serving the queued `/compact`, while one runs. */ #yieldCompactionWatcher: Promise | undefined; constructor( private readonly ctx: InteractiveModeContext, forkTerminalDependencies: Partial = {}, ) { this.#forkTerminalDependencies = { classifyTerminalMultiplexer, environment: () => process.env, launchTerminal, resolveCliEntryCmd, getActiveProfile, argv: () => process.argv.slice(2), ...forkTerminalDependencies, }; } /** Esc: take away the report shown above the editor (text mode); false when none is shown. */ dismissCommandReport(): boolean { return this.#clearReport(); } /** * Drop any report — the one above the editor, or a focused sheet/page — * because the transcript or session under it was reset, and its contents * describe what is gone. */ clearCommandReport(): void { this.#clearReport(); this.#closeReportSheet(); } #clearReport(): boolean { const docked = this.ctx.reportContainer; if (docked.children.length === 0) return false; docked.dispose(); docked.clear(); this.ctx.ui.requestRender(); return true; } #closeReportSheet(): void { const sheet = this.#reportSheet; if (!sheet) return; this.#reportSheet = undefined; sheet.handle.hide(); // Like the docked report, a closed sheet is never shown again. sheet.component.dispose?.(); this.ctx.ui.setFocus(this.ctx.editorContainer.children[0] ?? this.ctx.editor); this.ctx.ui.requestRender(); } /** Open `component` as a focused full-screen page; callers close any previous sheet first. */ #showFullscreenSheet(component: Component): void { const handle = this.ctx.ui.showOverlay(component, { anchor: "bottom-center", width: "100%", maxHeight: "100%", margin: 0, fullscreen: true, }); this.#reportSheet = { handle, component }; this.ctx.ui.setFocus(component); } /** * Show a read-only report, replacing the previous one. In text mode one that * fits the rows above the editor shows there like `/btw`: the editor keeps * focus and its Esc takes the report away. A taller one opens as a * full-screen page on the terminal's alternate screen, scrolled with the * arrow/page keys and the wheel, so the main screen is never touched and * Esc returns to it as it was. Natively it is a focused sheet like * `/usage`, its body scrolled by the terminal once it is long, closed by * Esc or Close. */ showCommandReport(options: { title: string; head?: TspText; body: Component }): void { this.#clearReport(); this.#closeReportSheet(); const terminal = this.ctx.ui.terminal; const inline = this.ctx.commandReportRows() ?? terminal.rows; let fullScreen = false; const report = new ReportPanel({ ...options, closeKey: appKey(this.ctx.keybindings, "app.interrupt"), onClose: () => this.#closeReportSheet(), maxRows: () => (fullScreen ? terminal.rows : this.ctx.commandReportRows()), }); if (isNativeRendering()) { report.holdFocus(); this.#reportSheet = { handle: this.ctx.ui.showOverlay(report, { anchor: "center", width: "90%", maxHeight: "90%" }), component: report, }; this.ctx.ui.setFocus(report); } else if (report.heightAt(terminal.columns) > inline) { fullScreen = true; report.holdFocus(); this.#showFullscreenSheet(report); } else { this.ctx.reportContainer.addChild(report); } this.ctx.ui.requestRender(); } /** A titled markdown report; see {@link showCommandReport}. */ #showMarkdownPanel(title: string, markdown: string): void { this.showCommandReport({ title, body: new Markdown(markdown.trim(), 0, 0, getMarkdownTheme()) }); } async #restoreAfterMoveFailure( previousState: Parameters[0], initialError?: unknown, ): Promise { if (initialError !== undefined) { this.ctx.showError( `Failed to switch workspace: ${initialError instanceof Error ? initialError.message : String(initialError)}`, ); } try { await this.ctx.sessionManager.rollbackMove(previousState); } catch (rollbackError) { const actual = this.ctx.sessionManager.getCwd(); let realigned = false; try { realigned = await this.ctx.applyCwdChange(actual); } catch {} if (!realigned) { this.ctx.showError( `Failed to roll back move: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)} (failed to re-align workspace to ${actual})`, ); await this.ctx.shutdown(); return; } this.ctx.showError( `Failed to roll back move: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)} (workspace remains at ${actual})`, ); return; } let sourceRestored = false; try { sourceRestored = await this.ctx.applyCwdChange(previousState.cwd); } catch {} if (sourceRestored) return; const actual = this.ctx.sessionManager.getCwd(); let realigned = false; try { realigned = await this.ctx.applyCwdChange(actual); } catch {} if (!realigned) { this.ctx.showError(`Failed to restore source workspace after rollback: workspace remains at ${actual}`); await this.ctx.shutdown(); return; } this.ctx.showError(`Failed to restore source workspace after rollback: workspace remains at ${actual}`); } openInBrowser(urlOrPath: string): void { openPath(urlOrPath); } async handleExportCommand(text: string): Promise { try { const { outputPath, useUserThemes } = parseExportArgs(text.slice("/export".length)); if (outputPath === "--copy" || outputPath === "clipboard" || outputPath === "copy") { this.ctx.showWarning("Use /dump to copy the session to clipboard."); return; } // The viewed session: the focused subagent's transcript (plus its own // subagents) from a focused view, otherwise the main session. const filePath = await this.ctx.viewSession.exportToHtml(outputPath, useUserThemes); this.ctx.showStatus(`Session exported to: ${filePath}`); this.openInBrowser(filePath); } catch (error: unknown) { this.ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); } } async handleTraceCommand(): Promise { const sessionFile = this.ctx.session.sessionFile; if (!sessionFile) { this.ctx.showWarning("No session file yet — send a message first."); return; } try { // Lazy: the stats dashboard (server + sqlite) loads on demand only, // matching src/cli/stats-cli.ts, to keep CLI startup fast. const { formatStatsDashboardUrl, startServer } = await import("@oh-my-pi/omp-stats"); const { hostname, port } = await startServer(); const url = `${formatStatsDashboardUrl(hostname, port)}/#/traces?s=${encodeURIComponent(sessionFile)}`; this.openInBrowser(url); this.ctx.showStatus(`Trace: ${url}`); } catch (error: unknown) { this.ctx.showError(`Failed to open trace: ${error instanceof Error ? error.message : "Unknown error"}`); } } async handleDumpCommand(): Promise { try { const formatted = this.ctx.session.formatSessionAsText(); if (!formatted) { this.ctx.showError("No messages to dump yet."); return; } // Build the LLM request JSON sidecar first so its path (and a // raw-context warning) can be appended to the copied transcript. let sidecarPath: string | undefined; let sidecarError: string | undefined; try { sidecarPath = await this.ctx.session.dumpLlmRequestToTmpDir(); } catch (error: unknown) { sidecarError = error instanceof Error ? error.message : "Unknown error"; } const doc = sidecarPath ? `${formatted}\n\n---\nLLM request JSON: ${sidecarPath}\nThis file persists on disk and may contain raw context/secrets — treat accordingly.` : formatted; await copyToClipboard(doc); const statusParts = ["Session copied to clipboard"]; if (sidecarPath) statusParts.push(`LLM request JSON: ${sidecarPath}`); if (sidecarError) statusParts.push(`LLM request JSON unavailable: ${sidecarError}`); this.ctx.showStatus(statusParts.join("\n")); } catch (error: unknown) { this.ctx.showError(`Failed to copy session: ${error instanceof Error ? error.message : "Unknown error"}`); } } handleDumpAllCommand(): Promise { return this.#writeDumpArchive(() => this.ctx.session.dumpSessionArchiveToTmpDir()); } handleDumpAnonCommand(): Promise { return this.#writeDumpArchive(() => this.ctx.session.dumpAnonymizedArchiveToTmpDir()); } async #writeDumpArchive(write: () => Promise): Promise { try { const archive = await write(); if (!archive) { this.ctx.showError("No messages to dump yet."); return; } await copyToClipboard(archive.path); this.ctx.showStatus([...formatDumpArchiveReport(archive), "Archive path copied to clipboard"].join("\n")); } catch (error: unknown) { this.ctx.showError( `Failed to write session dump: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } handleAdvisorDumpCommand(isRaw = false) { try { const advisorHistory = this.ctx.session.formatAdvisorHistoryAsText({ compact: !isRaw }); if (advisorHistory === null) { this.ctx.showError("Advisor is not active for this session."); return; } if (!advisorHistory) { this.ctx.showError("Advisor has no history yet."); return; } copyToClipboard(advisorHistory); this.ctx.showStatus("Advisor history copied to clipboard"); } catch (error: unknown) { this.ctx.showError( `Failed to copy advisor history: ${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("No messages to dump yet."); return; } const tmpPath = path.join(os.tmpdir(), `${Snowflake.next()}-tmp.txt`); await Bun.write(tmpPath, `${rendered}\n`); this.ctx.showStatus(`Debug transcript written to:\n${tmpPath}`); } catch (error: unknown) { this.ctx.showError( `Failed to write debug transcript: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } async handleShareCommand(): Promise { let customShare: LoadedCustomShare | null; try { customShare = await loadCustomShare(); } catch (err) { this.ctx.showError(err instanceof Error ? err.message : String(err)); return; } const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing session..."); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(loader); this.ctx.ui.setFocus(loader); this.ctx.ui.requestRender(); const restoreEditor = () => { loader.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(this.ctx.editor); }; loader.onAbort = () => { restoreEditor(); this.ctx.showStatus("Share cancelled"); }; // Custom share scripts keep their legacy contract: they receive a path // to a standalone HTML export. No fallback to the default flow on error. if (customShare) { const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`); try { await this.ctx.session.exportToHtml(tmpFile); const result = await customShare.fn(tmpFile); if (loader.signal.aborted) return; 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("Session shared"); } } catch (err) { if (!loader.signal.aborted) { restoreEditor(); this.ctx.showError(`Custom share failed: ${err instanceof Error ? err.message : String(err)}`); } } finally { await fs.rm(tmpFile, { force: true }).catch(() => {}); } return; } // Default: encrypted snapshot to a secret gist (preferred) or the share // server; the key rides in the link fragment and never leaves the client. try { const result = await shareSession(this.ctx.session.sessionManager, { serverUrl: cfgShareServerUrl.get(this.ctx.settings), store: cfgShareStore.get(this.ctx.settings), state: this.ctx.session.state, obfuscator: cfgShareRedactSecrets.get(this.ctx.settings) ? this.ctx.session.obfuscator : undefined, }); if (loader.signal.aborted) return; restoreEditor(); const lines = [`Share URL: ${result.url}`]; if (result.gistUrl) lines.push(`Gist: ${result.gistUrl}`); if (result.truncated) lines.push("Note: large content was trimmed to fit the share size limit."); this.ctx.showStatus(lines.join("\n")); this.openInBrowser(result.url); } catch (error: unknown) { if (!loader.signal.aborted) { restoreEditor(); this.ctx.showError(`Failed to share session: ${error instanceof Error ? error.message : "Unknown 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 = ""; info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n`; const builds = runtimeIdentityHistory(this.ctx.session.sessionManager.getEntries()); const running = currentRuntimeIdentity(); // A source checkout's diff hash changes with each edit, so a long-lived session can collect // many builds: keep the first and the latest few. Every row is keyed so the overlay never // mistakes a colourless continuation row for a section heading. const hidden = builds.length > MAX_LISTED_BUILDS ? builds.length - MAX_LISTED_BUILDS + 1 : 0; for (const [index, build] of builds.entries()) { if (hidden > 0 && index > 0 && index <= hidden) { if (index === 1) info += `${theme.fg("dim", `Builds 2–${hidden + 1}:`)} ${hidden} more\n`; continue; } const key = builds.length === 1 ? "Build:" : `Build ${index + 1}:`; info += `${theme.fg("dim", key)} ${replaceTabs(sanitizeText(formatRuntimeIdentity(build)))}\n`; } if (!sameRuntimeIdentity(builds[builds.length - 1], running)) { info += `${theme.fg("dim", "Running:")} ${replaceTabs(sanitizeText(formatRuntimeIdentity(running)))}\n`; } info += `\n${theme.bold("Provider")}\n`; const model = this.ctx.session.model; if (!model) { info += `${theme.fg("dim", "No model selected")}\n`; } else { const authMode = resolveProviderAuthMode(this.ctx.session.modelRegistry.authStorage, model.provider); const credentialSource = this.ctx.session.modelRegistry.authStorage.keys.describe( model.provider, stats.sessionId, ); const providerDetails = getProviderDetails({ model, sessionId: stats.sessionId, authMode, credentialSource, preferWebsockets: this.ctx.session.preferWebsockets, providerSessionState: this.ctx.session.providerSessionState, }); info += renderProviderSection(providerDetails, theme); if (stats.routedModels !== undefined) { const routed = Object.entries(stats.routedModels) .sort(([aId, aCount], [bId, bCount]) => bCount - aCount || aId.localeCompare(bId)) .map( ([id, count]) => `${replaceTabs(sanitizeText(id))}${count > 1 ? theme.fg("dim", ` ×${count}`) : ""}`, ); info += `${theme.fg("dim", "Served:")} ${routed.join(", ")}\n`; } } info += `\n`; info += `${theme.bold("Messages")}\n`; info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`; info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`; info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`; if (stats.editRejections !== undefined) { const kinds = Object.entries(stats.editRejections).sort( ([aKind, aCount], [bKind, bCount]) => bCount - aCount || aKind.localeCompare(bKind), ); const total = kinds.reduce((sum, [, count]) => sum + count, 0); const breakdown = kinds .map(([kind, count]) => `${replaceTabs(sanitizeText(kind.replaceAll("_", " ")))} ${count}`) .join(", "); info += `${theme.fg("dim", "Edit Rejections:")} ${total} ${theme.fg("dim", `(${breakdown})`)}\n`; } info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`; // Append-only context { const setting = cfgProviderAppendOnlyContext.get(this.ctx.settings); const model = this.ctx.session.model; const mode = shouldEnableAppendOnlyContext(setting, model); const activeLabel = mode ? theme.fg("success", "active") : theme.fg("dim", "inactive"); const settingLabel = setting === "auto" ? `${setting} (${model?.provider ?? "?"})` : setting; info += `${theme.fg("dim", "Append-Only:")} ${activeLabel} (setting: ${settingLabel})\n`; } info += `${theme.bold("Tokens")}\n`; info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`; info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`; if (stats.tokens.cacheRead > 0) { info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`; } if (stats.tokens.cacheWrite > 0) { info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`; } info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`; const cacheStatus = this.ctx.session.getPromptCacheStatus(); if (cacheStatus) { info += `\n${theme.bold("Prompt Cache")}\n`; for (const row of promptCacheDetails(cacheStatus, Date.now())) { const value = row.tone === "warning" ? theme.fg("warning", row.value) : row.value; info += `${theme.fg("dim", `${row.label}:`)} ${value}\n`; } } if (stats.cost > 0 || normalizedPremiumRequests > 0 || stats.credits !== undefined) { info += `\n${theme.bold("Cost")}\n`; if (stats.cost > 0) { info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}\n`; } if (normalizedPremiumRequests > 0) { info += `${theme.fg("dim", "Premium Requests:")} ${normalizedPremiumRequests.toLocaleString()}\n`; } if (stats.credits !== undefined) { info += `${theme.fg("dim", "Credits:")} ${formatCreditValue(stats.credits.cost)}\n`; info += `${theme.fg("dim", "Committed Credits:")} ${formatCreditValue(stats.credits.committedCost)}\n`; info += `${theme.fg("dim", "Committed ACU:")} ${formatCreditValue(stats.credits.acuCost)}\n`; } } if (this.ctx.lspServers && this.ctx.lspServers.length > 0) { info += `\n${theme.bold("LSP Servers")}\n`; for (const server of this.ctx.lspServers) { const statusColor = server.status === "ready" ? "success" : server.status === "available" ? "dim" : 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("MCP Servers")}\n`; if (mcpServers.length === 0) { info += `${theme.fg("dim", "None connected")}\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.showSessionInfo(info, this.ctx.session.getContextUsage()); } static readonly #advisorStatusGlyph: Record = { running: "●", paused: "○", no_model: "○", quota_exhausted: "✕", error: "✕", }; static readonly #advisorStatusLabel: Record = { running: "running", paused: "off", no_model: "no model", quota_exhausted: "quota exhausted", error: "error", }; async handleAdvisorStatusCommand(): Promise { const stats = this.ctx.session.getAdvisorStats(); if (!stats.configured) { this.showCommandReport({ title: "Advisor Status", body: new Text("Advisor is disabled.", 0, 0) }); return; } // Render cached quota data without waiting for the network. Quota lines // are optional, and the refresh below serves the next invocation. const usageProvider = this.ctx.session as { cachedUsageReports?: UsageReport[]; fetchUsageReports?: () => Promise; }; const usageReports: UsageReport[] | null = usageProvider.cachedUsageReports ?? null; // Warm the snapshot for the next invocation. The fetch logs its own // failures, and a missing quota line is non-fatal by design. void usageProvider.fetchUsageReports?.().catch(() => {}); // Resolve the active OAuth identity for each advisor's provider so quota // filtering matches the credential actually in use (not sibling accounts). const resolveActiveAdvisorAccount = (provider: string, sessionId?: string): OAuthAccountIdentity | undefined => this.ctx.session.modelRegistry.authStorage.oauth.identity(provider, sessionId ?? this.ctx.session.sessionId); const nowMs = Date.now(); // Roster view: show every configured advisor with its status, even when // none are live (all paused/no-model). The old code returned a generic // message that hid the per-advisor state the user needs to act on. if (stats.advisors.length > 1 || (stats.configured && !stats.active)) { let info = ""; for (const a of stats.advisors) { const glyph = CommandController.#advisorStatusGlyph[a.status] ?? "?"; const label = CommandController.#advisorStatusLabel[a.status] ?? a.status; const color = a.status === "running" ? "success" : a.status === "quota_exhausted" || a.status === "error" ? "error" : "dim"; info += `\n${theme.fg(color, glyph)} ${theme.bold(a.name)} ${theme.fg("dim", `[${label}]`)}\n`; if (a.model) { info += `${theme.fg("dim", "Model:")} ${a.model.provider}/${a.model.id}\n`; } if (a.model && usageReports) { const identity = resolveActiveAdvisorAccount(a.model.provider, a.sessionId); const quota = formatCompactQuota( a.model.provider, collapseSharedUsageReports(usageReports), nowMs, (report, limit) => !identity || limitMatchesActiveAccount(report, limit, identity), ); if (quota) info += `${theme.fg("dim", quota)}\n`; } if (a.status === "running" || a.status === "quota_exhausted") { const ctx = a.contextWindow > 0 ? `${a.contextTokens.toLocaleString()} / ${a.contextWindow.toLocaleString()} (${Math.round((a.contextTokens / a.contextWindow) * 100)}%)` : `${a.contextTokens.toLocaleString()}`; info += `${theme.fg("dim", "Context:")} ${ctx}\n`; info += `${theme.fg("dim", "Messages:")} ${a.messages.total.toLocaleString()}\n`; info += `${theme.fg("dim", "Spend:")} ${a.tokens.input.toLocaleString()} in / ${a.tokens.output.toLocaleString()} out`; if (a.cost > 0) info += `, $${a.cost.toFixed(4)}`; info += "\n"; } } if (stats.active) { info += `\n${theme.bold("Totals")}\n`; info += `${theme.fg("dim", "Tokens:")} ${stats.tokens.total.toLocaleString()}\n`; if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`; } this.showCommandReport({ title: `Advisor Status (${stats.advisors.length} advisors)`, body: new Text(info.trim(), 0, 0), }); return; } // Single active advisor — detailed view. const model = stats.model; let info = ""; if (stats.advisors.length === 1) { const a = stats.advisors[0]; const glyph = CommandController.#advisorStatusGlyph[a.status] ?? "?"; const label = CommandController.#advisorStatusLabel[a.status] ?? a.status; info += `${theme.fg(a.status === "running" ? "success" : "error", glyph)} ${a.name} ${theme.fg("dim", `[${label}]`)}\n\n`; } if (model) { info += `${theme.bold("Provider")}\n`; info += `${theme.fg("dim", "Model:")} ${model.provider}/${model.id}\n`; } if (model && usageReports) { const identity = resolveActiveAdvisorAccount(model.provider, stats.advisors[0]?.sessionId); const quota = formatCompactQuota( model.provider, collapseSharedUsageReports(usageReports), nowMs, (report, limit) => !identity || limitMatchesActiveAccount(report, limit, identity), ); if (quota) { info += `\n${theme.bold("Quota")}\n`; info += `${theme.fg("dim", quota)}\n`; } } info += `\n${theme.bold("Messages")}\n`; info += `${theme.fg("dim", "User:")} ${stats.messages.user.toLocaleString()}\n`; info += `${theme.fg("dim", "Assistant:")} ${stats.messages.assistant.toLocaleString()}\n`; info += `${theme.fg("dim", "Total:")} ${stats.messages.total.toLocaleString()}\n`; info += `\n${theme.bold("Context")}\n`; if (stats.contextWindow > 0) { const percent = Math.round((stats.contextTokens / stats.contextWindow) * 100); info += `${theme.fg("dim", "Tokens:")} ${stats.contextTokens.toLocaleString()} / ${stats.contextWindow.toLocaleString()} (${percent}%)\n`; } else { info += `${theme.fg("dim", "Tokens:")} ${stats.contextTokens.toLocaleString()}\n`; } info += `\n${theme.bold("Spend")}\n`; info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`; info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`; if (stats.tokens.cacheRead > 0) { info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`; } if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`; this.showCommandReport({ title: "Advisor Status", body: new Text(info.trim(), 0, 0) }); } /** * `/jobs`: natively the live jobs sheet the jobs pill opens (inspect and * cancel included); `/jobs full`, and text mode, a report of the running * and recent jobs (see {@link showCommandReport}). Both list the viewed * session's jobs and its subagents' as a tree (see {@link buildJobTree}). */ async handleJobsCommand(options?: { full?: boolean }): Promise { const full = options?.full === true; if (full) this.ctx.viewSession.requestJobCaptions(); const tree = this.ctx.viewSession.getAsyncJobTree(); if (!tree) { this.ctx.showWarning("Async background jobs are unavailable in this session."); return; } if (isNativeRendering() && !full) { this.ctx.showJobsSheet(); return; } const now = Date.now(); const columns = this.ctx.ui.terminal.columns ?? 100; let info = `${theme.fg("dim", "Running:")} ${tree.running.length}\n`; if (tree.running.length === 0 && tree.recent.length === 0) { info += `\n${theme.fg("dim", "No async jobs yet.")}`; this.showCommandReport({ title: "Background Jobs", body: new Text(info, 0, 0) }); return; } // Lines under a row hang off its tree rails. Full mode wraps here so // every line, including heredoc lines and wrap continuations, keeps that // indent inside the report box. info += "\n"; for (const row of buildJobTree([...tree.running, ...tree.recent], tree.agents, tree.rootId)) { const guide = theme.fg("dim", jobTreeGuide(row, theme)); if (row.kind === "agent") { info += `${guide}${theme.bold(row.agent.id)}\n`; continue; } const { job } = row; // A spawned subagent's task job takes the agent's id as its id and label: name it once. const agent = row.agent && row.agent.id !== job.id ? ` ${theme.bold(row.agent.id)}` : ""; info += `${guide}${renderJobLine(job, now)}${agent}\n`; // What the job is for leads; full mode follows it with what the command does and the whole command. const text = full ? (job.command ?? job.label) : (job.intent ?? job.label); if (text === job.id) continue; const indent = jobTreeIndent(row, theme); const prefix = theme.fg("dim", indent); if (!full) { const label = truncateJobLabel(text, Math.max(24, columns - 22 - visibleWidth(indent))); info += `${prefix}${theme.fg("dim", label)}\n`; continue; } for (const line of wrapTextWithAnsi( shortenEmbeddedPaths(replaceTabs(sanitizeText([job.intent, job.caption, text].filter(Boolean).join("\n")))), Math.max(1, columns - 4 - visibleWidth(indent)), )) { info += `${prefix}${theme.fg("dim", line)}\n`; } } this.showCommandReport({ title: "Background Jobs", body: new Text(info.trimEnd(), 0, 0) }); } 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("Usage reporting is not configured for this session."); return; } try { usageReports = await provider.fetchUsageReports(); } catch (error) { this.ctx.showError(`Failed to fetch usage data: ${error instanceof Error ? error.message : String(error)}`); } } this.ctx.showUsageDashboard(usageReports ?? []); } async handleChangelogCommand(args = ""): Promise { const view = parseChangelogView(args); if ("error" in view) { this.ctx.showWarning(view.error); return; } const changelogPath = getChangelogPath(); const allEntries = await parseChangelog(changelogPath); const entriesToShow = selectChangelogEntries(allEntries, view); const changelogMarkdown = entriesToShow.length > 0 ? renderChangelogEntries(entriesToShow).markdown : "No changelog entries found."; const shown = entriesToShow.length; const titleCount = shown > 0 ? shown : view.kind === "last" ? view.count : shown; const title = view.kind === "full" ? "Full Changelog" : view.kind === "last" ? titleCount === 1 ? "Last Release" : `Last ${titleCount} Releases` : "Recent Changes"; const hint = view.kind === "full" ? "" : `\n\n${theme.fg("dim", "Use")} ${theme.bold("/changelog full")} ${theme.fg("dim", "to view the complete changelog.")}`; this.#showMarkdownPanel(title, changelogMarkdown + hint); } handleHotkeysCommand(): void { const bindings = { keybindings: this.ctx.keybindings }; if (isNativeRendering()) { // A native terminal gets a dismissable sheet with keycaps instead of a markdown table. const sheet = new HotkeysSheetComponent(bindings, () => { handle.hide(); this.ctx.ui.setFocus(this.ctx.editorContainer.children[0] ?? this.ctx.editor); this.ctx.ui.requestRender(); }); const handle = this.ctx.ui.showOverlay(sheet, { anchor: "center", width: "90%", maxHeight: "90%" }); this.ctx.ui.setFocus(sheet); this.ctx.ui.requestRender(); return; } this.#showMarkdownPanel("Keyboard Shortcuts", buildHotkeysMarkdown(bindings)); } handleToolsCommand(): void { const tools = buildToolsMarkdown({ tools: this.ctx.session.agent.state.tools, xdevTools: this.ctx.session.getXdevToolEntries(), }); this.#showMarkdownPanel("Available Tools", tools); } handleContextCommand(): void { const session = this.ctx.session; if (!session.model) { this.ctx.showWarning("Context usage is unavailable: no model is selected for this session."); return; } // Validate capacity before tokenizing the whole history into a snapshot. const breakdown = computeSessionContextBreakdown(session, { snapcompactSavings: true }); if (!(breakdown.contextWindow > 0) || !Number.isFinite(breakdown.contextWindow)) { this.ctx.showWarning("Context usage is unavailable: the model's context window is unknown or invalid."); return; } const data = buildSessionContextView(session, breakdown); this.#clearReport(); this.#closeReportSheet(); const viewer = new ContextViewer(data, { onClose: () => this.#closeReportSheet(), requestRender: () => this.ctx.ui.requestRender(), getRows: () => this.ctx.ui.terminal.rows, }); this.#showFullscreenSheet(viewer); this.ctx.ui.requestRender(); } 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(); const backend = await resolveMemoryBackend(this.ctx.settings); if (action === "view") { const payload = await backend.buildDeveloperInstructions( agentDir, this.ctx.sessionManager.getCwd(), this.ctx.settings, this.ctx.session, ); if (!payload) { this.ctx.showWarning("Memory payload is empty (memory backend off, disabled, or no memory available)."); return; } this.#showMarkdownPanel("Memory Injection Payload", payload); return; } if (action === "reset" || action === "clear") { try { await backend.clear(agentDir, this.ctx.sessionManager.getCwd(), this.ctx.session); await this.ctx.session.refreshBaseSystemPrompt(); this.ctx.showStatus("Memory data cleared and system prompt refreshed."); } catch (error) { this.ctx.showError(`Memory clear failed: ${error instanceof Error ? error.message : String(error)}`); } return; } if (action === "enqueue" || action === "rebuild") { try { await backend.enqueue(agentDir, this.ctx.sessionManager.getCwd(), this.ctx.session); this.ctx.showStatus("Memory consolidation enqueued."); } catch (error) { this.ctx.showError(`Memory enqueue failed: ${error instanceof Error ? error.message : String(error)}`); } return; } if (action === "queue") { try { const payload = await backend.queuePreview?.({ agentDir, cwd: this.ctx.sessionManager.getCwd(), session: this.ctx.session, }); if (!payload) { this.ctx.showWarning(`Memory queue is not available for the ${backend.id} backend.`); return; } this.#showMarkdownPanel("Memory Queue", payload); } catch (error) { this.ctx.showError(`Memory queue failed: ${error instanceof Error ? error.message : String(error)}`); } return; } if (action === "sync") { try { await backend.enqueue(agentDir, this.ctx.sessionManager.getCwd(), this.ctx.session); this.ctx.showStatus("Memory consolidation ran."); } catch (error) { this.ctx.showError(`Memory sync failed: ${error instanceof Error ? error.message : String(error)}`); } return; } if (action === "stats" || action === "diagnose") { const hook = action === "stats" ? backend.stats : backend.diagnose; try { const payload = await hook?.(agentDir, this.ctx.sessionManager.getCwd(), this.ctx.session); if (!payload) { this.ctx.showWarning(memoryStatsUnavailableMessage(backend.id, action)); return; } this.#showMarkdownPanel(`Memory ${action === "stats" ? "Stats" : "Diagnostics"}`, payload); } catch (error) { this.ctx.showError(`Memory ${action} failed: ${error instanceof Error ? error.message : String(error)}`); } return; } if (action === "mm") { await this.#handleMentalModelsSubcommand(argumentText); return; } this.ctx.showError("Usage: /memory "); } async #handleMentalModelsSubcommand(argumentText: string): Promise { // Parse: "mm [arg]" const parts = argumentText.split(/\s+/).slice(1); const verb = parts[0]?.toLowerCase() ?? "list"; const arg = parts[1]; const state = this.ctx.session.getHindsightSessionState(); const primary = state && !state.aliasOf ? state : undefined; if (!primary) { this.ctx.showError("Hindsight backend is not active for this session."); return; } if (!primary.config.mentalModelsEnabled) { this.ctx.showError("Mental models are disabled (hindsight.mentalModelsEnabled = false)."); return; } switch (verb) { case "list": await this.#mmList(primary); return; case "show": if (!arg) return this.ctx.showError("Usage: /memory mm show "); await this.#mmShow(primary, arg); return; case "refresh": await this.#mmRefresh(primary, arg); return; case "history": if (!arg) return this.ctx.showError("Usage: /memory mm history "); await this.#mmHistory(primary, arg); return; case "seed": await this.#mmSeed(primary); return; case "reload": await this.#mmReload(primary); return; case "delete": case "remove": if (!arg) return this.ctx.showError("Usage: /memory mm delete "); await this.#mmDelete(primary, arg); return; default: this.ctx.showError("Usage: /memory mm "); } } async #mmList(state: HindsightSessionState): Promise { const client: HindsightClientApi = state.client; try { const response = await client.listMentalModels(state.bankId, { detail: "metadata" }); const items = response.items ?? []; if (items.length === 0) { this.ctx.showStatus(`No mental models on bank ${state.bankId}.`); return; } const lines = items .slice() .sort((a, b) => a.id.localeCompare(b.id)) .map(summarizeMentalModel); this.#showMarkdownPanel(`Mental Models — ${state.bankId}`, lines.join("\n")); } catch (error) { this.ctx.showError(`mm list failed: ${error instanceof Error ? error.message : String(error)}`); } } async #mmShow(state: HindsightSessionState, id: string): Promise { try { const model = await state.client.getMentalModel(state.bankId, id, { detail: "content" }); if (!model) { this.ctx.showError(`Mental model not found: ${id}`); return; } const tags = model.tags && model.tags.length > 0 ? `\n_tags: ${model.tags.join(", ")}_` : ""; const refreshed = model.last_refreshed_at ? `\n_last refreshed: ${model.last_refreshed_at}_` : ""; const sourceQuery = model.source_query ? `\n\n**Source query:** ${model.source_query}` : ""; const content = (model.content ?? "_(empty — background reflect may still be running)_").trim(); this.#showMarkdownPanel(model.name, `**id:** \`${model.id}\`${tags}${refreshed}${sourceQuery}\n\n${content}`); } catch (error) { this.ctx.showError(`mm show failed: ${error instanceof Error ? error.message : String(error)}`); } } async #mmRefresh(state: HindsightSessionState, id: string | undefined): Promise { try { if (id) { // Single-model refresh is explicit operator intent: bypass the // auto-refresh filter so curated/manual models can still be // refreshed on demand. await state.client.refreshMentalModel(state.bankId, id); this.ctx.showStatus(`Refresh queued for mental model ${id}.`); } else { // Bulk refresh: only touch models that opted into automatic // refresh via `trigger.refresh_after_consolidation`. Curated // models are reviewed before publishing and must not be // silently regenerated by a bank-wide refresh sweep. Reading // `detail: "content"` here is required because the trigger // field is excluded from `detail: "metadata"`. const list = await state.client.listMentalModels(state.bankId, { detail: "content" }); const items = list.items ?? []; if (items.length === 0) { this.ctx.showStatus(`No mental models on bank ${state.bankId}.`); return; } const targets = items.filter(m => m.trigger?.refresh_after_consolidation === true); const skipped = items.length - targets.length; if (targets.length === 0) { this.ctx.showStatus( `No mental models opted into auto-refresh; ${skipped} curated model(s) left untouched. Pass an explicit id to refresh one of them.`, ); return; } let queued = 0; for (const item of targets) { try { await state.client.refreshMentalModel(state.bankId, item.id); queued++; } catch (error) { this.ctx.showWarning( `Refresh failed for ${item.id}: ${error instanceof Error ? error.message : String(error)}`, ); } } const skippedSuffix = skipped > 0 ? `; skipped ${skipped} curated model(s)` : ""; this.ctx.showStatus( `Refresh queued for ${queued}/${targets.length} auto-refresh model(s)${skippedSuffix}.`, ); } // Reload the cache after a brief grace so the new content (if the refresh // completes synchronously on the server) flows into the system prompt. await Bun.sleep(500); await reloadMentalModelsForSession(state.session); } catch (error) { this.ctx.showError(`mm refresh failed: ${error instanceof Error ? error.message : String(error)}`); } } async #mmHistory(state: HindsightSessionState, id: string): Promise { try { const [model, history] = await Promise.all([ state.client.getMentalModel(state.bankId, id, { detail: "content" }), state.client.getMentalModelHistory(state.bankId, id), ]); if (!model) { this.ctx.showError(`Mental model not found: ${id}`); return; } if (history.length === 0) { this.ctx.showStatus(`No history recorded for ${id}.`); return; } // History is most-recent first. Each entry stores the content BEFORE that // change. To diff "what changed at entry N", compare entry N's // previous_content (= state before that change) with entry N-1's // previous_content (= state after that change, which was state before // the next change). For the most recent change, compare against the // model's CURRENT content. const sections: string[] = []; for (let i = 0; i < history.length; i++) { const before = history[i].previous_content ?? ""; const after = i === 0 ? (model.content ?? "") : (history[i - 1].previous_content ?? ""); const diff = diffMentalModelContent(before, after); sections.push(`### ${history[i].changed_at}\n\n\`\`\`diff\n${diff}\n\`\`\``); } this.#showMarkdownPanel(`History — ${model.name}`, sections.join("\n\n")); } catch (error) { this.ctx.showError(`mm history failed: ${error instanceof Error ? error.message : String(error)}`); } } async #mmSeed(state: HindsightSessionState): Promise { try { const config = loadHindsightConfig(this.ctx.settings); const seeds = resolveSeedsForScope( { bankId: state.bankId, retainTags: state.retainTags, recallTags: state.recallTags, recallTagsMatch: state.recallTagsMatch, }, config.scoping, ); if (seeds.length === 0) { this.ctx.showStatus(`No built-in seeds apply to scoping=${config.scoping}.`); return; } const list = await state.client.listMentalModels(state.bankId, { detail: "metadata" }); const existing = list.items ?? []; let created = 0; let skipped = 0; for (const seed of seeds) { if (seedAlreadyExists(seed, existing)) { skipped++; continue; } try { await state.client.createMentalModel(state.bankId, seed.name, seed.sourceQuery, { id: seed.id, tags: seed.tags.length > 0 ? seed.tags : undefined, maxTokens: seed.maxTokens, trigger: seed.trigger, }); created++; } catch (error) { this.ctx.showWarning( `Seed failed for ${seed.id}: ${error instanceof Error ? error.message : String(error)}`, ); } } this.ctx.showStatus(`Seeded ${created} new mental model(s); ${skipped} already present.`); } catch (error) { this.ctx.showError(`mm seed failed: ${error instanceof Error ? error.message : String(error)}`); } } async #mmReload(state: HindsightSessionState): Promise { const ok = await reloadMentalModelsForSession(state.session); if (ok) { this.ctx.showStatus("Mental-model cache reloaded."); } else { this.ctx.showError("Reload failed (Hindsight backend not active or mental models disabled)."); } } async #mmDelete(state: HindsightSessionState, id: string): Promise { try { const removed = await state.client.deleteMentalModel(state.bankId, id); if (!removed) { this.ctx.showError(`Mental model not found: ${id}`); return; } // Drop the cached snippet so the closing tag does not silently keep // stale content in the system prompt until the next agent_end TTL. await reloadMentalModelsForSession(state.session); this.ctx.showStatus(`Deleted mental model ${id} from bank ${state.bankId}.`); } catch (error) { this.ctx.showError(`mm delete failed: ${error instanceof Error ? error.message : String(error)}`); } } async #runNewSessionFlow(options?: NewSessionOptions, label: string = "New session started"): Promise { this.ctx.clearTransientSessionUi(); if (this.ctx.session.isCompacting) { this.ctx.session.abortCompaction(); while (this.ctx.session.isCompacting) { await Bun.sleep(10); } } if (!(await this.ctx.session.newSession(options))) return; // A focused subagent view keeps its own history: return to the main session // first so the transcript below cannot rebuild from the subagent's surviving // conversation, then drop any turn-scoped anchors (coalescing timers, // in-flight dispatches) the session boundary orphaned. if (this.ctx.focusedAgentId) await this.ctx.unfocusSession(); this.ctx.eventController.resetTranscriptAnchors(); this.ctx.resetObserverRegistry(); setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); this.ctx.statusLine.invalidate(); this.ctx.statusLine.resetActiveTime(); this.ctx.updateEditorBorderColor(); this.ctx.clearTransientSessionUi(); this.ctx.resetTranscript(); this.ctx.present([new Spacer(1), new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1)]); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); } async handleClearCommand(): Promise { await this.#runNewSessionFlow(); } async handleFreshCommand(): Promise { const result = this.ctx.session.freshSession(); if (!result) { this.ctx.showWarning("Wait for the current response to finish or abort it before refreshing provider state."); return; } const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states"; this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(); this.ctx.showStatus(`Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`); } async handleResetContextCommand(): Promise { if (this.ctx.session.isCompacting) { this.ctx.session.abortCompaction(); while (this.ctx.session.isCompacting) { await Bun.sleep(10); } } const result = await this.ctx.session.resetSessionContext(); if (!result) { this.ctx.showWarning("Wait for the current response to finish or abort it before resetting the context."); return; } // Drop the rendered transcript so the UI matches the now-empty model // context (mirrors #runNewSessionFlow's teardown, minus the new session — // the session id, title, and transcript file all survive). this.ctx.clearTransientSessionUi(); this.ctx.resetTranscript(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); const noun = result.droppedCount === 1 ? "message" : "messages"; this.ctx.present([ new Spacer(1), new Text( `${theme.fg("accent", `${theme.status.success} Context reset — ${result.droppedCount} ${noun} dropped; session continues.`)}`, 1, 1, ), ]); this.ctx.ui.requestRender(true, { clearScrollback: true }); } async handleDeleteCommand(): Promise { if (!this.ctx.sessionManager.getSessionFile()) { this.ctx.showError("Nothing to delete (in-memory session)"); return; } await this.#runNewSessionFlow({ drop: true }, "Session deleted"); } async handleForkCommand(placement?: "pane" | "window"): Promise { if (placement !== undefined) { await this.#handleForkTerminalCommand(placement); return; } // A Tern pane fork leaves this session untouched, so it may run mid-turn. if (await this.#forkIntoTernPane()) return; // An in-place fork moves this process onto the new file; a running turn // would finish there and leave the original transcript cut mid-turn. if (this.ctx.session.isStreaming) { this.ctx.showWarning("Wait for the current response to finish or abort it before forking."); return; } if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } this.ctx.statusContainer.disposeChildren(); // After a `/fork`, the current session ID is changed to the forked one, // so the session ID before the fork is the one we want to show in the hint. const previousSessionId = this.ctx.sessionManager.isSessionOnDisk() ? this.ctx.sessionManager.getSessionId() : undefined; const success = await this.ctx.session.fork(); if (!success) { this.ctx.showError("Fork failed (session not persisted or cancelled)"); return; } this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(); this.ctx.present([ new Spacer(1), new Text( theme.fg( "accent", previousSessionId ? `${theme.status.success} Session forked · return to original: ${resumeCommand(previousSessionId)} or /resume ${previousSessionId}` : `${theme.status.success} Session forked`, ), 1, 1, ), ]); } async #handleForkTerminalCommand(placement: "pane" | "window"): Promise { const dependencies = this.#forkTerminalDependencies; const environment = dependencies.environment(); const multiplexer = dependencies.classifyTerminalMultiplexer(environment); const placementInfo = getTerminalLaunchPlacement(multiplexer, placement); if ("error" in placementInfo) { this.ctx.showError(placementInfo.error); return; } if (this.ctx.session.isStreaming) { const confirmed = await this.ctx.showHookConfirm( "Fork while a response is running?", "The child may not include the partial response. Continue opening a fork? The current session will keep running here.", ); if (!confirmed) return; } const initialSessionFile = this.ctx.sessionManager.getSessionFile(); if (!initialSessionFile) { this.ctx.showError("Cannot open a fork in another terminal: the current session has not been persisted yet."); return; } const childArgs = restartArgv(dependencies.argv(), undefined, FORK_LAUNCH_OVERRIDES); if (hasForkApiKeyOverride(childArgs)) { this.ctx.showError( "Cannot open a fork in another terminal when --api-key was supplied at startup; the key cannot be forwarded without exposing it in the multiplexer command. Configure provider credentials in the auth store and retry.", ); return; } const shellGrammar = placementInfo.shellGrammar; if (shellGrammar === "posix") { const confirmed = await this.ctx.showHookConfirm( "Confirm destination shell compatibility", "This fork command uses POSIX shell syntax. Continue only if the destination's configured interactive shell accepts POSIX syntax; this cannot be inferred from the current terminal.", ); if (!confirmed) { this.ctx.showWarning("Fork cancelled: POSIX shell compatibility was not confirmed for the destination."); return; } } try { await this.ctx.sessionManager.flush(); } catch (error) { logger.error("Failed to flush the session before opening a terminal fork", { error }); this.ctx.showError("Cannot open a fork in another terminal: failed to save the current session."); return; } const sessionFile = this.ctx.sessionManager.getSessionFile(); if (!sessionFile) { this.ctx.showError("Cannot open a fork in another terminal: the current session is no longer persisted."); return; } const sourceSessionFile = path.resolve(sessionFile); try { await fs.access(sourceSessionFile); } catch { this.ctx.showError("Cannot open a fork in another terminal: the persisted session transcript is unavailable."); return; } try { const activeProfile = dependencies.getActiveProfile(); const activeModel = this.ctx.session.model; if (activeProfile) childArgs.push("--profile", activeProfile); if (activeModel) childArgs.push("--model", `${activeModel.provider}/${activeModel.id}`); const activeThinkingLevel = this.ctx.session.configuredThinkingLevel(); if (activeThinkingLevel !== undefined) childArgs.push("--thinking", activeThinkingLevel); // Forcing --model/--thinking marks the fork's cache shape as changed, which would // drop the inherited key. Automatic candidates travel in the source header; // forwarding them as --prompt-cache-key would bypass their model gate. if ( this.ctx.session.agent.promptCacheKey !== undefined || this.ctx.sessionManager.getHeader()?.taskPromptCacheKey === undefined ) { childArgs.push("--prompt-cache-key", this.ctx.session.agent.promptCacheKey ?? this.ctx.session.sessionId); } childArgs.push("--fork", sourceSessionFile); // Pin only non-secret scope variables. const command = [ ...forkScopeEnvCommand({ PI_CODING_AGENT_DIR: this.ctx.settings.getAgentDir(), PI_CONFIG_DIR: getConfigDirName(), OMP_PROFILE: activeProfile, PI_PROFILE: activeProfile, PI_CONFIG_FILES: this.ctx.settings.getConfigFiles().join(path.delimiter), PI_CODING_AGENT_SESSION_DIR: environment.PI_CODING_AGENT_SESSION_DIR, XDG_DATA_HOME: environment.XDG_DATA_HOME, XDG_STATE_HOME: environment.XDG_STATE_HOME, XDG_CACHE_HOME: environment.XDG_CACHE_HOME, }), ...dependencies.resolveCliEntryCmd(), ...childArgs, ]; const launchPlan = createDefaultTerminalLaunchRequest( multiplexer, placement, command, this.ctx.sessionManager.getCwd(), shellGrammar, ); if ("error" in launchPlan) { this.ctx.showError(launchPlan.error); return; } const result = await dependencies.launchTerminal(launchPlan.request); if (result.warning) { this.ctx.showWarning( `Opened a fork in ${placementInfo.displayName}, but ${result.warning} This session continues here.`, ); return; } this.ctx.showStatus( `Opened a fork in ${placementInfo.displayName} (${placementInfo.placementLabel}); this session continues here.`, ); } catch (error) { logger.error("Failed to open a terminal fork", { multiplexer, error }); this.ctx.showError( error instanceof TerminalLaunchError ? `Could not open a fork in ${placementInfo.displayName}: ${error.message}` : `Could not open a fork in ${placementInfo.displayName}. See logs for details.`, ); } } /** * `/fork` inside a Tern pane: ask Tern to run `omp --fork` of this session in a new pane beside * this one, which keeps the original session. False means fork in place instead: outside Tern, * an unsaved session, a Tern without `fork`, or Tern refusing it. Once the request is out, an * unconfirmed one is reported rather than retried in place, since Tern may still open the pane. */ async #forkIntoTernPane(): Promise { const tern = resolveTernPane(); if (!tern || !this.ctx.sessionManager.isSessionOnDisk()) return false; const client = new TernSocketClient({ socketPath: tern.socketPath }); try { try { await client.connect(); } catch (error) { logger.debug("Tern unreachable for /fork; forking in place", { error: error instanceof Error ? error.message : String(error), }); return false; } if (!client.supports("fork")) return false; // The new pane's omp reads the session file as it starts. await this.ctx.session.flushToDisk(); try { await client.fork({ block: tern.pane }, { timeoutMs: TERN_FORK_TIMEOUT_MS }); } catch (error) { if (error instanceof TernError && !TERN_FORK_UNCONFIRMED[error.kind]) { const details = { kind: error.kind, error: error.message }; if (error.kind === "failed") logger.warn("Tern could not open the fork pane; forking in place", details); else logger.debug("Tern refused the fork; forking in place", details); return false; } this.ctx.showError( `Tern did not confirm the fork: ${error instanceof Error ? error.message : String(error)}`, ); return true; } } finally { client.close(); } const note = this.ctx.session.isStreaming ? " · the running response continues here, not in the fork" : ""; this.ctx.present([ new Spacer(1), new Text(theme.fg("accent", `${theme.status.success} Session forked into a new pane${note}`), 1, 1), ]); return true; } /** * `/move` — relocate the current session to a different directory. * * With no `targetPath` (TUI only), opens an autocomplete overlay so the user * can pick or type a directory. With a `targetPath`, resolves it directly. * If the target directory does not exist, the user is asked whether to create * it. The active session file and artifacts are moved into the target * directory's session bucket so `/resume` from that directory can find it. */ async handleMoveCommand(targetPath?: string): Promise { if (this.ctx.session.isStreaming) { this.ctx.showWarning("Wait for the current response to finish or abort it before moving."); return; } let input: string | undefined = targetPath?.trim() || undefined; // No argument in TUI mode: open the path autocomplete overlay. if (!input) { const result = await this.ctx.showHookCustom( (_tui, _theme, _keybindings, done) => new MoveOverlay(this.ctx.sessionManager.getCwd(), done, moveDirectorySource), { overlay: true }, ); if (!result) return; // cancelled input = result.directory; } const unquoted = stripOuterDoubleQuotes(input); if (!unquoted) { this.ctx.showError("Usage: /move "); return; } const cwd = this.ctx.sessionManager.getCwd(); const resolvedPath = resolveToCwd(unquoted, cwd); // If the directory doesn't exist, offer to create it. let isDirectory: boolean; try { isDirectory = (await fs.stat(resolvedPath)).isDirectory(); } catch { isDirectory = false; } if (!isDirectory) { const parentDir = path.dirname(resolvedPath); let parentExists = false; try { parentExists = (await fs.stat(parentDir)).isDirectory(); } catch { parentExists = false; } if (!parentExists) { this.ctx.showError(`Cannot create "${path.basename(resolvedPath)}": parent directory does not exist`); return; } } const moved = await this.#withSessionMove(async () => { if (!isDirectory) { const confirmed = await this.ctx.showHookConfirm( "Create directory?", `"${path.basename(resolvedPath)}" does not exist. Create it?`, ); if (!confirmed) return false; try { await fs.mkdir(resolvedPath, { recursive: true }); } catch (err) { this.ctx.showError(`Failed to create directory: ${err instanceof Error ? err.message : String(err)}`); return false; } } return this.#relocateSession(resolvedPath); }); if (moved) { this.ctx.present([ new Spacer(1), new Text(`${theme.fg("accent", `${theme.status.success} Moved to ${resolvedPath}`)}`, 1, 1), ]); } } /** * `/wt []` — fork the checkout into a new linked git worktree on * `branch` (default `wt/`), carrying uncommitted changes along * unless `keepChanges` is false, then relocate the session there like `/move`. * Returns the worktree only when the session now lives in it. */ async handleWorktreeCommand( branch?: string, options: { keepChanges?: boolean } = {}, ): Promise { if (this.ctx.session.isStreaming) { this.ctx.showWarning("Wait for the current response to finish or abort it before creating a worktree."); return undefined; } let created: SessionWorktree | undefined; await this.#withSessionMove(async () => { const branchName = branch?.trim() || defaultSessionWorktreeBranch(); const cwd = this.ctx.sessionManager.getCwd(); this.ctx.statusContainer.disposeChildren(); const loader = new Loader( this.ctx.ui, spinner => theme.fg("accent", spinner), text => theme.fg("muted", text), `Creating worktree on ${branchName}…`, getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(loader); this.ctx.ui.requestRender(); let worktree: SessionWorktree; try { worktree = await createSessionWorktree(cwd, this.ctx.settings, branchName, options); } catch (err) { this.ctx.showError(`Worktree creation failed: ${err instanceof Error ? err.message : String(err)}`); return false; } finally { loader.stop(); this.ctx.statusContainer.disposeChildren(); } if (worktree.cloneError) { logger.warn("worktree clone fell back to plain checkout", { path: worktree.path, error: worktree.cloneError, }); } if (!(await this.#relocateSession(worktree.path))) return false; created = worktree; const cleanup = worktree.keptChanges ? await cleanSourceCheckoutIfConfigured(cwd, this.ctx.settings) : { cleaned: false, errorMessage: undefined }; if (cleanup.errorMessage !== undefined) { this.ctx.showWarning(`Worktree created, but cleaning source checkout failed: ${cleanup.errorMessage}`); } this.ctx.present([ new Spacer(1), new Text( `${theme.fg("accent", `${theme.status.success} ${formatSessionWorktreeSummary(worktree, cleanup.cleaned)}`)}`, 1, 1, ), ]); return true; }); return created; } /** Save source settings before acquiring the gate for a complete relocation operation. */ async #withSessionMove(operation: () => Promise): Promise { try { await this.ctx.settings.flush(); } catch (err) { this.ctx.showError(`Failed to save pending settings: ${err instanceof Error ? err.message : String(err)}`); return false; } return this.ctx.withBtwSessionMove(operation); } /** Relocate only while #withSessionMove holds the BTW gate; false means no successful move. */ async #relocateSession(resolvedPath: string): Promise { if (resolvedPath === path.resolve(this.ctx.sessionManager.getCwd())) return false; const previousState = this.ctx.sessionManager.captureState(); try { await this.ctx.session.moveSession(resolvedPath); } catch (err) { this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`); return false; } let applied = false; try { applied = await this.ctx.applyCwdChange(resolvedPath); } catch (error) { await this.#restoreAfterMoveFailure(previousState, error); return false; } if (!applied) { await this.#restoreAfterMoveFailure(previousState); return false; } this.ctx.updateEditorBorderColor(); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(); return true; } async handleRenameCommand(title: string): Promise { const session = this.ctx.session; const sessionManager = this.ctx.sessionManager; const sessionId = sessionManager.getSessionId(); const signal = session.titleGenerationSignal; let titleRevision = sessionManager.titleRevision; const isCurrent = () => this.ctx.session === session && this.ctx.sessionManager === sessionManager && !signal.aborted && sessionManager.getSessionId() === sessionId && sessionManager.titleRevision === titleRevision; try { const persistence = sessionManager.setSessionName(title, "user"); titleRevision = sessionManager.titleRevision; const stored = await persistence; if (!isCurrent()) return; if (!stored) { this.ctx.showError("Session name cannot be empty."); return; } const name = sessionManager.getSessionName()!; this.ctx.showStatus(`Session renamed to "${name}".`); } catch (err) { if (!isCurrent()) return; this.ctx.showError(`Rename failed: ${err instanceof Error ? err.message : String(err)}`); } } async handleBashCommand(command: string, excludeFromContext = false): Promise { const isDeferred = this.ctx.session.isStreaming; const shouldPersistCwd = isPersistentShellCdCommand(command); if (isDeferred && shouldPersistCwd) { this.ctx.showWarning("Wait for the current response to finish or abort it before changing directories."); return; } if (shouldPersistCwd) { await this.#withSessionMove(() => this.#executeBashCommand(command, excludeFromContext, isDeferred, true)); } else { await this.#executeBashCommand(command, excludeFromContext, isDeferred, false); } } /** Returns whether shell execution committed a cwd relocation, not whether the shell command succeeded. */ async #executeBashCommand( command: string, excludeFromContext: boolean, isDeferred: boolean, shouldPersistCwd: boolean, ): Promise { const component = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext); this.ctx.bashComponent = component; // Provisional until the session records the run. const block = new PresentedBlock( component, messageBlock({ role: "bashExecution", command, output: "", exitCode: undefined, cancelled: false, truncated: false, timestamp: Date.now(), excludeFromContext, }), ); block.setExpanded(this.ctx.toolOutputExpanded); let detachInput: (() => void) | undefined; let finished = false; if (isDeferred) { this.ctx.pendingShellContainer.addChild(block); this.ctx.pendingBashComponents.push(block); } else { this.ctx.present(block); } this.ctx.ui.requestRender(); try { const result = await this.ctx.session.executeBash( command, chunk => { if (this.ctx.bashComponent) { this.ctx.bashComponent.appendOutput(chunk); } }, { excludeFromContext, onRecorded: message => block.setBlock(messageBlock(message)), useUserShell: true, // User-shell zsh/fish `!` commands run on a headless PTY; raw // bytes render through the component's vterm replay (color-safe). pty: { ...bashPtyViewport(this.ctx.ui), onChunk: chunk => this.ctx.bashComponent?.appendPtyChunk(chunk), onStdin: write => { if (finished) return; // A TSP field without a mode handles printable keys itself and sends // `edit` packets instead of raw input. Label it while the PTY owns // the keyboard so Tern delivers those keys to the input listener. detachInput = this.#forwardKeysToPty(component, write); this.ctx.editor.nativeKeyPassthrough = true; 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, artifactError: meta?.artifactError, minimized: result.minimized, images: result.images, showImages: cfgTerminalShowImages.get(this.ctx.settings), }); } try { if (shouldPersistCwd) return await this.#applyBashResultCwd(result); } catch (error) { this.ctx.showError( `Bash command completed, but OMP failed to update its working directory: ${ error instanceof Error ? error.message : "Unknown error" }`, ); } } catch (error) { if (this.ctx.bashComponent) { this.ctx.bashComponent.setComplete(undefined, false); } this.ctx.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`); } finally { finished = true; detachInput?.(); if (detachInput) this.ctx.editor.nativeKeyPassthrough = false; this.ctx.bashComponent = undefined; this.ctx.ui.requestRender(); } return false; } /** * Routes composer keystrokes to a running `!` command's PTY so it can answer * its own prompts (`read`, shell-startup questions), like a foreground job. * The interrupt key still cancels; focused dialogs and mouse reports keep * their input. Returns the detach function. */ #forwardKeysToPty(component: BashExecutionComponent, write: (data: string) => void): () => void { return this.ctx.ui.addInputListener( data => { if (this.ctx.ui.getFocused() !== this.ctx.editor) return undefined; if (this.ctx.keybindings.matches(data, "app.interrupt") || parseSgrMouse(data)) return undefined; const bytes = component.encodePtyInput(data); if (bytes) write(bytes); return { consume: true }; }, { prepend: true }, ); } async #applyBashResultCwd(result: BashResult): Promise { if (result.cancelled || result.exitCode !== 0 || !result.workingDir) return false; if (!path.isAbsolute(result.workingDir)) return false; const resolvedPath = path.resolve(result.workingDir); if (resolvedPath === path.resolve(this.ctx.sessionManager.getCwd())) return false; let isDirectory = false; try { isDirectory = (await fs.stat(resolvedPath)).isDirectory(); } catch { isDirectory = false; } if (!isDirectory) return false; return this.#relocateSession(resolvedPath); } async handlePythonCommand(code: string, excludeFromContext = false): Promise { const isDeferred = this.ctx.session.isStreaming; this.ctx.pythonComponent = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext); // Provisional until the session records the cell. const block = new PresentedBlock( this.ctx.pythonComponent, messageBlock({ role: "pythonExecution", code, output: "", exitCode: undefined, cancelled: false, truncated: false, timestamp: Date.now(), excludeFromContext, }), ); block.setExpanded(this.ctx.toolOutputExpanded); if (isDeferred) { this.ctx.pendingShellContainer.addChild(block); this.ctx.pendingPythonComponents.push(block); } else { this.ctx.present(block); } this.ctx.ui.requestRender(); try { const result = await this.ctx.session.executePython( code, chunk => { if (this.ctx.pythonComponent) { this.ctx.pythonComponent.appendOutput(chunk); } }, { excludeFromContext, onRecorded: message => block.setBlock(messageBlock(message)) }, ); 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, artifactError: meta?.artifactError, }); } } catch (error) { if (this.ctx.pythonComponent) { this.ctx.pythonComponent.setComplete(undefined, false); } this.ctx.showError(`Python execution failed: ${error instanceof Error ? error.message : "Unknown error"}`); } this.ctx.pythonComponent = undefined; this.ctx.ui.requestRender(); } async handleCompactCommand( customInstructions?: string, mode?: CompactMode, beforeFlush?: (outcome: CompactionOutcome) => void | Promise, options?: Pick, ): 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 "ok"; } // `internalGuidance` is a private summarizer directive (plan-mode // "Approve and compact context") that MUST stay off the public // `customInstructions` channel of the `session_before_compact` extension // hook — extensions treat that field as user focus and would otherwise // bias the summary toward the plan boilerplate (issue #4359). Ride it // through as a CompactOptions field instead. That caller also dispatches // the execution turn itself, so the compaction must not resume the // plan-approval turn it aborted. if (options?.internalGuidance) { return this.executeCompaction( { internalGuidance: options.internalGuidance, suppressContinuation: true, ...(mode ? { mode } : {}) }, false, beforeFlush, mode, options.readerModel, ); } return this.executeCompaction(customInstructions, false, beforeFlush, mode, options?.readerModel); } /** * Run the `/compact` queued for the next yield once the session goes quiet. One watcher serves the * request from the moment it is queued, so no single event (an `agent_end` that a stale-settle guard * skips, a setup that bails without one) can strand it; later calls join the running watcher. * Follow-ups queued behind the request wait in `compactionQueuedMessages` and flush once it ends. */ runYieldCompaction(): Promise { this.#yieldCompactionWatcher ??= this.#watchYieldCompaction().finally(() => { this.#yieldCompactionWatcher = undefined; }); return this.#yieldCompactionWatcher; } async #watchYieldCompaction(): Promise { let request = this.ctx.yieldCompaction; if (!request) return; const session = this.ctx.session; while (true) { await session.waitForIdle(); // Input sent before the request reaches the session first, also while it is still dispatching. await this.ctx.waitForSubmissionDispatches(); await session.waitForAdmittedSubmissions(); // Let a session switch commit or roll back before judging which conversation is live. if (session.isSessionTransitioning) await session.waitForSessionTransition(); if (session.hasPendingAsyncWork()) await session.settleAsyncWork(); if (this.ctx.session !== session) return; if (this.ctx.yieldCompaction !== request) { // Dequeued (and maybe queued again): serve whatever request is pending now. if (!this.ctx.yieldCompaction) return; request = this.ctx.yieldCompaction; continue; } // The run yielded only once nothing can continue it: no turn, retry, queued input, pending // submission, background job, or transition. Otherwise something is mid-flight; look again. if ( !session.isStreaming && !session.isRetrying && !session.isSessionTransitioning && !session.hasAdmittedSubmission && !session.hasPendingAsyncWork() && session.queuedMessageCount === 0 && !this.ctx.hasPendingSubmission() ) { break; } await Bun.sleep(10); } if (!continuesConversation(this.ctx.sessionManager, request)) { // Switched sessions or navigated the tree (both may reuse this `AgentSession`): nothing queued // for the old position may compact or run here. this.ctx.restoreCompactionQueueToEditor(); return; } this.ctx.yieldCompaction = undefined; // The request leaves; its position stays with the follow-ups it held until they flush. this.ctx.compactionQueueOrigin = { sessionId: request.sessionId, leafId: request.leafId }; this.ctx.updatePendingMessagesDisplay(); if (session.isCompacting) { // Another pass (auto, or an extension's `ctx.compact()`) already compacts. this.ctx.showStatus("Skipped queued compaction: context is already compacting"); while (session.isCompacting) { await Bun.sleep(10); } await this.ctx.flushCompactionQueue({ willRetry: false }); return; } // `executeCompaction` calls the hook right before it flushes the queue; "Nothing to compact" // returns before both, so the held follow-ups are released here instead. let compactionFlushes = false; await this.handleCompactCommand(request.instructions, request.mode, () => { compactionFlushes = true; }); if (!compactionFlushes) await this.ctx.flushCompactionQueue({ willRetry: false }); } /** * TUI handler for `/shake`. `elide` drops heavy structural content, * `images` strips image blocks, and `thinking` drops the thinking blocks the * request rebuilds from them, keeping the turns it replays natively (they * are reported as kept and the transcript is left alone). Rebuilds the chat * and reports counts. */ async handleShakeCommand(mode: ShakeMode): Promise { let result: ShakeResult; try { result = await this.ctx.session.shake(mode); } catch (error) { this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`); return; } const dropped = result.toolResultsDropped + result.blocksDropped + (result.imagesDropped ?? 0) + (result.thinkingBlocksDropped ?? 0); if (dropped === 0) { // Replayed thinking was left in place: nothing to rebuild, but say so. this.ctx.showStatus((result.thinkingBlocksKept ?? 0) > 0 ? formatShakeSummary(result) : "Nothing to shake."); return; } this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(true, { clearScrollback: true }); this.ctx.showStatus(formatShakeSummary(result)); } async executeCompaction( customInstructionsOrOptions?: string | CompactOptions, isAuto = false, beforeFlush?: (outcome: CompactionOutcome) => void | Promise, mode?: CompactMode, readerModel?: Model, ): Promise { if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } this.ctx.statusContainer.disposeChildren(); const cancelHint = `(${appKey(this.ctx.keybindings, "app.interrupt")} to cancel)`; const label = isAuto ? `Auto-compacting context... ${cancelHint}` : `Compacting context... ${cancelHint}`; const compactingLoader = new Loader( this.ctx.ui, spinner => theme.fg("accent", spinner), text => theme.fg("muted", text), label, getSymbolTheme().spinnerFrames, ); const compactionStartMs = Date.now(); compactingLoader.setWorkingRow( () => ({ label: isAuto ? "Auto-compacting context…" : "Compacting context…", startedAt: compactionStartMs, variant: { kind: "compaction" }, interruptKey: this.ctx.maintenanceInterruptKey(), }), () => this.ctx.interruptFromPointer(), ); this.ctx.statusContainer.addChild(compactingLoader); this.ctx.ui.requestRender(); let outcome: CompactionOutcome = "ok"; try { const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined; const baseOptions = customInstructionsOrOptions && typeof customInstructionsOrOptions === "object" ? customInstructionsOrOptions : undefined; // The slash path passes `mode` positionally; the extension path carries // it inside the options object. Either source wins over no mode. const effectiveMode = mode ?? baseOptions?.mode; const options = baseOptions || effectiveMode || readerModel ? { ...baseOptions, ...(effectiveMode ? { mode: effectiveMode } : {}), ...(readerModel ? { readerModel } : {}), } : undefined; await this.ctx.session.compact(instructions, options); compactingLoader.stop(); this.ctx.statusContainer.disposeChildren(); this.ctx.rebuildChatFromMessages({ reuseSettledComponents: true }); this.ctx.statusLine.invalidate(); // Same pairing as the auto-compaction arm in event-controller: the // rebuild clears the container's emission ledger, so every block // re-emits on this frame while the previous copy is still in native // scrollback — without a clear the collapse-disabled path appends a // duplicate transcript, exactly as `/compact` reproduced (#12140). this.ctx.ui.requestRender(true, { clearScrollback: true }); } catch (error) { if (error instanceof CompactionCancelledError) { outcome = "cancelled"; this.ctx.showError("Compaction cancelled"); } else { outcome = "failed"; const message = error instanceof Error ? error.message : String(error); this.ctx.showError(`Compaction failed: ${message}`); } } finally { compactingLoader.stop(); this.ctx.statusContainer.disposeChildren(); } // Run the caller's pre-flush hook (e.g. the plan-approval model transition) // before queued user input is dispatched, so any turn queued during // compaction executes on the post-compaction model rather than the model // compaction itself ran on. if (beforeFlush) await beforeFlush(outcome); await this.ctx.flushCompactionQueue({ willRetry: false }); return outcome; } async handleHandoffCommand(customInstructions?: string): Promise { if (this.ctx.session.isStreaming) { this.ctx.showWarning("Wait for the current response to finish or abort it before handing off."); return; } if (this.ctx.session.isCompacting) { this.ctx.showWarning("Wait for context compaction to finish or cancel it before handing off."); return; } 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; } if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } this.ctx.statusContainer.disposeChildren(); const handoffLoader = new Loader( this.ctx.ui, spinner => theme.fg("accent", spinner), text => theme.fg("muted", text), `Generating handoff… (${appKey(this.ctx.keybindings, "app.interrupt")} to cancel)`, getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(handoffLoader); this.ctx.ui.requestRender(); try { // Handoff generation runs as a oneshot request; the document is then // committed as a compaction entry on this session. const result = await this.ctx.session.handoff(customInstructions); if (!result) { this.ctx.showError("Handoff cancelled"); return; } // Rebuild chat from the session, which now shows the handoff compaction divider. this.ctx.clearTransientSessionUi(); await this.ctx.renderInitialMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); await this.ctx.reloadTodos(); this.ctx.present([ new Spacer(1), new Text( `${theme.fg("accent", `${theme.status.success} Context handed off and compacted in place`)}`, 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); // `session.handoff()` normalizes genuine cancellations to this exact message; a // provider error (even one named AbortError) is re-thrown verbatim so it surfaces // as a real failure instead of a false "cancelled". if (message === "Handoff cancelled") { this.ctx.showError("Handoff cancelled"); } else { // Persist the real failure so it is debuggable after the transient // TUI error clears (#7993). logger.error("Handoff failed", { error: message }); this.ctx.showError(`Handoff failed: ${message}`); } } finally { this.#finishHandoffUi(handoffLoader); await this.ctx.flushCompactionQueue({ willRetry: false }); } this.ctx.ui.requestRender(true, { clearScrollback: true }); } #finishHandoffUi(handoffLoader: Loader): void { handoffLoader.stop(); // A retry/compaction event may replace the handoff overlay while transcript // replay yields. Preserve it only while it still owns the status row; a // reference to a loader disposed earlier must not retain the handoff overlay. const maintenanceLoader = this.ctx.autoCompactionLoader ?? this.ctx.retryLoader; if (maintenanceLoader && this.ctx.statusContainer.children.includes(maintenanceLoader)) return; this.ctx.statusContainer.disposeChildren(); // `disposeChildren()` disposed any working loader mounted by a delayed // `agent_start` during transcript replay, which stops its animation timer. // Drop the now-frozen reference so the reconciler below never reattaches it // (`ensureLoadingAnimation()` only re-adds an existing instance, never // restarts it). if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } if (this.ctx.session.isStreaming) { // A new turn won the race with handoff cleanup; mount a fresh, running // loader for it now that the stale reference is cleared. this.ctx.ensureLoadingAnimation(); } } } const BAR_WIDTH_MAX = 24; const COLUMN_WIDTH_MIN = 4; function renderJobLine(job: AsyncJobSnapshotItem, now: number): string { const duration = formatDuration(Math.max(0, (job.endTime ?? 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 formatNumber(value: number, maxFractionDigits = 1): string { return new Intl.NumberFormat("en-US", { maximumFractionDigits: maxFractionDigits }).format(value); } function resolveProviderAuthMode(authStorage: AuthStorage, provider: string): string { if (authStorage.credentials.hasOAuth(provider)) { return "oauth"; } if (authStorage.credentials.has(provider)) { return "api key"; } if (getEnvApiKey(provider)) { return "env api key"; } if (authStorage.keys.source(provider) !== undefined) { 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 resolveProviderUsageTotal(reports: UsageReport[]): number { return reports .flatMap(report => report.limits) .map(limit => resolveUsedFraction(limit) ?? 0) .reduce((sum, value) => sum + value, 0); } function formatWindowSuffix(label: string, windowLabel: string, uiTheme: 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})`); } /** ` (org)` suffix for providers whose orgName is an organization. */ function orgSuffix(report: UsageReport): string { const orgName = report.metadata?.orgName; const orgId = report.metadata?.orgId; const org = typeof orgName === "string" && orgName ? orgName : typeof orgId === "string" ? orgId : undefined; return org ? ` (${org})` : ""; } /** Keep the existing TUI `(plan)` layout while using the live Codex usage plan. */ function formatCodexTuiLabel(report: UsageReport, peers: readonly UsageReport[], base: string): string { const identity = formatCodexUsageReportLabel(report, peers, base, undefined, false); const plan = codexUsagePlan(report); return plan ? `${identity} (${plan})` : identity; } function formatAccountLabel( limit: UsageLimit, report: UsageReport, peers: readonly UsageReport[], index: number, ): string { const codex = report.provider === "openai-codex"; const email = report.metadata?.email; if (typeof email === "string" && email) return codex ? formatCodexTuiLabel(report, peers, email) : `${email}${orgSuffix(report)}`; const accountId = typeof report.metadata?.accountId === "string" && report.metadata.accountId ? report.metadata.accountId : limit.scope.accountId || undefined; if (accountId) return codex ? formatCodexTuiLabel(report, peers, accountId) : `${accountId}${orgSuffix(report)}`; const projectId = typeof report.metadata?.projectId === "string" && report.metadata.projectId ? report.metadata.projectId : limit.scope.projectId || undefined; const base = typeof projectId === "string" && projectId ? projectId : `account ${index + 1}`; return codex ? formatCodexTuiLabel(report, peers, base) : base; } function formatUnlimitedReportLabel(report: UsageReport, peers: readonly UsageReport[], index: number): string { const email = report.metadata?.email; if (typeof email === "string" && email) return report.provider === "openai-codex" ? formatCodexTuiLabel(report, peers, email) : `${email}${orgSuffix(report)}`; const accountId = report.metadata?.accountId; if (typeof accountId === "string" && accountId) return report.provider === "openai-codex" ? formatCodexTuiLabel(report, peers, accountId) : `${accountId}${orgSuffix(report)}`; const projectId = report.metadata?.projectId; const base = typeof projectId === "string" && projectId ? projectId : `account ${index + 1}`; return report.provider === "openai-codex" ? formatCodexTuiLabel(report, peers, base) : base; } function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined { const resetsAt = limit.window?.resetsAt; if (resetsAt === undefined) return undefined; // Codex returns the prior window's reset_at until a new request opens a fresh window — // rendering a negative delta is meaningless, so drop the suffix in that case. if (resetsAt <= nowMs) return undefined; return formatDuration(resetsAt - nowMs); } function formatAccountHeaderRow( limits: UsageLimit[], reports: UsageReport[], peers: readonly UsageReport[], nowMs: number, columnWidth: number, uiTheme: Theme, activeAccount?: OAuthAccountIdentity, ): string[] { const parts = limits.map((limit, index) => { const reset = formatResetShort(limit, nowMs); const report = reports[index]; const active = report !== undefined && limitMatchesActiveAccount(report, limit, activeAccount); const label = formatAccountLabel(limit, report, peers, index); return { label: active ? `● ${label}` : label, suffix: reset ? `(${reset})` : "", active, daybreak: report?.metadata?.daybreak === true, }; }); const maxSuffixWidth = parts.reduce((max, p) => Math.max(max, visibleWidth(p.suffix)), 0); const gap = maxSuffixWidth > 0 ? 1 : 0; const prefixBudget = columnWidth - maxSuffixWidth - gap; // If suffix can't share the cell with at least `x…`, fall back to whole-label truncation. if (prefixBudget < 2) { return parts.map(p => { const full = `${p.label}${p.daybreak ? " daybreak" : ""}${p.suffix ? ` ${p.suffix}` : ""}`; const cell = padColumn(truncateJobLabel(full, columnWidth), columnWidth); return p.active ? uiTheme.fg("accent", cell) : cell; }); } return parts.map(p => { // Keep the full badge visible by taking its columns from the account label. const badge = p.daybreak && prefixBudget >= 10 ? " daybreak" : ""; const label = truncateJobLabel(p.label, prefixBudget - visibleWidth(badge)); const prefix = `${p.active ? uiTheme.fg("accent", label) : label}${badge ? uiTheme.fg("success", badge) : ""}`; const prefixCell = prefix + " ".repeat(prefixBudget - visibleWidth(prefix)); if (!p.suffix) return prefixCell + " ".repeat(maxSuffixWidth + gap); const suffixPad = " ".repeat(maxSuffixWidth - visibleWidth(p.suffix)); return `${prefixCell} ${suffixPad}${uiTheme.fg("dim", p.suffix)}`; }); } function padColumn(text: string, width: number): string { const visible = visibleWidth(text); if (visible >= width) return text; return `${text}${padding(width - visible)}`; } type AggregateDisplayStatus = NonNullable | "neutral"; function resolveAggregateStatus(limits: UsageLimit[]): AggregateDisplayStatus { 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 limits.length > 0 && limits.every(isUsedOnlyAbsoluteAmount) ? "neutral" : "unknown"; } if (hasOk) { return hasWarning || hasExhausted ? "warning" : "ok"; } if (hasWarning) return "warning"; return "exhausted"; } function formatAggregateAmount(limits: UsageLimit[]): string { const fractions = limits .map(limit => resolveUsedFraction(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 avgRemaining = Math.max(0, ((limits.length - sum) / limits.length) * 100); return `${formatNumber(avgRemaining)}% free`; } 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 remainingPct = totalLimit > 0 ? Math.max(0, 100 - (totalUsed / totalLimit) * 100) : 0; return `${formatNumber(remainingPct)}% free`; } if (limits.length > 0 && limits.every(isUsedOnlyAbsoluteAmount)) return ""; // Prepaid balances have no total to divide by. `totalRemainingOnly` // collapses account-wide pools seen once per stored key and sums only // genuinely distinct ones, so a multi-key provider is never double-counted. const remaining = formatRemainingOnlyTotal(limits); if (remaining !== undefined) return remaining; // Count unique accounts from limit scopes — not limits.length. const uniqueAccountIds = new Set( limits.map(limit => limit.scope.accountId).filter((id): id is string => typeof id === "string" && id.length > 0), ); if (uniqueAccountIds.size > 0) return `${uniqueAccountIds.size} ${uniqueAccountIds.size === 1 ? "acct" : "accts"}`; // No account IDs available — keep the pre-existing fallback so providers // that don't populate scope.accountId still show a summary. return `${limits.length} accts`; } function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null { const windows = limits .map(limit => limit.window) .filter( (window): window is NonNullable => window?.resetsAt !== undefined && Number.isFinite(window.resetsAt) && window.resetsAt > nowMs, ); if (windows.length === 0) return null; // Use the shared verb when every contributing window agrees (e.g. all "tick"); // mixed or absent labels fall back to the generic "resets". const labels = new Set(windows.map(window => window.resetLabel ?? "resets")); const verb = labels.size === 1 ? [...labels][0]! : "resets"; const offsets = windows.map(window => window.resetsAt! - nowMs); const minReset = Math.min(...offsets); const maxReset = Math.max(...offsets); if (maxReset - minReset > 60_000) { return `${verb} in ${formatDuration(minReset)}–${formatDuration(maxReset)}`; } return `${verb} in ${formatDuration(minReset)}`; } function resolveStatusIcon(status: AggregateDisplayStatus, uiTheme: Theme): string { if (status === "neutral") return uiTheme.fg("dim", uiTheme.status.info); 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 uiTheme.fg("dim", uiTheme.status.pending); } 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: Theme, barWidth: number): string { const usedAmount = limit.amount.used; if (usedAmount !== undefined && isUsedOnlyAbsoluteAmount(limit)) { const used = limit.amount.unit === "usd" ? `$${usedAmount.toFixed(2)}` : `${formatNumber(usedAmount, 2)} ${limit.amount.unit}`; return uiTheme.fg("dim", truncateJobLabel(`${used} used`, barWidth)); } const fraction = resolveUsedFraction(limit); if (fraction === undefined) { return uiTheme.fg("dim", "·".repeat(barWidth)); } const clamped = Math.min(Math.max(fraction, 0), 1); const exact = clamped * barWidth; const fullCells = Math.floor(exact); const remainder = exact - fullCells; let partial = ""; if (remainder >= 2 / 3) partial = "▓"; else if (remainder >= 1 / 3) partial = "▒"; const leading = "█".repeat(fullCells) + partial; const empty = "░".repeat(Math.max(0, barWidth - fullCells - (partial ? 1 : 0))); const color = resolveStatusColor(limit.status); return `${uiTheme.fg(color, leading)}${uiTheme.fg("dim", empty)}`; } /** * Pick a per-account column width so the columns and trailing amount fit in `available`. * Falls back to the minimum when the terminal is too narrow rather than wrapping. */ function resolveColumnWidth(count: number, available: number, trailing: number): number { if (count <= 0) return BAR_WIDTH_MAX; const indent = 2; const gaps = count - 1; const spaceForBars = available - indent - gaps - (trailing > 0 ? trailing + 1 : 0); const ideal = Math.floor(spaceForBars / count); if (ideal < COLUMN_WIDTH_MIN) return COLUMN_WIDTH_MIN; return ideal; } export function renderUsageReports( reports: UsageReport[], uiTheme: Theme, nowMs: number, availableWidth: number, resolveActiveAccount?: (provider: string) => OAuthAccountIdentity | undefined, usageModelSelectors: readonly string[] = [], unavailableAccounts: readonly UnavailableUsageAccount[] = [], ): string { const displayReports = collapseSharedUsageReports(reports); const lines: string[] = []; const latestFetchedAt = Math.max(0, ...reports.map(report => report.fetchedAt ?? 0)); const headerSuffix = latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : ""; lines.push(uiTheme.bold(uiTheme.fg("accent", `Usage${headerSuffix}`))); const grouped = new Map(); for (const report of displayReports) { const list = grouped.get(report.provider) ?? []; list.push(report); grouped.set(report.provider, list); } for (const account of unavailableAccounts) { if (!grouped.has(account.provider)) grouped.set(account.provider, []); } 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 activeAccount = resolveActiveAccount?.(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"; // Distinct buckets can share a label and window (Antigravity's Gemini // vs Gemini (Argon) weekly) and must stay separate rows instead of // averaging into "50% free", so the id joins the key. Label and window // stay in it too: some providers build ids positionally or per plan // (kimi's `${index}`, copilot's allowance), where ids differ across // accounts for the same quota. `sharedGroup` keeps the routing copies // of one shared bucket together. const key = limit.scope.sharedGroup ?? `${formatLimitTitle(limit)}|${windowId}|${limit.id}`; 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("accent", providerName))); const activeReport = provider === "openai-codex" && activeAccount ? ((activeAccount.accountId ? providerReports.find( report => report.metadata?.orgId === activeAccount.orgId && report.metadata?.accountId === activeAccount.accountId, ) : undefined) ?? providerReports.find( report => report.metadata?.orgId === activeAccount.orgId && !!activeAccount.email && report.metadata?.email === activeAccount.email && (!activeAccount.accountId || !report.metadata?.accountId), )) : undefined; const activeAccountLabel = provider === "openai-codex" ? activeReport ? formatCodexTuiLabel( activeReport, providerReports, activeAccount?.email || activeAccount?.accountId || "account", ) : activeAccount?.email || activeAccount?.accountId || activeAccount?.projectId : formatActiveAccountLabel(activeAccount); if (activeAccountLabel) { lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`); } const reportingModels = usageModelSelectors.filter(selector => selector.startsWith(`${provider}/`)); if (reportingModels.length > 0) { lines.push(` ${uiTheme.fg("accent", "Models with usage data")}`); for (const selector of reportingModels) { lines.push(` ${replaceTabs(truncateToWidth(sanitizeText(selector), availableWidth - 4))}`); } } for (const account of unavailableAccounts) { if (account.provider !== provider) continue; const label = replaceTabs(sanitizeText(account.label.replace(/[\r\n]+/g, " "))); const status = " — usage unavailable"; const boundedLabel = truncateToWidth(label, Math.max(0, availableWidth - 2 - visibleWidth(status))); lines.push(` ${uiTheme.fg("dim", truncateToWidth(`${boundedLabel}${status}`, availableWidth - 2))}`); } // Provider-wide disclaimers (e.g. "OMP-observed spend only") render once // above the per-account sections instead of duplicating onto every limit. const providerNotes = [...new Set(providerReports.flatMap(report => report.notes ?? []))]; if (providerNotes.length > 0) { lines.push( ` ${uiTheme.fg("dim", replaceTabs(truncateToWidth(sanitizeText(providerNotes.map(n => n.replace(/[\r\n]+/g, " ")).join(" • ")), 110)))}`.trimEnd(), ); } const resetAccountLines: string[] = []; for (const report of providerReports) { const resets = summarizeUsageResetCredits(report.resetCredits, nowMs); if (!resets || resets.bankedCount <= 0) continue; const identityLabel = typeof report.metadata?.email === "string" && report.metadata.email ? report.metadata.email : typeof report.metadata?.accountId === "string" && report.metadata.accountId ? report.metadata.accountId : "account"; const orgName = report.metadata?.orgName; const orgId = report.metadata?.orgId; const orgLabel = typeof orgName === "string" && orgName ? orgName : typeof orgId === "string" ? orgId : undefined; const rawLabel = provider === "openai-codex" ? formatCodexTuiLabel(report, providerReports, identityLabel) : orgLabel && orgLabel !== identityLabel ? `${identityLabel} (${orgLabel})` : identityLabel; const label = sanitizeText(rawLabel.replace(/[\r\n\t]+/g, " ")); const activeOrg = activeAccount?.orgId; const reportOrg = typeof report.metadata?.orgId === "string" ? report.metadata.orgId : undefined; const orgMatches = !activeOrg && !reportOrg ? true : activeOrg === reportOrg; const isActive = provider === "openai-codex" ? activeReport === report : orgMatches && !!activeAccount && ((!!activeAccount.accountId && activeAccount.accountId === report.metadata?.accountId) || (!!activeAccount.email && activeAccount.email === report.metadata?.email)); const availability = resets.redeemableCount === resets.bankedCount ? "" : ` · ${resets.redeemableCount} usable now`; resetAccountLines.push( ` • ${label}: ${resets.bankedCount} saved reset${resets.bankedCount === 1 ? "" : "s"}${availability}${isActive ? " (active)" : ""}`, ); if (resets.soonestExpiry) { const expiryMs = Date.parse(resets.soonestExpiry); const remaining = expiryMs - nowMs; const expiryDate = resets.soonestExpiry.slice(0, 10); if (remaining > 0) { resetAccountLines.push(` soonest expires in ${formatDuration(remaining)} (${expiryDate})`); } else { resetAccountLines.push(` expired (${expiryDate})`); } } if (resets.redeemableCount === 0 && resets.unavailableReason) { const reason = sanitizeText(resets.unavailableReason.replace(/[\r\n\t]+/g, " ")); resetAccountLines.push(` unavailable: ${reason}`); } } if (resetAccountLines.length > 0) { lines.push( ` ${uiTheme.fg("accent", "Saved rate-limit resets")} ${uiTheme.fg("dim", "(/usage reset to spend)")}`, ); for (const line of resetAccountLines) lines.push(uiTheme.fg("dim", line)); } // Order account columns ONCE per provider (worst-first), then apply that // same order to every window group. Sorting each group independently by // its own used fraction (issue #6067) desynchronized the columns: an // account exhausted on its 5h window but light on the weekly window would // land in different column positions on each row, so the positional // `account N` labels denoted different credentials per row and an // exhausted limit appeared under a sibling that still had quota. const accountRank = new Map(); providerReports.forEach((report, position) => { const worst = report.limits.reduce((max, limit) => { const fraction = resolveUsedFraction(limit) ?? -1; return fraction > max ? fraction : max; }, -1); // Encode worst-first primary key with the stable position as tiebreak // so accounts tied on pressure keep their discovery order. accountRank.set(report, -worst * 1000 + position); }); const renderableGroups = Array.from(limitGroups.values()).map(group => { const entries = group.limits.map((limit, index) => ({ limit, report: group.reports[index], index, })); entries.sort((a, b) => { const aRank = accountRank.get(a.report) ?? a.index; const bRank = accountRank.get(b.report) ?? b.index; if (aRank !== bRank) return aRank - bRank; return a.index - b.index; }); const sortedLimits = entries.map(entry => entry.limit); const sortedReports = entries.map(entry => entry.report); return { group, sortedLimits, sortedReports, amountText: formatAggregateAmount(sortedLimits) }; }); const sectionCount = renderableGroups.reduce((max, g) => Math.max(max, g.sortedLimits.length), 0); const sectionTrailing = renderableGroups.reduce((max, g) => Math.max(max, visibleWidth(g.amountText)), 0); const sectionColumnWidth = resolveColumnWidth(sectionCount, availableWidth, sectionTrailing); const sectionBarWidth = Math.min(sectionColumnWidth, BAR_WIDTH_MAX); for (const { group, sortedLimits, sortedReports, amountText } of renderableGroups) { 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 = formatAccountHeaderRow( sortedLimits, sortedReports, providerReports, nowMs, sectionColumnWidth, uiTheme, activeAccount, ); lines.push(` ${accountLabels.join(" ")}`.trimEnd()); const bars = sortedLimits.map(limit => padColumn(renderUsageBar(limit, uiTheme, sectionBarWidth), sectionColumnWidth), ); lines.push(` ${bars.join(" ")} ${amountText}`.trimEnd()); const resetText = sortedLimits.length <= 1 ? resolveResetRange(sortedLimits, nowMs) : null; if (resetText) { lines.push(` ${uiTheme.fg("dim", resetText)}`.trimEnd()); } const notes = [...new Set(sortedLimits.flatMap(limit => limit.notes ?? []))]; if (notes.length > 0) { lines.push( ` ${uiTheme.fg("dim", replaceTabs(truncateToWidth(sanitizeText(notes.map(n => n.replace(/[\r\n]+/g, " ")).join(" • ")), 110)))}`.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, providerReports, 0); const tier = report.provider === "openai-codex" ? undefined : report.metadata?.planType; const tierSuffix = typeof tier === "string" && tier ? ` ${uiTheme.fg("dim", `(${tier})`)}` : ""; const daybreakSuffix = report.metadata?.daybreak === true ? uiTheme.fg("success", " daybreak") : ""; lines.push( `${uiTheme.fg("success", uiTheme.status.success)} ${label}${daybreakSuffix}${tierSuffix} ${uiTheme.fg("dim", "-- no limits")}`, ); } // No per-provider footer; global header shows last check. } return lines.join("\n"); }