mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Overlays ignored the blocked option, so an extension question shown as an overlay (for example pi-ask-user's default mode) left pi reported as working. Each opted-in overlay now registers its own program status source from when it is shown until done(), also while hidden with setHidden(). closes #11816
7232 lines
251 KiB
TypeScript
7232 lines
251 KiB
TypeScript
/**
|
|
* Interactive mode for the coding agent.
|
|
* Handles TUI rendering and user interaction, delegating business logic to AgentSession.
|
|
*/
|
|
|
|
import * as crypto from "node:crypto";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
|
|
import {
|
|
type AssistantMessage,
|
|
type ImageContent,
|
|
isRetryableAssistantError,
|
|
type Message,
|
|
type Model,
|
|
type Usage,
|
|
} from "@earendil-works/pi-ai/compat";
|
|
import type {
|
|
AutocompleteItem,
|
|
AutocompleteProvider,
|
|
EditorComponent,
|
|
Keybinding,
|
|
KeyId,
|
|
MarkdownTheme,
|
|
OverlayHandle,
|
|
OverlayOptions,
|
|
SlashCommand,
|
|
TuiMainScreenRenderState,
|
|
} from "@earendil-works/pi-tui";
|
|
import * as TuiLayouts from "@earendil-works/pi-tui";
|
|
import {
|
|
CombinedAutocompleteProvider,
|
|
type Component,
|
|
Container,
|
|
type DefaultTextStyle,
|
|
fuzzyFilter,
|
|
getCapabilities,
|
|
hyperlink,
|
|
Markdown,
|
|
matchesKey,
|
|
Spacer,
|
|
setCapabilityOverrides,
|
|
setKeybindings,
|
|
type Terminal,
|
|
Text,
|
|
TruncatedText,
|
|
type TUI,
|
|
TuiAltScreen,
|
|
TuiMainScreen,
|
|
type TuiMouseEvent,
|
|
type TuiMouseEventResult,
|
|
visibleWidth,
|
|
} from "@earendil-works/pi-tui";
|
|
import chalk from "chalk";
|
|
import { spawn } from "child_process";
|
|
import {
|
|
APP_NAME,
|
|
APP_TITLE,
|
|
CONFIG_DIR_NAME,
|
|
detectInstallChange,
|
|
getAgentDir,
|
|
getAuthPath,
|
|
getDebugLogPath,
|
|
getDocsPath,
|
|
VERSION,
|
|
} from "../../config.ts";
|
|
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts";
|
|
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts";
|
|
import type { AgentSessionRuntimeDiagnostic } from "../../core/agent-session-services.ts";
|
|
import {
|
|
CACHE_TTL_MS,
|
|
type CacheMiss,
|
|
collectCacheMisses,
|
|
computeCacheWaste,
|
|
detectCacheMiss,
|
|
} from "../../core/cache-stats.ts";
|
|
import { formatCacheWarmingStatus, formatCacheWarmingUsage } from "../../core/cache-warmer.ts";
|
|
import { findExtensionStackMatches, recordCrash, takeUnnotifiedCrash } from "../../core/crash-log.ts";
|
|
import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts";
|
|
import type {
|
|
AutocompleteProviderFactory,
|
|
EditorFactory,
|
|
ExtensionCommandContext,
|
|
ExtensionContext,
|
|
ExtensionRunner,
|
|
ExtensionUIContext,
|
|
ExtensionUIDialogOptions,
|
|
ExtensionWidgetOptions,
|
|
MarkdownTransformer,
|
|
ProjectTrustContext,
|
|
UserBashEventResult,
|
|
WorkingIndicatorOptions,
|
|
} from "../../core/extensions/index.ts";
|
|
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
|
|
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
|
|
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
|
|
import { createCompactionSummaryMessage, createCustomMessage } from "../../core/messages.ts";
|
|
import {
|
|
defaultModelPerProvider,
|
|
findExactModelReferenceMatch,
|
|
resolveModelScopeFromModels,
|
|
} from "../../core/model-resolver.ts";
|
|
import { CredentialSynchronizationError } from "../../core/model-runtime.ts";
|
|
import { DefaultPackageManager } from "../../core/package-manager.ts";
|
|
import { findMatchingProviders } from "../../core/provider-matching.ts";
|
|
import { RADIUS_PROVIDER_ID } from "../../core/radius.ts";
|
|
import { planRadiusMcpServer } from "../../core/radius-mcp.ts";
|
|
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
|
|
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
|
|
import {
|
|
type SessionEntry,
|
|
SessionManager,
|
|
sessionEntryToContextMessages,
|
|
type UsageEntry,
|
|
} from "../../core/session-manager.ts";
|
|
import type { FullscreenExitOutput, TuiMode } from "../../core/settings-manager.ts";
|
|
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
|
|
import { getSourcePackageKey, type SourceInfo } from "../../core/source-info.ts";
|
|
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
|
import { withBuiltInRenderers } from "../../core/tools/renderers/index.ts";
|
|
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
|
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
|
import { getUsageCostBreakdown } from "../../core/usage-totals.ts";
|
|
import { addMcpServerConfig } from "../../extensions/mcp/config.ts";
|
|
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
|
|
import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts";
|
|
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
|
import { parseGitUrl } from "../../utils/git.ts";
|
|
import { ensurePngTranscoder } from "../../utils/image-convert.ts";
|
|
import { getCwdRelativePath, shortenHomePath } from "../../utils/paths.ts";
|
|
import { getPiUserAgent } from "../../utils/pi-user-agent.ts";
|
|
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
|
|
import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts";
|
|
import { stripBom } from "../../utils/text.ts";
|
|
import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts";
|
|
import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts";
|
|
import { reportBug } from "./bug-report.ts";
|
|
import { createChatViewport } from "./chat-viewport.ts";
|
|
import { ArminComponent } from "./components/armin.ts";
|
|
import { AssistantMessageComponent } from "./components/assistant-message.ts";
|
|
import { BashExecutionComponent } from "./components/bash-execution.ts";
|
|
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";
|
|
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";
|
|
import { CustomEditor } from "./components/custom-editor.ts";
|
|
import { CustomEntryComponent } from "./components/custom-entry.ts";
|
|
import { CustomMessageComponent } from "./components/custom-message.ts";
|
|
import { DynamicBorder } from "./components/dynamic-border.ts";
|
|
import { EarendilAnnouncementComponent } from "./components/earendil-announcement.ts";
|
|
import { playArmin3d, playPiLogo3d } from "./components/easter-egg-3d.lazy.ts";
|
|
import { ExtensionEditorComponent } from "./components/extension-editor.ts";
|
|
import { ExtensionInputComponent } from "./components/extension-input.ts";
|
|
import { ExtensionSelectorComponent } from "./components/extension-selector.ts";
|
|
import { FooterComponent, formatTokens } from "./components/footer.ts";
|
|
import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts";
|
|
import { LoginDialogComponent } from "./components/login-dialog.ts";
|
|
import { createMarkdownTransform } from "./components/markdown-transform.ts";
|
|
import { createMermaidMarkdownTransformer } from "./components/mermaid.ts";
|
|
import { ModelSelectorComponent } from "./components/model-selector.ts";
|
|
import {
|
|
type AuthSelectorProvider,
|
|
formatAuthSelectorProviderStatus,
|
|
formatAuthSelectorProviderType,
|
|
OAuthSelectorComponent,
|
|
} from "./components/oauth-selector.ts";
|
|
import { piLogoLines, piWordmark, supportsPiLogo } from "./components/pi-logo.ts";
|
|
import { createLoginMenuSelector } from "./components/radius-login-selector.ts";
|
|
import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts";
|
|
import { SessionSelectorComponent } from "./components/session-selector.ts";
|
|
import { SettingsSelectorComponent } from "./components/settings-selector.ts";
|
|
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts";
|
|
import {
|
|
BranchSummaryStatusIndicator,
|
|
CompactionStatusIndicator,
|
|
IdleStatus,
|
|
RetryStatusIndicator,
|
|
type StatusIndicator,
|
|
WorkingStatusIndicator,
|
|
} from "./components/status-indicator.ts";
|
|
import { ThemedText } from "./components/themed-text.ts";
|
|
import { ThinkingSelectorComponent } from "./components/thinking-selector.ts";
|
|
import { ToolExecutionComponent } from "./components/tool-execution.ts";
|
|
import { TreeSelectorComponent } from "./components/tree-selector.ts";
|
|
import { TrustSelectorComponent } from "./components/trust-selector.ts";
|
|
import { UserMessageComponent } from "./components/user-message.ts";
|
|
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
|
|
import { editInExternalEditor } from "./external-editor.ts";
|
|
import { refreshModelCatalogs } from "./model-catalog-refresh.ts";
|
|
import { getModelSearchText } from "./model-search.ts";
|
|
import { type BlockedStatus, ProgramStatusReporter } from "./program-status-reporter.ts";
|
|
import { shareSession } from "./session-share.ts";
|
|
import {
|
|
getAvailableThemes,
|
|
getAvailableThemesWithPaths,
|
|
getEditorTheme,
|
|
getMarkdownTheme,
|
|
getThemeByName,
|
|
onThemeChange,
|
|
SYSTEM_THEME_NAME,
|
|
setRegisteredThemes,
|
|
stopThemeWatcher,
|
|
Theme,
|
|
type ThemeColor,
|
|
theme,
|
|
} from "./theme/theme.ts";
|
|
import { InteractiveThemeController } from "./theme/theme-controller.ts";
|
|
import { createInteractiveTui, createInteractiveTuiReference } from "./tui-renderer.ts";
|
|
|
|
export { createInteractiveTui, createInteractiveTuiReference } from "./tui-renderer.ts";
|
|
|
|
/** Interface for components that can be expanded/collapsed */
|
|
interface Expandable {
|
|
setExpanded(expanded: boolean): void;
|
|
}
|
|
|
|
interface ActiveEditor extends EditorComponent {
|
|
getCursor?(): { line: number; col: number };
|
|
}
|
|
|
|
interface WorkingStatusEditor extends EditorComponent {
|
|
readonly embedWorkingStatus: boolean;
|
|
setWorkingStatusIndicator(indicator: StatusIndicator | undefined): void;
|
|
}
|
|
|
|
function isEmbeddedStatus(editor: EditorComponent): editor is WorkingStatusEditor {
|
|
return (
|
|
"embedWorkingStatus" in editor &&
|
|
editor.embedWorkingStatus === true &&
|
|
"setWorkingStatusIndicator" in editor &&
|
|
typeof editor.setWorkingStatusIndicator === "function"
|
|
);
|
|
}
|
|
|
|
function isExpandable(obj: unknown): obj is Expandable {
|
|
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
|
|
}
|
|
|
|
class ExpandableText extends ThemedText implements Expandable {
|
|
private readonly state: { expanded: boolean };
|
|
|
|
constructor(
|
|
getCollapsedText: () => string,
|
|
getExpandedText: () => string,
|
|
expanded = false,
|
|
paddingX = 0,
|
|
paddingY = 0,
|
|
) {
|
|
const state = { expanded };
|
|
super(() => (state.expanded ? getExpandedText() : getCollapsedText()), paddingX, paddingY);
|
|
this.state = state;
|
|
}
|
|
|
|
setExpanded(expanded: boolean): void {
|
|
this.state.expanded = expanded;
|
|
this.invalidate();
|
|
}
|
|
}
|
|
|
|
/** The built-in header. Clicking its logo (the first two lines, after one column of padding) plays an easter egg. */
|
|
class BuiltInHeader extends ExpandableText {
|
|
onLogoClick: ((column: number, row: number) => void) | undefined;
|
|
|
|
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
if (event.type !== "click" || event.y > 1 || event.x < 1 || event.x > 4 || !this.onLogoClick) return undefined;
|
|
this.onLogoClick(event.screenX - event.x + 1, event.screenY - event.y);
|
|
return { handled: true };
|
|
}
|
|
}
|
|
|
|
type ExtensionDialogComponent = Component & { dispose?(): void };
|
|
|
|
type CompactionQueuedMessage = {
|
|
text: string;
|
|
mode: "steer" | "followUp";
|
|
};
|
|
|
|
type CompactionCostNotice = {
|
|
type: "compaction_cost";
|
|
kind: "compaction" | "branch_summary";
|
|
usage: Usage;
|
|
};
|
|
|
|
type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" | "usage" }> | CompactionCostNotice;
|
|
|
|
function isCustomSessionEntry(item: RenderSessionItem): item is Extract<SessionEntry, { type: "custom" }> {
|
|
return "type" in item && item.type === "custom";
|
|
}
|
|
|
|
function isCompactionCostNotice(item: RenderSessionItem): item is CompactionCostNotice {
|
|
return "type" in item && item.type === "compaction_cost";
|
|
}
|
|
|
|
function isUsageSessionEntry(item: RenderSessionItem): item is Extract<SessionEntry, { type: "usage" }> {
|
|
return "type" in item && item.type === "usage";
|
|
}
|
|
|
|
// EIO: tty reads/ioctls from an orphaned background process group, or writes after hangup.
|
|
// ENOTTY: the tty was revoked (macOS) and stdin is no longer a terminal.
|
|
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN", "ENOTTY"]);
|
|
|
|
function isDeadTerminalError(error: unknown): boolean {
|
|
if (!error || typeof error !== "object" || !("code" in error)) {
|
|
return false;
|
|
}
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
|
|
}
|
|
|
|
export function formatCrashExtensionHint(extensionMatches: readonly string[] | undefined): string | undefined {
|
|
const matches = Array.isArray(extensionMatches)
|
|
? extensionMatches.filter((match): match is string => typeof match === "string" && match.length > 0)
|
|
: [];
|
|
if (matches.length === 0) return undefined;
|
|
const quoted = matches.map((match) => `\`${match}\``);
|
|
const labels =
|
|
quoted.length === 1
|
|
? quoted[0]
|
|
: quoted.length === 2
|
|
? quoted.join(" and ")
|
|
: `${quoted.slice(0, -1).join(", ")}, and ${quoted[quoted.length - 1]}`;
|
|
const noun = matches.length === 1 ? "extension" : "extensions";
|
|
const pronoun = matches.length === 1 ? "it" : "them";
|
|
return `A stack frame came from loaded ${noun} ${labels}, which may be involved. Try disabling ${pronoun} with \`${APP_NAME} config\`, or run \`${APP_NAME} -ne\` to confirm.`;
|
|
}
|
|
|
|
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
|
|
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
|
|
|
|
function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {
|
|
return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
|
|
}
|
|
|
|
function isUnknownModel(model: Model<any> | undefined): boolean {
|
|
return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";
|
|
}
|
|
|
|
function quoteIfNeeded(value: string): string {
|
|
if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) {
|
|
return value;
|
|
}
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
export function formatResumeCommand(sessionManager: SessionManager): string | undefined {
|
|
if (!process.stdout.isTTY) return undefined;
|
|
if (!sessionManager.isPersisted()) return undefined;
|
|
|
|
const sessionFile = sessionManager.getSessionFile();
|
|
if (!sessionFile || !fs.existsSync(sessionFile)) return undefined;
|
|
|
|
const args = [APP_NAME];
|
|
if (!sessionManager.usesDefaultSessionDir()) {
|
|
args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir()));
|
|
}
|
|
args.push("--session", sessionManager.getSessionId());
|
|
return args.join(" ");
|
|
}
|
|
|
|
function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider {
|
|
return providerId in defaultModelPerProvider;
|
|
}
|
|
|
|
function llamaCppPostLoginGuidance(actionLabel: string, loadedModelCount: number): string {
|
|
return loadedModelCount === 0
|
|
? `${actionLabel}. No llama.cpp models are loaded. Use /llama to load a model, then /model to select it.`
|
|
: `${actionLabel}. Use /model to select a loaded llama.cpp model, or /llama to manage models.`;
|
|
}
|
|
|
|
type LoginProviderCompletionOption = {
|
|
id: string;
|
|
name: string;
|
|
authTypes: AuthSelectorProvider["authType"][];
|
|
subscription?: boolean;
|
|
};
|
|
|
|
const RADIUS_LOGIN_INTRO = "Radius is a service crafted for Pi by the builders of Pi, Earendil Works";
|
|
|
|
const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record<AuthSelectorProvider["authType"], number>;
|
|
|
|
function createFuzzyAutocompleteItems<T>(
|
|
items: T[],
|
|
prefix: string,
|
|
getSearchText: (item: T) => string,
|
|
toAutocompleteItem: (item: T) => AutocompleteItem,
|
|
): AutocompleteItem[] | null {
|
|
const filtered = fuzzyFilter(items, prefix, getSearchText);
|
|
if (filtered.length === 0) return null;
|
|
return filtered.map(toAutocompleteItem);
|
|
}
|
|
|
|
function getLoginProviderCompletionOptions(
|
|
providerOptions: readonly AuthSelectorProvider[],
|
|
): LoginProviderCompletionOption[] {
|
|
const byId = new Map<string, LoginProviderCompletionOption>();
|
|
for (const provider of providerOptions) {
|
|
const existing = byId.get(provider.id);
|
|
if (existing) {
|
|
if (!existing.authTypes.includes(provider.authType)) {
|
|
existing.authTypes.push(provider.authType);
|
|
existing.authTypes.sort((a, b) => AUTH_TYPE_ORDER[a] - AUTH_TYPE_ORDER[b]);
|
|
}
|
|
continue;
|
|
}
|
|
byId.set(provider.id, {
|
|
id: provider.id,
|
|
name: provider.name,
|
|
authTypes: [provider.authType],
|
|
subscription: provider.subscription,
|
|
});
|
|
}
|
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
function getLoginProviderSearchText(provider: LoginProviderCompletionOption): string {
|
|
const authTypes = provider.authTypes
|
|
.map((authType) => `${authType} ${formatAuthSelectorProviderType(authType, provider.subscription)}`)
|
|
.join(" ");
|
|
return `${provider.id} ${provider.name} ${authTypes}`;
|
|
}
|
|
|
|
function formatLoginProviderCompletionDescription(provider: LoginProviderCompletionOption): string {
|
|
const authTypes = provider.authTypes
|
|
.map((authType) => formatAuthSelectorProviderType(authType, provider.subscription))
|
|
.join("/");
|
|
return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`;
|
|
}
|
|
|
|
/**
|
|
* Options for InteractiveMode initialization.
|
|
*/
|
|
export interface InteractiveModeOptions {
|
|
/** Providers that were migrated to auth.json (shows warning) */
|
|
migratedProviders?: string[];
|
|
/** Diagnostics collected before the interactive TUI was initialized. */
|
|
startupDiagnostics?: AgentSessionRuntimeDiagnostic[];
|
|
/** Warning message if session model couldn't be restored */
|
|
modelFallbackMessage?: string;
|
|
/** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */
|
|
autoTrustOnReloadCwd?: string;
|
|
/** Initial message to send on startup (can include @file content) */
|
|
initialMessage?: string;
|
|
/** Images to attach to the initial message */
|
|
initialImages?: ImageContent[];
|
|
/** Additional messages to send after the initial message */
|
|
initialMessages?: string[];
|
|
/** Force verbose startup (overrides quietStartup setting) */
|
|
verbose?: boolean;
|
|
/** TUI layout mode. */
|
|
tuiMode?: TuiMode;
|
|
/** Initial interactive theme setting for this invocation. */
|
|
initialThemeSetting?: string;
|
|
/** Terminal implementation. Defaults to the current process terminal. */
|
|
terminal?: Terminal;
|
|
}
|
|
|
|
interface ExtensionChangelogUpdate {
|
|
name: string;
|
|
version: string;
|
|
markdown: string;
|
|
}
|
|
|
|
export class InteractiveMode {
|
|
private runtimeHost: AgentSessionRuntime;
|
|
private renderer: TuiMainScreen | TuiAltScreen;
|
|
private ui: TUI;
|
|
private mainScreenRenderState: TuiMainScreenRenderState | undefined;
|
|
private loadedResourcesContainer: Container;
|
|
private chatContainer: Container;
|
|
private documentContainer: Container;
|
|
private transcriptScrollView: TuiLayouts.ScrollView | undefined;
|
|
private fullscreenLayoutRoot: Component | undefined;
|
|
private pendingMessagesContainer: Container;
|
|
private statusContainer: Container;
|
|
private defaultEditor: CustomEditor;
|
|
private editor: ActiveEditor;
|
|
private editorComponentFactory: EditorFactory | undefined;
|
|
private autocompleteProvider: AutocompleteProvider | undefined;
|
|
private autocompleteProviderWrappers: AutocompleteProviderFactory[] = [];
|
|
private fdPath: string | undefined;
|
|
private editorContainer: Container;
|
|
private activeSelectorToken?: object;
|
|
private activeSelectorDispose?: () => void;
|
|
private footer: FooterComponent;
|
|
private footerContainer: Container;
|
|
private footerDataProvider: FooterDataProvider;
|
|
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
|
|
private keybindings: KeybindingsManager;
|
|
private version: string;
|
|
private isInitialized = false;
|
|
private onInputCallback?: (text: string) => void;
|
|
private pendingUserInputs: string[] = [];
|
|
private activeStatusIndicator: StatusIndicator | undefined = undefined;
|
|
private activeWorkingIndicatorEmbedded = false;
|
|
private readonly idleStatus = new IdleStatus();
|
|
private workingMessage: string | undefined = undefined;
|
|
private workingVisible = true;
|
|
private workingIndicatorOptions: WorkingIndicatorOptions | undefined = undefined;
|
|
private readonly defaultWorkingMessage = "Working";
|
|
private readonly defaultHiddenThinkingLabel = "Thinking...";
|
|
private hiddenThinkingLabel = this.defaultHiddenThinkingLabel;
|
|
|
|
private lastSigintTime = 0;
|
|
private lastEscapeTime = 0;
|
|
private changelogMarkdown: string | undefined = undefined;
|
|
private extensionChangelogUpdates: ExtensionChangelogUpdate[] = [];
|
|
private startupNoticesShown = false;
|
|
/** Last cache-warming notice, extended in place while it is the last chat line. */
|
|
private cacheWarmingNotice: { component: ThemedText; total: { cost: number }; note: string | undefined } | undefined;
|
|
private anthropicSubscriptionWarningShown = false;
|
|
|
|
// Status line tracking (for mutating immediately-sequential status updates)
|
|
private lastStatusSpacer: Spacer | undefined = undefined;
|
|
private lastStatusText: ThemedText | undefined = undefined;
|
|
private lastStatusMessage = "";
|
|
private managedToolStatusStarted = false;
|
|
|
|
// Streaming message tracking
|
|
private streamingComponent: AssistantMessageComponent | undefined = undefined;
|
|
private readonly entriesRenderedByBoundaryCompaction = new Set<string>();
|
|
private streamingMessage: AssistantMessage | undefined = undefined;
|
|
|
|
// Tool execution tracking: toolCallId -> component
|
|
private pendingTools = new Map<string, ToolExecutionComponent>();
|
|
|
|
// Tool output expansion state
|
|
private toolOutputExpanded = false;
|
|
|
|
// Thinking block visibility state
|
|
private hideThinkingBlock = false;
|
|
private outputPad = 1;
|
|
private readonly mermaidMarkdownTransformer: MarkdownTransformer = createMermaidMarkdownTransformer({
|
|
getMode: () => this.settingsManager.getMermaidRenderingMode(),
|
|
theme,
|
|
});
|
|
|
|
// Skill commands: command name -> skill file path
|
|
private skillCommands = new Map<string, string>();
|
|
|
|
// Agent subscription unsubscribe function
|
|
private unsubscribe?: () => void;
|
|
private signalCleanupHandlers: Array<() => void> = [];
|
|
|
|
// Track if editor is in bash mode (text starts with !)
|
|
private isBashMode = false;
|
|
|
|
// Track current bash execution component
|
|
private bashComponent: BashExecutionComponent | undefined = undefined;
|
|
|
|
// Track pending bash components (shown in pending area, moved to chat on submit)
|
|
private pendingBashComponents: BashExecutionComponent[] = [];
|
|
|
|
// Auto-compaction state
|
|
private autoCompactionEscapeHandler?: () => void;
|
|
|
|
// Auto-retry state
|
|
private retryEscapeHandler?: () => void;
|
|
|
|
// Messages queued while compaction is running
|
|
private compactionQueuedMessages: CompactionQueuedMessage[] = [];
|
|
|
|
// Shutdown state
|
|
private shutdownRequested = false;
|
|
|
|
/** Reports working, blocked, done, and error states to terminals that support OSC 7501. */
|
|
private readonly programStatus = new ProgramStatusReporter(
|
|
() => this.ui.terminal,
|
|
() => this.sessionManager.getSessionName(),
|
|
);
|
|
|
|
/** The `/bug` hint is shown at most once per session so error output stays readable. */
|
|
private bugReportHintShown = false;
|
|
private installChangeWarningShown = false;
|
|
|
|
// Extension UI state
|
|
// Extension dialogs shown in the editor slot (select, confirm, input, editor, inline custom).
|
|
// The last entry is visible; see pushExtensionDialog().
|
|
private extensionDialogs: Array<{ component: ExtensionDialogComponent; blocked: BlockedStatus | undefined }> = [];
|
|
/** Numbers the program status sources of blocking overlays, so closing one keeps the others. */
|
|
private nextBlockingOverlayId = 0;
|
|
private extensionTerminalInputSubscriptions = new Set<{
|
|
handler: (data: string) => { consume?: boolean; data?: string } | undefined;
|
|
unsubscribe: () => void;
|
|
}>();
|
|
|
|
// Extension widgets (components rendered above/below the editor)
|
|
private extensionWidgetsAbove = new Map<string, Component & { dispose?(): void }>();
|
|
private extensionWidgetsBelow = new Map<string, Component & { dispose?(): void }>();
|
|
private widgetContainerAbove!: Container;
|
|
private widgetContainerBelow!: Container;
|
|
|
|
// Custom footer from extension (undefined = use built-in footer)
|
|
private customFooter: (Component & { dispose?(): void }) | undefined = undefined;
|
|
|
|
// Header container that holds the built-in or custom header
|
|
private headerContainer: Container;
|
|
|
|
// Built-in header (logo + keybinding hints + changelog)
|
|
private builtInHeader: Component | undefined = undefined;
|
|
|
|
// Custom header from extension (undefined = use built-in header)
|
|
private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
|
|
|
|
private options: InteractiveModeOptions;
|
|
private readonly onRightClickPaste = (): void => {
|
|
void this.handleRightClickPaste();
|
|
};
|
|
private autoTrustOnReloadCwd: string | undefined;
|
|
private themeController: InteractiveThemeController;
|
|
|
|
// Convenience accessors
|
|
private get session(): AgentSession {
|
|
return this.runtimeHost.session;
|
|
}
|
|
private get agent() {
|
|
return this.session.agent;
|
|
}
|
|
private get sessionManager() {
|
|
return this.session.sessionManager;
|
|
}
|
|
private get settingsManager() {
|
|
return this.session.settingsManager;
|
|
}
|
|
|
|
constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) {
|
|
this.runtimeHost = runtimeHost;
|
|
setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides());
|
|
const tuiMode = options.tuiMode ?? this.settingsManager.getTuiMode();
|
|
this.options = { ...options, tuiMode };
|
|
this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd;
|
|
this.runtimeHost.setBeforeSessionInvalidate(() => {
|
|
this.resetExtensionUI();
|
|
});
|
|
this.runtimeHost.setRebindSession(async () => {
|
|
await this.rebindCurrentSession({ renderBeforeBind: true });
|
|
this.themeController.applyFromSettings();
|
|
});
|
|
this.version = VERSION;
|
|
this.renderer = createInteractiveTui({
|
|
tuiMode,
|
|
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
|
|
logDirectory: getAgentDir(),
|
|
terminal: options.terminal,
|
|
onRightClickPaste: this.onRightClickPaste,
|
|
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
|
|
fullscreenWheelScrollLines: this.settingsManager.getFullscreenWheelScrollLines(),
|
|
});
|
|
this.ui = createInteractiveTuiReference(() => this.renderer);
|
|
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
|
|
this.headerContainer = new Container();
|
|
this.loadedResourcesContainer = new Container();
|
|
this.chatContainer = new Container();
|
|
this.documentContainer = new Container();
|
|
this.documentContainer.addChild(this.headerContainer);
|
|
this.documentContainer.addChild(this.loadedResourcesContainer);
|
|
this.documentContainer.addChild(this.chatContainer);
|
|
this.pendingMessagesContainer = new Container();
|
|
this.statusContainer = new Container();
|
|
this.widgetContainerAbove = new Container();
|
|
this.widgetContainerBelow = new Container();
|
|
this.keybindings = KeybindingsManager.create();
|
|
setKeybindings(this.keybindings);
|
|
const editorPaddingX = this.settingsManager.getEditorPaddingX();
|
|
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
|
|
this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, {
|
|
paddingX: editorPaddingX,
|
|
autocompleteMaxVisible,
|
|
embedWorkingStatus: true,
|
|
});
|
|
this.editor = this.defaultEditor;
|
|
this.editorContainer = new Container();
|
|
this.editorContainer.addChild(this.editor as Component);
|
|
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
|
|
this.footer = new FooterComponent(this.session, this.footerDataProvider);
|
|
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
|
|
this.footerContainer = new Container();
|
|
this.footerContainer.addChild(this.footer);
|
|
|
|
// Load hide thinking block setting
|
|
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
|
this.outputPad = this.settingsManager.getOutputPad();
|
|
|
|
// Register themes from resource loader and initialize
|
|
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
|
this.themeController = new InteractiveThemeController(this.ui, {
|
|
getSettingsManager: () => this.settingsManager,
|
|
showError: (message) => this.showError(message),
|
|
onChanged: () => this.updateEditorBorderColor(),
|
|
initialThemeSetting: options.initialThemeSetting,
|
|
});
|
|
}
|
|
|
|
private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
|
|
// Built-in extension commands are untagged, like built-in commands.
|
|
if (!sourceInfo || sourceInfo.source === "builtin") {
|
|
return undefined;
|
|
}
|
|
|
|
const scopePrefix = sourceInfo.scope === "user" ? "u" : sourceInfo.scope === "project" ? "p" : "t";
|
|
const source = sourceInfo.source.trim();
|
|
|
|
if (source === "auto" || source === "local" || source === "cli") {
|
|
return scopePrefix;
|
|
}
|
|
|
|
if (source.startsWith("npm:")) {
|
|
return `${scopePrefix}:${source}`;
|
|
}
|
|
|
|
const gitSource = parseGitUrl(source);
|
|
if (gitSource) {
|
|
const ref = gitSource.ref ? `@${gitSource.ref}` : "";
|
|
return `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`;
|
|
}
|
|
|
|
return scopePrefix;
|
|
}
|
|
|
|
private prefixAutocompleteDescription(description: string | undefined, sourceInfo?: SourceInfo): string | undefined {
|
|
const sourceTag = this.getAutocompleteSourceTag(sourceInfo);
|
|
if (!sourceTag) {
|
|
return description;
|
|
}
|
|
return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`;
|
|
}
|
|
|
|
private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] {
|
|
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
|
|
return extensionRunner
|
|
.getRegisteredCommands()
|
|
.filter((command) => builtinNames.has(command.name))
|
|
.map((command) => ({
|
|
type: "warning" as const,
|
|
message:
|
|
command.invocationName === command.name
|
|
? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`
|
|
: `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`,
|
|
path: command.sourceInfo.path,
|
|
}));
|
|
}
|
|
|
|
private createBaseAutocompleteProvider(): AutocompleteProvider {
|
|
// Define commands for autocomplete
|
|
const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({
|
|
name: command.name,
|
|
description: command.description,
|
|
...(command.argumentHint && { argumentHint: command.argumentHint }),
|
|
}));
|
|
|
|
const modelCommand = slashCommands.find((command) => command.name === "model");
|
|
if (modelCommand) {
|
|
modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
|
|
const models =
|
|
this.session.scopedModels.length > 0
|
|
? this.session.scopedModels.map((s) => s.model)
|
|
: this.session.modelRuntime.getAvailableSnapshot();
|
|
|
|
if (models.length === 0) return null;
|
|
|
|
// Create items with provider/id format
|
|
const items = models.map((m) => ({
|
|
id: m.id,
|
|
provider: m.provider,
|
|
name: m.name,
|
|
label: `${m.provider}/${m.id}`,
|
|
}));
|
|
|
|
return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({
|
|
value: item.label,
|
|
label: item.id,
|
|
description: item.provider,
|
|
}));
|
|
};
|
|
}
|
|
|
|
const thinkingCommand = slashCommands.find((command) => command.name === "thinking");
|
|
if (thinkingCommand) {
|
|
thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
|
|
return createFuzzyAutocompleteItems(
|
|
this.session.getAvailableThinkingLevels(),
|
|
prefix,
|
|
(level) => level,
|
|
(level) => ({
|
|
value: level,
|
|
label: level,
|
|
}),
|
|
);
|
|
};
|
|
}
|
|
|
|
const loginCommand = slashCommands.find((command) => command.name === "login");
|
|
if (loginCommand) {
|
|
loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
|
|
const providers = getLoginProviderCompletionOptions(this.getLoginProviderOptions());
|
|
return createFuzzyAutocompleteItems(providers, prefix, getLoginProviderSearchText, (provider) => ({
|
|
value: provider.id,
|
|
label: provider.id,
|
|
description: formatLoginProviderCompletionDescription(provider),
|
|
}));
|
|
};
|
|
}
|
|
|
|
// Convert prompt templates to SlashCommand format for autocomplete
|
|
const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({
|
|
name: cmd.name,
|
|
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
|
|
...(cmd.argumentHint && { argumentHint: cmd.argumentHint }),
|
|
}));
|
|
|
|
// Convert extension commands to SlashCommand format
|
|
const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
|
|
const extensionCommands: SlashCommand[] = this.session.extensionRunner
|
|
.getRegisteredCommands()
|
|
.filter((cmd) => !builtinCommandNames.has(cmd.name))
|
|
.map((cmd) => ({
|
|
name: cmd.invocationName,
|
|
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
|
|
getArgumentCompletions: cmd.getArgumentCompletions,
|
|
}));
|
|
|
|
// Build skill commands from session.skills (if enabled)
|
|
this.skillCommands.clear();
|
|
const skillCommandList: SlashCommand[] = [];
|
|
if (this.settingsManager.getEnableSkillCommands()) {
|
|
for (const skill of this.session.resourceLoader.getSkills().skills) {
|
|
const commandName = `skill:${skill.name}`;
|
|
this.skillCommands.set(commandName, skill.filePath);
|
|
skillCommandList.push({
|
|
name: commandName,
|
|
description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo),
|
|
});
|
|
}
|
|
}
|
|
|
|
return new CombinedAutocompleteProvider(
|
|
[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],
|
|
this.sessionManager.getCwd(),
|
|
this.fdPath,
|
|
);
|
|
}
|
|
|
|
private setupAutocompleteProvider(): void {
|
|
let provider = this.createBaseAutocompleteProvider();
|
|
const triggerCharacters: string[] = [];
|
|
for (const wrapProvider of this.autocompleteProviderWrappers) {
|
|
provider = wrapProvider(provider);
|
|
triggerCharacters.push(...(provider.triggerCharacters ?? []));
|
|
}
|
|
if (triggerCharacters.length > 0) {
|
|
provider.triggerCharacters = [...new Set(triggerCharacters)];
|
|
}
|
|
|
|
this.autocompleteProvider = provider;
|
|
this.defaultEditor.setAutocompleteProvider(provider);
|
|
if (this.editor !== this.defaultEditor) {
|
|
this.editor.setAutocompleteProvider?.(provider);
|
|
}
|
|
}
|
|
|
|
private showStartupNoticesIfNeeded(): void {
|
|
if (this.startupNoticesShown) {
|
|
return;
|
|
}
|
|
this.startupNoticesShown = true;
|
|
|
|
if (!this.changelogMarkdown && this.extensionChangelogUpdates.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (this.chatContainer.children.length > 0) {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
}
|
|
this.chatContainer.addChild(new DynamicBorder());
|
|
if (this.changelogMarkdown) {
|
|
if (this.settingsManager.getCollapseChangelog()) {
|
|
const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
|
|
const latestVersion = versionMatch ? versionMatch[1] : this.version;
|
|
const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
|
|
this.chatContainer.addChild(new Text(condensedText, 1, 0));
|
|
} else {
|
|
this.chatContainer.addChild(new ThemedText(() => theme.bold(theme.fg("accent", "What's New")), 1, 0));
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(this.createChangelogMarkdown(this.changelogMarkdown.trim(), 1, 0));
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
}
|
|
}
|
|
if (this.extensionChangelogUpdates.length > 0) {
|
|
if (this.changelogMarkdown) {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
}
|
|
if (this.settingsManager.getCollapseChangelog()) {
|
|
const condensedText = this.extensionChangelogUpdates
|
|
.map((update) => `Updated ${update.name} to v${update.version}.`)
|
|
.join("\n");
|
|
this.chatContainer.addChild(new Text(condensedText, 1, 0));
|
|
} else {
|
|
this.chatContainer.addChild(
|
|
new ThemedText(() => theme.bold(theme.fg("accent", "Extension Updates")), 1, 0),
|
|
);
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
const markdown = this.extensionChangelogUpdates.map((update) => update.markdown).join("\n\n");
|
|
this.chatContainer.addChild(this.createChangelogMarkdown(markdown, 1, 0));
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
}
|
|
}
|
|
this.chatContainer.addChild(new DynamicBorder());
|
|
}
|
|
|
|
private mountInteractiveTui(tui: TuiMainScreen | TuiAltScreen, components: readonly Component[]): void {
|
|
for (const component of components) tui.addChild(component);
|
|
if (TuiLayouts.isViewportTUI(tui)) {
|
|
if (!this.fullscreenLayoutRoot) throw new Error("Fullscreen layout is not initialized");
|
|
tui.setLayoutRoot(this.fullscreenLayoutRoot);
|
|
}
|
|
}
|
|
|
|
private stopInteractiveTui(fullscreenExitOutput: FullscreenExitOutput): void {
|
|
if (this.renderer.mode === "fullscreen" && fullscreenExitOutput === "transcript") {
|
|
while (this.renderer.hasOverlayEntries) this.renderer.hideOverlay();
|
|
this.switchTuiMode("regular", false, false);
|
|
this.renderer.renderNow();
|
|
}
|
|
this.ui.stop({ preserveScreen: this.renderer.mode === "fullscreen" });
|
|
}
|
|
|
|
private switchTuiMode(mode: TuiMode, restoreProgress = true, startRenderer = true): boolean {
|
|
const previousUi = this.renderer;
|
|
if (mode === previousUi.mode) return true;
|
|
if (previousUi.hasOverlayEntries) return false;
|
|
|
|
const components = [...previousUi.children];
|
|
const focus = previousUi.getFocusedComponent();
|
|
const terminal = previousUi.terminal;
|
|
const showHardwareCursor = previousUi.getShowHardwareCursor();
|
|
const clearOnShrink = previousUi.getClearOnShrink();
|
|
const onDebug = previousUi.onDebug;
|
|
if (previousUi instanceof TuiMainScreen) {
|
|
this.mainScreenRenderState = previousUi.captureRenderState();
|
|
}
|
|
|
|
previousUi.stop({ preserveScreen: true });
|
|
previousUi.setFocus(null);
|
|
previousUi.clear();
|
|
if (TuiLayouts.isViewportTUI(previousUi)) previousUi.setLayoutRoot(undefined);
|
|
|
|
const nextUi = createInteractiveTui({
|
|
tuiMode: mode,
|
|
showHardwareCursor,
|
|
logDirectory: getAgentDir(),
|
|
terminal,
|
|
onRightClickPaste: this.onRightClickPaste,
|
|
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
|
|
fullscreenWheelScrollLines: this.settingsManager.getFullscreenWheelScrollLines(),
|
|
});
|
|
nextUi.setClearOnShrink(clearOnShrink);
|
|
nextUi.onDebug = onDebug;
|
|
if (nextUi instanceof TuiMainScreen && this.mainScreenRenderState) {
|
|
nextUi.restoreRenderState(this.mainScreenRenderState);
|
|
}
|
|
this.renderer = nextUi;
|
|
this.options.tuiMode = mode;
|
|
this.mountInteractiveTui(nextUi, components);
|
|
nextUi.invalidate();
|
|
nextUi.setFocus(focus);
|
|
if (!startRenderer) return true;
|
|
nextUi.start();
|
|
this.themeController.rebindTui();
|
|
this.rebindExtensionTerminalInputListeners();
|
|
if (
|
|
restoreProgress &&
|
|
this.settingsManager.getShowTerminalProgress() &&
|
|
(this.session.isStreaming || this.session.isCompacting)
|
|
) {
|
|
terminal.setProgress(true);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
if (this.isInitialized) return;
|
|
|
|
this.registerSignalHandlers();
|
|
|
|
// Load changelogs (only show new entries, skip for resumed sessions)
|
|
this.changelogMarkdown = this.getChangelogForDisplay();
|
|
this.extensionChangelogUpdates = this.getExtensionChangelogUpdates();
|
|
|
|
// Fullscreen renders on the alternate screen, so text printed to stdout here would stay in the shell
|
|
// after exit. Fullscreen shows the notice in the header instead (#11508).
|
|
if (this.renderer.mode !== "fullscreen") {
|
|
const modelScopeNotice = this.getModelScopeNotice();
|
|
if (modelScopeNotice) console.log(modelScopeNotice);
|
|
}
|
|
|
|
// Keep one component tree and remount it when changing renderers.
|
|
this.renderWidgets(); // Initialize with default spacer
|
|
const viewport = createChatViewport({
|
|
document: this.documentContainer,
|
|
pendingMessages: this.pendingMessagesContainer,
|
|
status: this.statusContainer,
|
|
widgetsAbove: this.widgetContainerAbove,
|
|
editor: this.editorContainer,
|
|
widgetsBelow: this.widgetContainerBelow,
|
|
footer: this.footerContainer,
|
|
scrollbar: this.settingsManager.getFullscreenScrollbar(),
|
|
scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),
|
|
scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),
|
|
});
|
|
this.transcriptScrollView = viewport.transcript;
|
|
this.fullscreenLayoutRoot = viewport.root;
|
|
this.mountInteractiveTui(this.renderer, [
|
|
this.documentContainer,
|
|
this.pendingMessagesContainer,
|
|
this.statusContainer,
|
|
this.widgetContainerAbove,
|
|
this.editorContainer,
|
|
this.widgetContainerBelow,
|
|
this.footerContainer,
|
|
]);
|
|
// Accept text while startup completes, but only enable interrupt, exit, and submission feedback.
|
|
this.defaultEditor.onAction("app.clear", () => this.handleCtrlC());
|
|
this.defaultEditor.onCtrlD = () => this.handleCtrlD();
|
|
this.defaultEditor.onSubmit = (text) => this.handleStartupSubmit(text);
|
|
this.ui.setFocus(this.editor);
|
|
|
|
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
|
|
this.ui.start();
|
|
this.isInitialized = true;
|
|
this.programStatus.report();
|
|
this.ensurePngTranscoder();
|
|
|
|
this.themeController.applyFromSettings();
|
|
// The header and startup notices bake theme colors into their text, so build them once the terminal
|
|
// reported its colors. This ends at the terminal's DA1 reply, or after 100 ms if it answers nothing.
|
|
await this.themeController.waitForTerminalColors();
|
|
|
|
// Add header with keybindings from config (unless silenced)
|
|
if (this.shouldShowStartupHeader()) {
|
|
const showDetails = this.shouldShowStartupDetails();
|
|
// Built on demand so the header follows theme changes. The logo's first line carries the version,
|
|
// its second line the first line of key hints. Terminals that cannot render the logo get a
|
|
// "Pi vX" line instead, with the key hints below it.
|
|
const showLogo = supportsPiLogo();
|
|
const withLogo = (hints: string) => {
|
|
if (!showLogo) return `${piWordmark()} ${theme.fg("dim", `v${this.version}`)}\n${hints}`;
|
|
const [top, bottom] = piLogoLines();
|
|
return `${top} ${theme.fg("dim", `v${this.version}`)}\n${bottom} ${hints}`;
|
|
};
|
|
|
|
// Build startup instructions using keybinding hint helpers
|
|
const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);
|
|
|
|
const expandedInstructions = () =>
|
|
[
|
|
hint("app.interrupt", "to interrupt"),
|
|
hint("app.clear", "to clear"),
|
|
rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
|
|
hint("app.exit", "to exit (empty)"),
|
|
hint("app.suspend", "to suspend"),
|
|
keyHint("tui.editor.deleteToLineEnd", "to delete to end"),
|
|
hint("app.thinking.cycle", "to cycle thinking level"),
|
|
rawKeyHint(
|
|
`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`,
|
|
"to cycle models",
|
|
),
|
|
hint("app.model.select", "to select model"),
|
|
hint("app.tools.expand", "to expand tools"),
|
|
hint("app.thinking.toggle", "to expand thinking"),
|
|
hint("app.editor.external", "for external editor"),
|
|
rawKeyHint("/", "for commands"),
|
|
rawKeyHint("!", "to run bash"),
|
|
rawKeyHint("!!", "to run bash (no context)"),
|
|
hint("app.message.followUp", "to queue follow-up"),
|
|
hint("app.message.dequeue", "to edit all queued messages"),
|
|
hint("app.clipboard.pasteImage", "to paste files on macOS, images, or text"),
|
|
rawKeyHint("drop files", "to attach"),
|
|
].join("\n");
|
|
const compactInstructions = () =>
|
|
[
|
|
hint("app.interrupt", "interrupt"),
|
|
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
|
|
rawKeyHint("/", "commands"),
|
|
rawKeyHint("!", "bash"),
|
|
hint("app.tools.expand", "more"),
|
|
].join(theme.fg("muted", " · "));
|
|
const compactOnboarding = () =>
|
|
theme.fg(
|
|
"dim",
|
|
`Press ${keyText("app.tools.expand")} to show full startup help${showDetails ? " and loaded resources" : ""}.`,
|
|
);
|
|
const onboarding = () =>
|
|
theme.fg("dim", `Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`);
|
|
const header = new BuiltInHeader(
|
|
() => `${withLogo(compactInstructions())}\n${compactOnboarding()}\n\n${onboarding()}`,
|
|
() => `${withLogo(expandedInstructions())}\n\n${onboarding()}`,
|
|
this.getStartupExpansionState(),
|
|
1,
|
|
0,
|
|
);
|
|
if (showLogo) header.onLogoClick = (column, row) => playPiLogo3d(this.renderer, column, row);
|
|
this.builtInHeader = header;
|
|
|
|
// Setup UI layout
|
|
this.headerContainer.addChild(new Spacer(1));
|
|
this.headerContainer.addChild(this.builtInHeader);
|
|
this.headerContainer.addChild(new Spacer(1));
|
|
} else {
|
|
// Minimal header when silenced
|
|
this.builtInHeader = new Text("", 0, 0);
|
|
this.headerContainer.addChild(this.builtInHeader);
|
|
}
|
|
if (this.renderer.mode === "fullscreen") this.showFullscreenModelScopeNotice();
|
|
this.ui.requestRender();
|
|
|
|
// Ensure fd and rg are available after mounting the TUI (downloads if missing, adds to PATH via getBinDir)
|
|
// so slow downloads do not make startup appear frozen.
|
|
// Both are needed: fd for autocomplete, rg for grep tool and bash commands.
|
|
const [fdPath] = await Promise.all([
|
|
ensureTool("fd", (status) => this.showManagedToolStatus(status)),
|
|
ensureTool("rg", (status) => this.showManagedToolStatus(status)),
|
|
]);
|
|
this.fdPath = fdPath;
|
|
|
|
// Enable the remaining input handlers only after managed-tool setup completes.
|
|
this.setupKeyHandlers();
|
|
this.setupEditorSubmitHandler();
|
|
this.ui.requestRender();
|
|
|
|
// Initialize extensions first so resources are shown before messages
|
|
await this.rebindCurrentSession();
|
|
|
|
// Render initial messages AFTER showing loaded resources
|
|
this.renderInitialMessages();
|
|
|
|
// Set up theme file watcher
|
|
onThemeChange(() => {
|
|
this.ui.invalidate();
|
|
this.updateEditorBorderColor();
|
|
this.ui.requestRender();
|
|
});
|
|
|
|
// Set up git branch watcher (uses provider instead of footer)
|
|
this.footerDataProvider.onBranchChange(() => {
|
|
this.ui.requestRender();
|
|
});
|
|
|
|
// Initialize available provider count for footer display
|
|
await this.updateAvailableProviderCount();
|
|
|
|
// Flush the completed startup state before loading the remaining syntax grammars.
|
|
this.ui.renderNow();
|
|
void loadAllHighlightLanguages().then(() => {
|
|
if (!this.isInitialized) return;
|
|
this.ui.invalidate();
|
|
this.ui.requestRender();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update terminal title with session name and cwd.
|
|
*/
|
|
private updateTerminalTitle(): void {
|
|
const cwdBasename = path.basename(this.sessionManager.getCwd());
|
|
const sessionName = this.sessionManager.getSessionName();
|
|
if (sessionName) {
|
|
this.ui.terminal.setTitle(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`);
|
|
} else {
|
|
this.ui.terminal.setTitle(`${APP_TITLE} - ${cwdBasename}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the interactive mode. This is the main entry point.
|
|
* Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop.
|
|
*/
|
|
async run(): Promise<void> {
|
|
await this.init();
|
|
|
|
if (!process.env.PI_OFFLINE) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
void refreshModelCatalogs(this.session.modelRuntime, controller.signal)
|
|
.then(() => this.updateAvailableProviderCount())
|
|
.catch(() => {})
|
|
.finally(() => clearTimeout(timeout));
|
|
}
|
|
|
|
// Start version check asynchronously
|
|
checkForNewPiVersion(this.version).then((newRelease) => {
|
|
if (newRelease) {
|
|
this.showNewVersionNotification(newRelease);
|
|
}
|
|
});
|
|
|
|
// Start package update check asynchronously
|
|
this.checkForPackageUpdates()
|
|
.then((updates) => {
|
|
if (updates.length > 0) {
|
|
this.showPackageUpdateNotification(updates);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
// On Windows, npm can overwrite the shared console title while checking
|
|
// extension package versions. Restore Pi's title after the startup check.
|
|
if (process.platform === "win32" && this.isInitialized) {
|
|
this.updateTerminalTitle();
|
|
}
|
|
});
|
|
|
|
// Check tmux keyboard setup asynchronously
|
|
this.checkTmuxKeyboardSetup().then((warning) => {
|
|
if (warning) {
|
|
this.showWarning(warning);
|
|
}
|
|
});
|
|
|
|
// Show startup warnings
|
|
const {
|
|
migratedProviders,
|
|
startupDiagnostics,
|
|
modelFallbackMessage,
|
|
initialMessage,
|
|
initialImages,
|
|
initialMessages,
|
|
} = this.options;
|
|
|
|
for (const diagnostic of startupDiagnostics ?? []) {
|
|
if (diagnostic.type === "error") {
|
|
this.showError(diagnostic.message);
|
|
} else if (diagnostic.type === "warning") {
|
|
this.showWarning(diagnostic.message);
|
|
} else {
|
|
this.showStatus(diagnostic.message);
|
|
}
|
|
}
|
|
|
|
if (migratedProviders && migratedProviders.length > 0) {
|
|
this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
|
|
}
|
|
|
|
const modelsJsonError = this.session.modelRuntime.getError();
|
|
if (modelsJsonError) {
|
|
this.showError(`models.json error: ${modelsJsonError}`);
|
|
}
|
|
|
|
if (modelFallbackMessage) {
|
|
this.showWarning(modelFallbackMessage);
|
|
}
|
|
|
|
const crash = takeUnnotifiedCrash();
|
|
if (crash) {
|
|
const when = new Date(crash.timestamp).toLocaleString();
|
|
this.showWarning(
|
|
`${APP_NAME} crashed on ${when} (${crash.message}). Run /bug to report it; the crash details are attached automatically.`,
|
|
);
|
|
}
|
|
|
|
void this.maybeWarnAboutAnthropicSubscriptionAuth();
|
|
|
|
// Process initial messages
|
|
if (initialMessage) {
|
|
try {
|
|
await this.session.prompt(initialMessage, { images: initialImages });
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
this.showError(errorMessage);
|
|
}
|
|
}
|
|
|
|
if (initialMessages) {
|
|
for (const message of initialMessages) {
|
|
try {
|
|
await this.session.prompt(message);
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
this.showError(errorMessage);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Main interactive loop
|
|
while (true) {
|
|
const userInput = await this.getUserInput();
|
|
try {
|
|
await this.session.prompt(userInput);
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
this.showError(errorMessage);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async checkForPackageUpdates(): Promise<string[]> {
|
|
if (process.env.PI_OFFLINE) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const packageManager = new DefaultPackageManager({
|
|
cwd: this.sessionManager.getCwd(),
|
|
agentDir: getAgentDir(),
|
|
settingsManager: this.settingsManager,
|
|
});
|
|
const updates = await packageManager.checkForAvailableUpdates();
|
|
return updates.map((update) => update.displayName);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private async checkTmuxKeyboardSetup(): Promise<string | undefined> {
|
|
if (!process.env.TMUX) return undefined;
|
|
|
|
const runTmuxShow = (option: string): Promise<string | undefined> => {
|
|
return new Promise((resolve) => {
|
|
const proc = spawn("tmux", ["show", "-gv", option], {
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
let stdout = "";
|
|
const timer = setTimeout(() => {
|
|
proc.kill();
|
|
resolve(undefined);
|
|
}, 2000);
|
|
|
|
proc.stdout?.on("data", (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
proc.on("error", () => {
|
|
clearTimeout(timer);
|
|
resolve(undefined);
|
|
});
|
|
proc.on("close", (code) => {
|
|
clearTimeout(timer);
|
|
resolve(code === 0 ? stdout.trim() : undefined);
|
|
});
|
|
});
|
|
};
|
|
|
|
const [extendedKeys, extendedKeysFormat] = await Promise.all([
|
|
runTmuxShow("extended-keys"),
|
|
runTmuxShow("extended-keys-format"),
|
|
]);
|
|
|
|
// If we couldn't query tmux (timeout, sandbox, etc.), don't warn
|
|
if (extendedKeys === undefined) return undefined;
|
|
|
|
if (extendedKeys !== "on" && extendedKeys !== "always") {
|
|
return "tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.";
|
|
}
|
|
|
|
if (extendedKeysFormat === "xterm") {
|
|
return "tmux extended-keys-format is xterm. Pi works best with csi-u. Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux.";
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Get changelog entries to display on startup.
|
|
* Only shows new entries since last seen version, skips for resumed sessions.
|
|
*/
|
|
private getChangelogForDisplay(): string | undefined {
|
|
// Skip changelog for resumed/continued sessions (already have messages)
|
|
if (this.session.state.messages.length > 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const lastVersion = this.settingsManager.getLastChangelogVersion();
|
|
const changelogPath = getChangelogPath();
|
|
const entries = parseChangelog(changelogPath);
|
|
|
|
if (!lastVersion) {
|
|
// Fresh install - record the version, send telemetry, don't show changelog
|
|
this.settingsManager.setLastChangelogVersion(VERSION);
|
|
this.reportInstallTelemetry(VERSION);
|
|
return undefined;
|
|
}
|
|
|
|
const newEntries = getNewEntries(entries, lastVersion);
|
|
if (!newEntries) {
|
|
// Unreadable saved version: show nothing and record the current one so later updates compare again
|
|
this.settingsManager.setLastChangelogVersion(VERSION);
|
|
return undefined;
|
|
}
|
|
if (newEntries.length > 0) {
|
|
this.settingsManager.setLastChangelogVersion(VERSION);
|
|
this.reportInstallTelemetry(VERSION);
|
|
return newEntries.map((e) => normalizeChangelogLinks(e.content, e)).join("\n\n");
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private getExtensionChangelogUpdates(): ExtensionChangelogUpdate[] {
|
|
// Skip changelog for resumed/continued sessions (already have messages)
|
|
if (this.session.state.messages.length > 0) {
|
|
return [];
|
|
}
|
|
|
|
const seenKeys = new Set<string>();
|
|
const updates: ExtensionChangelogUpdate[] = [];
|
|
for (const { sourceInfo } of this.session.resourceLoader.getExtensions().extensions) {
|
|
if (sourceInfo.origin !== "package" || !sourceInfo.baseDir || !sourceInfo.changelogPath) {
|
|
continue;
|
|
}
|
|
|
|
const key = getSourcePackageKey(sourceInfo);
|
|
if (seenKeys.has(key)) {
|
|
continue;
|
|
}
|
|
seenKeys.add(key);
|
|
|
|
const pkg = this.readPackageJson(sourceInfo.baseDir);
|
|
if (!pkg?.version) {
|
|
continue;
|
|
}
|
|
|
|
const lastVersion = this.settingsManager.getExtensionChangelogVersion(key);
|
|
if (!lastVersion) {
|
|
// Fresh install - record the version, don't show changelog
|
|
this.settingsManager.setExtensionChangelogVersion(key, pkg.version);
|
|
continue;
|
|
}
|
|
|
|
const newEntries = getNewEntries(parseChangelog(sourceInfo.changelogPath), lastVersion);
|
|
if (!newEntries) {
|
|
// Unreadable saved version: show nothing and record the current one so later updates compare again
|
|
this.settingsManager.setExtensionChangelogVersion(key, pkg.version);
|
|
continue;
|
|
}
|
|
if (newEntries.length > 0) {
|
|
this.settingsManager.setExtensionChangelogVersion(key, pkg.version);
|
|
const name = pkg.name || sourceInfo.source;
|
|
// Prefix each "## [x.y.z]" heading with the package name so entries render like Pi's own.
|
|
const markdown = newEntries
|
|
.map((entry) => entry.content.replace(/^##\s+/, `## ${name} `))
|
|
.join("\n\n")
|
|
.trim();
|
|
updates.push({ name, version: pkg.version, markdown });
|
|
}
|
|
}
|
|
|
|
return updates;
|
|
}
|
|
|
|
private readPackageJson(packageRoot: string): { name?: string; version?: string } | undefined {
|
|
try {
|
|
const pkg = JSON.parse(stripBom(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"))) as {
|
|
name?: unknown;
|
|
version?: unknown;
|
|
};
|
|
return {
|
|
name: typeof pkg.name === "string" ? pkg.name : undefined,
|
|
version: typeof pkg.version === "string" ? pkg.version : undefined,
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private reportInstallTelemetry(version: string): void {
|
|
if (process.env.PI_OFFLINE) {
|
|
return;
|
|
}
|
|
|
|
if (!isInstallTelemetryEnabled(this.settingsManager)) {
|
|
return;
|
|
}
|
|
|
|
void fetch(`https://pi.dev/api/report-install?version=${encodeURIComponent(version)}`, {
|
|
headers: {
|
|
"User-Agent": getPiUserAgent(version),
|
|
},
|
|
signal: AbortSignal.timeout(5000),
|
|
})
|
|
.then(() => undefined)
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
private getMarkdownThemeWithSettings(): MarkdownTheme {
|
|
return {
|
|
...getMarkdownTheme(),
|
|
codeBlockIndent: this.settingsManager.getCodeBlockIndent(),
|
|
};
|
|
}
|
|
|
|
// =========================================================================
|
|
// Extension System
|
|
// =========================================================================
|
|
|
|
private formatDisplayPath(p: string): string {
|
|
return shortenHomePath(p);
|
|
}
|
|
|
|
private formatExtensionDisplayPath(path: string): string {
|
|
let result = this.formatDisplayPath(path);
|
|
result = result.replace(/\/index\.ts$/, "").replace(/\/index\.js$/, "");
|
|
return result;
|
|
}
|
|
|
|
private formatContextPath(p: string): string {
|
|
const cwd = path.resolve(this.sessionManager.getCwd());
|
|
const absolutePath = path.isAbsolute(p) ? path.resolve(p) : path.resolve(cwd, p);
|
|
const relativePath = getCwdRelativePath(absolutePath, cwd);
|
|
if (relativePath !== undefined) {
|
|
return relativePath;
|
|
}
|
|
|
|
return this.formatDisplayPath(absolutePath);
|
|
}
|
|
|
|
private getStartupExpansionState(): boolean {
|
|
return this.options.verbose || this.toolOutputExpanded;
|
|
}
|
|
|
|
/** Startup header (logo, version, key hints). Hidden only by quietStartup: true. */
|
|
private shouldShowStartupHeader(): boolean {
|
|
return this.options.verbose === true || this.settingsManager.getQuietStartup() !== true;
|
|
}
|
|
|
|
/** Startup details (model scope, loaded resources). Hidden by quietStartup: true or "header". */
|
|
private getModelScopeNotice(): string | undefined {
|
|
if (this.session.scopedModels.length === 0 || !this.shouldShowStartupDetails()) return undefined;
|
|
const modelList = this.session.scopedModels
|
|
.map((sm) => {
|
|
const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : "";
|
|
return `${sm.model.id}${thinkingStr}`;
|
|
})
|
|
.join(", ");
|
|
const cycleKeys = this.keybindings.getKeys("app.model.cycleForward");
|
|
const cycleHint =
|
|
cycleKeys.length > 0
|
|
? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`)
|
|
: "";
|
|
return theme.fg("dim", `Model scope: ${modelList}${cycleHint}`);
|
|
}
|
|
|
|
private showFullscreenModelScopeNotice(): void {
|
|
if (!this.getModelScopeNotice()) return;
|
|
this.headerContainer.addChild(new ThemedText(() => this.getModelScopeNotice() ?? "", 1, 0));
|
|
this.headerContainer.addChild(new Spacer(1));
|
|
}
|
|
|
|
private shouldShowStartupDetails(): boolean {
|
|
return this.options.verbose === true || this.settingsManager.getQuietStartup() === false;
|
|
}
|
|
|
|
/**
|
|
* Get a short path relative to the package root for display.
|
|
*/
|
|
private getShortPath(fullPath: string, sourceInfo?: SourceInfo): string {
|
|
const normalizedFullPath = fullPath.replace(/\\/g, "/");
|
|
const baseDir = sourceInfo?.baseDir;
|
|
if (baseDir && this.isPackageSource(sourceInfo)) {
|
|
const normalizedBaseDir = baseDir.replace(/\\/g, "/");
|
|
const npmRootMatch = normalizedBaseDir.match(/^(.*\/node_modules)\/(@?[^/]+(?:\/[^/]+)?)$/);
|
|
// If fullPath is under the same node_modules root as baseDir, preserve that relative topology.
|
|
if (npmRootMatch?.[1] && normalizedFullPath.startsWith(`${npmRootMatch[1]}/`)) {
|
|
return path.posix.relative(normalizedBaseDir, normalizedFullPath);
|
|
}
|
|
|
|
const relativePath = path.relative(path.resolve(baseDir), path.resolve(fullPath));
|
|
if (
|
|
relativePath &&
|
|
relativePath !== "." &&
|
|
!relativePath.startsWith("..") &&
|
|
!relativePath.startsWith(`..${path.sep}`) &&
|
|
!path.isAbsolute(relativePath)
|
|
) {
|
|
return relativePath.replace(/\\/g, "/");
|
|
}
|
|
}
|
|
|
|
const source = sourceInfo?.source ?? "";
|
|
const npmMatch = normalizedFullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);
|
|
if (npmMatch && source.startsWith("npm:")) {
|
|
return npmMatch[2];
|
|
}
|
|
|
|
const gitMatch = normalizedFullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
|
|
if (gitMatch && source.startsWith("git:")) {
|
|
return gitMatch[1];
|
|
}
|
|
|
|
return this.formatDisplayPath(fullPath);
|
|
}
|
|
|
|
private getCompactPathLabel(resourcePath: string, sourceInfo?: SourceInfo): string {
|
|
const shortPath = this.getShortPath(resourcePath, sourceInfo);
|
|
const normalizedPath = shortPath.replace(/\\/g, "/");
|
|
const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~");
|
|
if (segments.length > 0) {
|
|
return segments[segments.length - 1]!;
|
|
}
|
|
return shortPath;
|
|
}
|
|
|
|
private getCompactPackageSourceLabel(sourceInfo?: SourceInfo): string {
|
|
const source = sourceInfo?.source ?? "";
|
|
if (source.startsWith("npm:")) {
|
|
return source.slice("npm:".length) || source;
|
|
}
|
|
|
|
const gitSource = parseGitUrl(source);
|
|
if (gitSource) {
|
|
return gitSource.path || source;
|
|
}
|
|
|
|
return source;
|
|
}
|
|
|
|
private getCompactExtensionLabel(resourcePath: string, sourceInfo?: SourceInfo): string {
|
|
if (!this.isPackageSource(sourceInfo)) {
|
|
return this.getCompactPathLabel(resourcePath, sourceInfo);
|
|
}
|
|
|
|
const sourceLabel = this.getCompactPackageSourceLabel(sourceInfo);
|
|
if (!sourceLabel) {
|
|
return this.getCompactPathLabel(resourcePath, sourceInfo);
|
|
}
|
|
|
|
const shortPath = this.getShortPath(resourcePath, sourceInfo).replace(/\\/g, "/");
|
|
const packagePath = shortPath.startsWith("extensions/") ? shortPath.slice("extensions/".length) : shortPath;
|
|
const parsedPath = path.posix.parse(packagePath);
|
|
|
|
if (parsedPath.name === "index") {
|
|
return !parsedPath.dir || parsedPath.dir === "." ? sourceLabel : `${sourceLabel}:${parsedPath.dir}`;
|
|
}
|
|
|
|
return `${sourceLabel}:${packagePath}`;
|
|
}
|
|
|
|
private getCompactDisplayPathSegments(resourcePath: string): string[] {
|
|
return this.formatDisplayPath(resourcePath)
|
|
.replace(/\\/g, "/")
|
|
.split("/")
|
|
.filter((segment) => segment.length > 0 && segment !== "~");
|
|
}
|
|
|
|
private getCompactNonPackageExtensionLabel(
|
|
resourcePath: string,
|
|
index: number,
|
|
allPaths: Array<{ path: string; segments: string[] }>,
|
|
): string {
|
|
const segments = allPaths[index]?.segments;
|
|
if (!segments || segments.length === 0) {
|
|
return this.getCompactPathLabel(resourcePath);
|
|
}
|
|
|
|
for (let segmentCount = 1; segmentCount <= segments.length; segmentCount += 1) {
|
|
const candidate = segments.slice(-segmentCount).join("/");
|
|
const isUnique = allPaths.every((item, itemIndex) => {
|
|
if (itemIndex === index) {
|
|
return true;
|
|
}
|
|
return item.segments.slice(-segmentCount).join("/") !== candidate;
|
|
});
|
|
|
|
if (isUnique) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return segments.join("/");
|
|
}
|
|
|
|
private getCompactExtensionLabels(extensions: Array<{ path: string; sourceInfo?: SourceInfo }>): string[] {
|
|
const nonPackageExtensions = extensions
|
|
.map((extension) => {
|
|
const segments = this.getCompactDisplayPathSegments(extension.path);
|
|
const lastSegment = segments[segments.length - 1];
|
|
if (segments.length > 1 && (lastSegment === "index.ts" || lastSegment === "index.js")) {
|
|
segments.pop();
|
|
}
|
|
return {
|
|
path: extension.path,
|
|
sourceInfo: extension.sourceInfo,
|
|
segments,
|
|
};
|
|
})
|
|
.filter((extension) => !this.isPackageSource(extension.sourceInfo));
|
|
|
|
return extensions.map((extension) => {
|
|
if (this.isPackageSource(extension.sourceInfo)) {
|
|
return this.getCompactExtensionLabel(extension.path, extension.sourceInfo);
|
|
}
|
|
|
|
const nonPackageIndex = nonPackageExtensions.findIndex((item) => item.path === extension.path);
|
|
if (nonPackageIndex === -1) {
|
|
return this.getCompactPathLabel(extension.path, extension.sourceInfo);
|
|
}
|
|
|
|
return this.getCompactNonPackageExtensionLabel(extension.path, nonPackageIndex, nonPackageExtensions);
|
|
});
|
|
}
|
|
|
|
private getDisplaySourceInfo(sourceInfo?: SourceInfo): {
|
|
label: string;
|
|
scopeLabel?: string;
|
|
color: "accent" | "muted";
|
|
} {
|
|
const source = sourceInfo?.source ?? "local";
|
|
const scope = sourceInfo?.scope ?? "project";
|
|
if (source === "local") {
|
|
if (scope === "user") {
|
|
return { label: "user", color: "muted" };
|
|
}
|
|
if (scope === "project") {
|
|
return { label: "project", color: "muted" };
|
|
}
|
|
if (scope === "temporary") {
|
|
return { label: "path", scopeLabel: "temp", color: "muted" };
|
|
}
|
|
return { label: "path", color: "muted" };
|
|
}
|
|
|
|
if (source === "cli") {
|
|
return { label: "path", scopeLabel: scope === "temporary" ? "temp" : undefined, color: "muted" };
|
|
}
|
|
|
|
const scopeLabel =
|
|
scope === "user" ? "user" : scope === "project" ? "project" : scope === "temporary" ? "temp" : undefined;
|
|
return { label: source, scopeLabel, color: "accent" };
|
|
}
|
|
|
|
private getScopeGroup(sourceInfo?: SourceInfo): "user" | "project" | "path" {
|
|
const source = sourceInfo?.source ?? "local";
|
|
const scope = sourceInfo?.scope ?? "project";
|
|
if (source === "cli" || scope === "temporary") return "path";
|
|
if (scope === "user") return "user";
|
|
if (scope === "project") return "project";
|
|
return "path";
|
|
}
|
|
|
|
private isPackageSource(sourceInfo?: SourceInfo): boolean {
|
|
const source = sourceInfo?.source ?? "";
|
|
return source.startsWith("npm:") || source.startsWith("git:");
|
|
}
|
|
|
|
private buildScopeGroups(items: Array<{ path: string; sourceInfo?: SourceInfo }>): Array<{
|
|
scope: "user" | "project" | "path";
|
|
paths: Array<{ path: string; sourceInfo?: SourceInfo }>;
|
|
packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>;
|
|
}> {
|
|
const groups: Record<
|
|
"user" | "project" | "path",
|
|
{
|
|
scope: "user" | "project" | "path";
|
|
paths: Array<{ path: string; sourceInfo?: SourceInfo }>;
|
|
packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>;
|
|
}
|
|
> = {
|
|
user: { scope: "user", paths: [], packages: new Map() },
|
|
project: { scope: "project", paths: [], packages: new Map() },
|
|
path: { scope: "path", paths: [], packages: new Map() },
|
|
};
|
|
|
|
for (const item of items) {
|
|
const groupKey = this.getScopeGroup(item.sourceInfo);
|
|
const group = groups[groupKey];
|
|
const source = item.sourceInfo?.source ?? "local";
|
|
|
|
if (this.isPackageSource(item.sourceInfo)) {
|
|
const list = group.packages.get(source) ?? [];
|
|
list.push(item);
|
|
group.packages.set(source, list);
|
|
} else {
|
|
group.paths.push(item);
|
|
}
|
|
}
|
|
|
|
return [groups.project, groups.user, groups.path].filter(
|
|
(group) => group.paths.length > 0 || group.packages.size > 0,
|
|
);
|
|
}
|
|
|
|
private formatScopeGroups(
|
|
groups: Array<{
|
|
scope: "user" | "project" | "path";
|
|
paths: Array<{ path: string; sourceInfo?: SourceInfo }>;
|
|
packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>;
|
|
}>,
|
|
options: {
|
|
formatPath: (item: { path: string; sourceInfo?: SourceInfo }) => string;
|
|
formatPackagePath: (item: { path: string; sourceInfo?: SourceInfo }, source: string) => string;
|
|
},
|
|
): string {
|
|
const lines: string[] = [];
|
|
|
|
for (const group of groups) {
|
|
lines.push(` ${theme.fg("accent", group.scope)}`);
|
|
|
|
const sortedPaths = [...group.paths].sort((a, b) => a.path.localeCompare(b.path));
|
|
for (const item of sortedPaths) {
|
|
lines.push(theme.fg("dim", ` ${options.formatPath(item)}`));
|
|
}
|
|
|
|
const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b));
|
|
for (const [source, items] of sortedPackages) {
|
|
lines.push(` ${theme.fg("mdLink", source)}`);
|
|
const sortedPackagePaths = [...items].sort((a, b) => a.path.localeCompare(b.path));
|
|
for (const item of sortedPackagePaths) {
|
|
lines.push(theme.fg("dim", ` ${options.formatPackagePath(item, source)}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
private findSourceInfoForPath(p: string, sourceInfos: Map<string, SourceInfo>): SourceInfo | undefined {
|
|
const exact = sourceInfos.get(p);
|
|
if (exact) return exact;
|
|
|
|
let current = p;
|
|
while (current.includes("/")) {
|
|
current = current.substring(0, current.lastIndexOf("/"));
|
|
const parent = sourceInfos.get(current);
|
|
if (parent) return parent;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private formatPathWithSource(p: string, sourceInfo?: SourceInfo): string {
|
|
if (sourceInfo) {
|
|
const shortPath = this.getShortPath(p, sourceInfo);
|
|
const { label, scopeLabel } = this.getDisplaySourceInfo(sourceInfo);
|
|
const labelText = scopeLabel ? `${label} (${scopeLabel})` : label;
|
|
return `${labelText} ${shortPath}`;
|
|
}
|
|
return this.formatDisplayPath(p);
|
|
}
|
|
|
|
private formatDiagnostics(diagnostics: readonly ResourceDiagnostic[], sourceInfos: Map<string, SourceInfo>): string {
|
|
const lines: string[] = [];
|
|
|
|
// Group collision diagnostics by name
|
|
const collisions = new Map<string, ResourceDiagnostic[]>();
|
|
const otherDiagnostics: ResourceDiagnostic[] = [];
|
|
|
|
for (const d of diagnostics) {
|
|
if (d.type === "collision" && d.collision) {
|
|
const list = collisions.get(d.collision.name) ?? [];
|
|
list.push(d);
|
|
collisions.set(d.collision.name, list);
|
|
} else {
|
|
otherDiagnostics.push(d);
|
|
}
|
|
}
|
|
|
|
// Format collision diagnostics grouped by name
|
|
for (const [name, collisionList] of collisions) {
|
|
const first = collisionList[0]?.collision;
|
|
if (!first) continue;
|
|
lines.push(theme.fg("warning", ` "${name}" collision:`));
|
|
lines.push(
|
|
theme.fg(
|
|
"dim",
|
|
` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, this.findSourceInfoForPath(first.winnerPath, sourceInfos))}`,
|
|
),
|
|
);
|
|
for (const d of collisionList) {
|
|
if (d.collision) {
|
|
lines.push(
|
|
theme.fg(
|
|
"dim",
|
|
` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, this.findSourceInfoForPath(d.collision.loserPath, sourceInfos))} (skipped)`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const d of otherDiagnostics) {
|
|
if (d.path) {
|
|
const formattedPath = this.formatPathWithSource(d.path, this.findSourceInfoForPath(d.path, sourceInfos));
|
|
lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${formattedPath}`));
|
|
lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`));
|
|
} else {
|
|
lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`));
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
private showLoadedResources(options?: {
|
|
extensions?: Array<{ path: string; sourceInfo?: SourceInfo }>;
|
|
force?: boolean;
|
|
showDiagnosticsWhenQuiet?: boolean;
|
|
}): void {
|
|
// Resource rendering is idempotent; chat clears no longer clear this separate container.
|
|
this.loadedResourcesContainer.clear();
|
|
|
|
const showListing = options?.force || this.shouldShowStartupDetails();
|
|
const showDiagnostics = showListing || options?.showDiagnosticsWhenQuiet === true;
|
|
if (!showListing && !showDiagnostics) {
|
|
return;
|
|
}
|
|
|
|
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`);
|
|
const formatCompactList = (items: string[], options?: { sort?: boolean }): string => {
|
|
const labels = items.map((item) => item.trim()).filter((item) => item.length > 0);
|
|
if (options?.sort !== false) {
|
|
labels.sort((a, b) => a.localeCompare(b));
|
|
}
|
|
return theme.fg("dim", ` ${labels.join(", ")}`);
|
|
};
|
|
// Bodies are built on demand so the listing follows theme changes.
|
|
const addLoadedSection = (
|
|
name: string,
|
|
collapsedBody: () => string,
|
|
expandedBody = collapsedBody,
|
|
color: ThemeColor = "mdHeading",
|
|
): void => {
|
|
const section = new ExpandableText(
|
|
() => `${sectionHeader(name, color)}\n${collapsedBody()}`,
|
|
() => `${sectionHeader(name, color)}\n${expandedBody()}`,
|
|
this.getStartupExpansionState(),
|
|
0,
|
|
0,
|
|
);
|
|
this.loadedResourcesContainer.addChild(section);
|
|
this.loadedResourcesContainer.addChild(new Spacer(1));
|
|
};
|
|
|
|
const skillsResult = this.session.resourceLoader.getSkills();
|
|
const promptsResult = this.session.resourceLoader.getPrompts();
|
|
const themesResult = this.session.resourceLoader.getThemes();
|
|
const extensions =
|
|
options?.extensions ??
|
|
this.session.resourceLoader
|
|
.getExtensions()
|
|
.extensions.filter((extension) => !extension.hidden)
|
|
.map((extension) => ({
|
|
path: extension.path,
|
|
sourceInfo: extension.sourceInfo,
|
|
}));
|
|
const sourceInfos = new Map<string, SourceInfo>();
|
|
for (const extension of extensions) {
|
|
if (extension.sourceInfo) {
|
|
sourceInfos.set(extension.path, extension.sourceInfo);
|
|
}
|
|
}
|
|
for (const skill of skillsResult.skills) {
|
|
if (skill.sourceInfo) {
|
|
sourceInfos.set(skill.filePath, skill.sourceInfo);
|
|
}
|
|
}
|
|
for (const prompt of promptsResult.prompts) {
|
|
if (prompt.sourceInfo) {
|
|
sourceInfos.set(prompt.filePath, prompt.sourceInfo);
|
|
}
|
|
}
|
|
for (const loadedTheme of themesResult.themes) {
|
|
if (loadedTheme.sourcePath && loadedTheme.sourceInfo) {
|
|
sourceInfos.set(loadedTheme.sourcePath, loadedTheme.sourceInfo);
|
|
}
|
|
}
|
|
|
|
if (showListing) {
|
|
const systemPromptSource = this.session.resourceLoader.getSystemPromptSource();
|
|
const contextFiles = [
|
|
...(systemPromptSource ? [systemPromptSource] : []),
|
|
...this.session.resourceLoader.getAppendSystemPromptSources(),
|
|
...this.session.resourceLoader.getAgentsFiles().agentsFiles,
|
|
];
|
|
if (contextFiles.length > 0) {
|
|
this.loadedResourcesContainer.addChild(new Spacer(1));
|
|
const contextList = () =>
|
|
contextFiles.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)).join("\n");
|
|
const contextCompactList = () =>
|
|
formatCompactList(
|
|
contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)),
|
|
{ sort: false },
|
|
);
|
|
addLoadedSection("Context", contextCompactList, contextList);
|
|
}
|
|
|
|
const skills = skillsResult.skills;
|
|
if (skills.length > 0) {
|
|
const groups = this.buildScopeGroups(
|
|
skills.map((skill) => ({ path: skill.filePath, sourceInfo: skill.sourceInfo })),
|
|
);
|
|
const skillList = () =>
|
|
this.formatScopeGroups(groups, {
|
|
formatPath: (item) => this.formatDisplayPath(item.path),
|
|
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
|
|
});
|
|
const skillCompactList = () => formatCompactList(skills.map((skill) => skill.name));
|
|
addLoadedSection("Skills", skillCompactList, skillList);
|
|
}
|
|
|
|
const templates = this.session.promptTemplates;
|
|
if (templates.length > 0) {
|
|
const groups = this.buildScopeGroups(
|
|
templates.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })),
|
|
);
|
|
const templateByPath = new Map(templates.map((t) => [t.filePath, t]));
|
|
const templateList = () =>
|
|
this.formatScopeGroups(groups, {
|
|
formatPath: (item) => {
|
|
const template = templateByPath.get(item.path);
|
|
return template ? `/${template.name}` : this.formatDisplayPath(item.path);
|
|
},
|
|
formatPackagePath: (item) => {
|
|
const template = templateByPath.get(item.path);
|
|
return template ? `/${template.name}` : this.formatDisplayPath(item.path);
|
|
},
|
|
});
|
|
const promptCompactList = () => formatCompactList(templates.map((template) => `/${template.name}`));
|
|
addLoadedSection("Prompts", promptCompactList, templateList);
|
|
}
|
|
|
|
if (extensions.length > 0) {
|
|
const groups = this.buildScopeGroups(extensions);
|
|
const extList = () =>
|
|
this.formatScopeGroups(groups, {
|
|
formatPath: (item) => this.formatExtensionDisplayPath(item.path),
|
|
formatPackagePath: (item) =>
|
|
this.formatExtensionDisplayPath(this.getShortPath(item.path, item.sourceInfo)),
|
|
});
|
|
const extensionLabels = this.getCompactExtensionLabels(extensions);
|
|
const extensionCompactList = () => formatCompactList(extensionLabels);
|
|
addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading");
|
|
}
|
|
}
|
|
|
|
if (showDiagnostics) {
|
|
const skillDiagnostics = skillsResult.diagnostics;
|
|
if (skillDiagnostics.length > 0) {
|
|
const warningLines = () => this.formatDiagnostics(skillDiagnostics, sourceInfos);
|
|
this.loadedResourcesContainer.addChild(
|
|
new ThemedText(() => `${theme.fg("warning", "[Skill warnings]")}\n${warningLines()}`, 0, 0),
|
|
);
|
|
this.loadedResourcesContainer.addChild(new Spacer(1));
|
|
}
|
|
|
|
const promptDiagnostics = promptsResult.diagnostics;
|
|
if (promptDiagnostics.length > 0) {
|
|
const warningLines = () => this.formatDiagnostics(promptDiagnostics, sourceInfos);
|
|
this.loadedResourcesContainer.addChild(
|
|
new ThemedText(() => `${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines()}`, 0, 0),
|
|
);
|
|
this.loadedResourcesContainer.addChild(new Spacer(1));
|
|
}
|
|
|
|
const extensionDiagnostics: ResourceDiagnostic[] = [];
|
|
const extensionsResult = this.session.resourceLoader.getExtensions();
|
|
for (const error of extensionsResult.errors) {
|
|
extensionDiagnostics.push({ type: "error", message: error.error, path: error.path });
|
|
}
|
|
for (const warning of extensionsResult.warnings ?? []) {
|
|
extensionDiagnostics.push({ type: "warning", message: warning.warning, path: warning.path });
|
|
}
|
|
|
|
const commandDiagnostics = this.session.extensionRunner.getCommandDiagnostics();
|
|
extensionDiagnostics.push(...commandDiagnostics);
|
|
extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner));
|
|
|
|
const shortcutDiagnostics = this.session.extensionRunner.getShortcutDiagnostics();
|
|
extensionDiagnostics.push(...shortcutDiagnostics);
|
|
|
|
if (extensionDiagnostics.length > 0) {
|
|
const warningLines = () => this.formatDiagnostics(extensionDiagnostics, sourceInfos);
|
|
this.loadedResourcesContainer.addChild(
|
|
new ThemedText(() => `${theme.fg("warning", "[Extension issues]")}\n${warningLines()}`, 0, 0),
|
|
);
|
|
this.loadedResourcesContainer.addChild(new Spacer(1));
|
|
}
|
|
|
|
const themeDiagnostics = themesResult.diagnostics;
|
|
if (themeDiagnostics.length > 0) {
|
|
const warningLines = () => this.formatDiagnostics(themeDiagnostics, sourceInfos);
|
|
this.loadedResourcesContainer.addChild(
|
|
new ThemedText(() => `${theme.fg("warning", "[Theme conflicts]")}\n${warningLines()}`, 0, 0),
|
|
);
|
|
this.loadedResourcesContainer.addChild(new Spacer(1));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the extension system with TUI-based UI context.
|
|
*/
|
|
private async bindCurrentSessionExtensions(): Promise<void> {
|
|
const uiContext = this.createExtensionUIContext();
|
|
await this.session.bindExtensions({
|
|
uiContext,
|
|
mode: "tui",
|
|
abortHandler: () => {
|
|
this.restoreQueuedMessagesToEditor({ abort: true });
|
|
},
|
|
commandContextActions: {
|
|
waitForIdle: () => this.session.waitForIdle(),
|
|
newSession: async (options) => {
|
|
this.clearStatusIndicator();
|
|
try {
|
|
return await this.runtimeHost.newSession(options);
|
|
} catch (error: unknown) {
|
|
return this.handleFatalRuntimeError("Failed to create session", error);
|
|
}
|
|
},
|
|
fork: async (entryId, options) => {
|
|
try {
|
|
const result = await this.runtimeHost.fork(entryId, options);
|
|
if (!result.cancelled) {
|
|
this.editor.setText(result.selectedText ?? "");
|
|
this.showStatus("Forked to new session");
|
|
}
|
|
return { cancelled: result.cancelled };
|
|
} catch (error: unknown) {
|
|
return this.handleFatalRuntimeError("Failed to fork session", error);
|
|
}
|
|
},
|
|
navigateTree: async (targetId, options) => {
|
|
const result = await this.session.navigateTree(targetId, {
|
|
summarize: options?.summarize,
|
|
customInstructions: options?.customInstructions,
|
|
replaceInstructions: options?.replaceInstructions,
|
|
label: options?.label,
|
|
});
|
|
if (result.cancelled) {
|
|
return { cancelled: true };
|
|
}
|
|
|
|
this.chatContainer.clear();
|
|
this.renderInitialMessages();
|
|
if (result.editorText && !this.editor.getText().trim()) {
|
|
this.editor.setText(result.editorText);
|
|
}
|
|
this.showStatus("Navigated to selected point");
|
|
void this.flushCompactionQueue({ willRetry: false });
|
|
return { cancelled: false };
|
|
},
|
|
switchSession: async (sessionPath, options) => {
|
|
return this.handleResumeSession(sessionPath, options);
|
|
},
|
|
reload: async () => {
|
|
await this.handleReloadCommand();
|
|
},
|
|
},
|
|
shutdownHandler: () => {
|
|
this.shutdownRequested = true;
|
|
if (this.session.isIdle) {
|
|
void this.shutdown();
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
|
},
|
|
});
|
|
|
|
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
|
this.setupAutocompleteProvider();
|
|
|
|
const extensionRunner = this.session.extensionRunner;
|
|
this.setupExtensionShortcuts(extensionRunner);
|
|
this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });
|
|
this.showStartupNoticesIfNeeded();
|
|
}
|
|
|
|
private applyFullscreenScrollbarSetting(): void {
|
|
this.transcriptScrollView?.setScrollbar(this.settingsManager.getFullscreenScrollbar());
|
|
}
|
|
|
|
/** Lets extension images use the PNG transcoder; tool results register it themselves. */
|
|
private ensurePngTranscoder(): void {
|
|
ensurePngTranscoder(() => {
|
|
this.ui.invalidate();
|
|
this.ui.requestRender();
|
|
});
|
|
}
|
|
|
|
private applyRuntimeSettings(): void {
|
|
setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides());
|
|
this.ensurePngTranscoder();
|
|
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
|
|
this.applyFullscreenScrollbarSetting();
|
|
if (this.renderer instanceof TuiAltScreen) {
|
|
this.renderer.setCopyOnSelect(this.settingsManager.getFullscreenCopyOnSelect());
|
|
this.renderer.setWheelScrollLines(this.settingsManager.getFullscreenWheelScrollLines());
|
|
}
|
|
this.footer.setSession(this.session);
|
|
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
|
|
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
|
|
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
|
this.outputPad = this.settingsManager.getOutputPad();
|
|
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
|
|
const clearOnShrink = this.settingsManager.getClearOnShrink();
|
|
this.ui.setClearOnShrink(clearOnShrink);
|
|
if (!clearOnShrink && !this.activeStatusIndicator) {
|
|
this.statusContainer.clear();
|
|
}
|
|
const editorPaddingX = this.settingsManager.getEditorPaddingX();
|
|
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
|
|
this.defaultEditor.setPaddingX(editorPaddingX);
|
|
this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);
|
|
if (this.editor !== this.defaultEditor) {
|
|
this.editor.setPaddingX?.(editorPaddingX);
|
|
this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
|
|
}
|
|
}
|
|
|
|
private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise<void> {
|
|
const session = this.session;
|
|
|
|
this.unsubscribe?.();
|
|
this.unsubscribe = undefined;
|
|
this.programStatus.reset();
|
|
this.applyRuntimeSettings();
|
|
|
|
if (options.renderBeforeBind) {
|
|
this.renderCurrentSessionState();
|
|
this.subscribeToAgent();
|
|
}
|
|
|
|
await this.bindCurrentSessionExtensions();
|
|
|
|
if (this.session !== session) {
|
|
return;
|
|
}
|
|
|
|
if (!options.renderBeforeBind) {
|
|
this.subscribeToAgent();
|
|
}
|
|
|
|
await this.updateAvailableProviderCount();
|
|
this.updateEditorBorderColor();
|
|
this.updateTerminalTitle();
|
|
}
|
|
|
|
private async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.showError(`${prefix}: ${message}`);
|
|
const extensionHint = this.getCrashExtensionHint(error);
|
|
if (extensionHint) {
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg("warning", extensionHint), this.outputPad, 0));
|
|
}
|
|
if (this.recordCrash("fatal_error", error)) {
|
|
const instructions = this.crashReportInstructions();
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg("muted", instructions), this.outputPad, 0));
|
|
}
|
|
stopThemeWatcher();
|
|
this.stop("transcript");
|
|
process.exit(1);
|
|
}
|
|
|
|
private getCrashExtensionHint(error: unknown): string | undefined {
|
|
try {
|
|
return formatCrashExtensionHint(
|
|
findExtensionStackMatches(
|
|
error instanceof Error ? error.stack : undefined,
|
|
this.session.resourceLoader.getExtensions().extensions,
|
|
),
|
|
);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Persist a crash so the next start can point the user at `/bug`. Returns false when nothing was written. */
|
|
private recordCrash(kind: "uncaught_exception" | "fatal_error", error: unknown): boolean {
|
|
try {
|
|
return (
|
|
recordCrash({
|
|
kind,
|
|
error,
|
|
sessionFile: this.session.sessionFile,
|
|
cwd: this.session.sessionManager.getCwd(),
|
|
}) !== undefined
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private crashReportInstructions(): string {
|
|
const resume = this.session.sessionFile ? `run \`${APP_NAME} -r\` to resume the session, then` : "start pi and";
|
|
return `To report this crash: ${resume} run /bug. The crash details are attached automatically.`;
|
|
}
|
|
|
|
private suggestBugReport(): void {
|
|
if (this.bugReportHintShown) return;
|
|
this.bugReportHintShown = true;
|
|
this.chatContainer.addChild(
|
|
new ThemedText(
|
|
() => theme.fg("muted", `If this looks like a ${APP_NAME} bug, /bug sends a report to the developers.`),
|
|
this.outputPad,
|
|
0,
|
|
),
|
|
);
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private maybeSuggestBugReport(message: AssistantMessage): void {
|
|
if (message.stopReason !== "error" || isRetryableAssistantError(message)) return;
|
|
if (/\b(?:abort(?:ed)?|cancel(?:l?ed)?)\b/i.test(message.errorMessage ?? "")) return;
|
|
if (this.maybeShowInstallChangeWarning()) return;
|
|
this.suggestBugReport();
|
|
}
|
|
|
|
/**
|
|
* After an error, check whether an update replaced or removed this install while the session ran.
|
|
* Code loaded on demand then fails with missing modules until restart (#10439). Returns true when
|
|
* the install changed.
|
|
*/
|
|
private maybeShowInstallChangeWarning(): boolean {
|
|
if (this.installChangeWarningShown) return true;
|
|
const change = detectInstallChange();
|
|
if (!change) return false;
|
|
this.installChangeWarningShown = true;
|
|
const cause =
|
|
change.kind === "updated"
|
|
? `${APP_NAME} was updated to ${change.version} while this session was running (${VERSION})`
|
|
: `The ${APP_NAME} installation this session runs from was removed or replaced`;
|
|
const resumeCommand = formatResumeCommand(this.sessionManager);
|
|
const restart = resumeCommand
|
|
? `Restart with \`${resumeCommand}\` to continue this session.`
|
|
: `Restart ${APP_NAME}.`;
|
|
this.showWarning(`${cause}. Features that load code on demand can fail until restart. ${restart}`);
|
|
return true;
|
|
}
|
|
|
|
private renderCurrentSessionState(): void {
|
|
this.loadedResourcesContainer.clear();
|
|
this.chatContainer.clear();
|
|
this.pendingMessagesContainer.clear();
|
|
this.compactionQueuedMessages = [];
|
|
this.streamingComponent = undefined;
|
|
this.streamingMessage = undefined;
|
|
this.pendingTools.clear();
|
|
this.renderInitialMessages();
|
|
}
|
|
|
|
/**
|
|
* Get a registered tool definition by name (for custom rendering).
|
|
*/
|
|
/**
|
|
* Extension-registered definition, falling back to the built-in one. The renderer components take
|
|
* whatever this returns, so they never reach into the tool registry themselves.
|
|
*/
|
|
private getRegisteredToolDefinition(toolName: string) {
|
|
return this.session.extensionRunner.resolveToolRenderers(toolName, () =>
|
|
withBuiltInRenderers(toolName, this.session.getToolDefinition(toolName)),
|
|
);
|
|
}
|
|
|
|
private getMarkdownTransformers(): MarkdownTransformer[] {
|
|
return [this.mermaidMarkdownTransformer, ...this.session.extensionRunner.getMarkdownTransformers()];
|
|
}
|
|
|
|
/** Markdown of a changelog or release note that runs the Markdown transformers of the `changelog` type. */
|
|
private createChangelogMarkdown(
|
|
markdown: string,
|
|
paddingX: number,
|
|
paddingY: number,
|
|
defaultTextStyle?: DefaultTextStyle,
|
|
): Markdown {
|
|
return new Markdown(markdown, paddingX, paddingY, this.getMarkdownThemeWithSettings(), defaultTextStyle, {
|
|
transform: createMarkdownTransform("changelog", () => false, this.getMarkdownTransformers()),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set up keyboard shortcuts registered by extensions.
|
|
*/
|
|
private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void {
|
|
const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());
|
|
if (shortcuts.size === 0) return;
|
|
|
|
// Create a context for shortcut handlers
|
|
const createContext = (): ExtensionContext => ({
|
|
ui: this.createExtensionUIContext(),
|
|
mode: "tui",
|
|
hasUI: true,
|
|
cwd: this.sessionManager.getCwd(),
|
|
sessionManager: this.sessionManager,
|
|
modelRegistry: extensionRunner.getModelRegistry(),
|
|
model: this.session.model,
|
|
scopedModels: this.session.scopedModels,
|
|
thinkingLevel: this.session.thinkingLevel,
|
|
isIdle: () => this.session.isIdle,
|
|
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
|
|
signal: this.session.agent.signal,
|
|
abort: () => {
|
|
this.restoreQueuedMessagesToEditor({ abort: true });
|
|
},
|
|
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
|
shutdown: () => {
|
|
this.shutdownRequested = true;
|
|
},
|
|
getContextUsage: () => this.session.getContextUsage(),
|
|
compact: (options) => {
|
|
void (async () => {
|
|
try {
|
|
const result = await this.session.compact(options?.customInstructions);
|
|
options?.onComplete?.(result);
|
|
} catch (error) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
options?.onError?.(err);
|
|
}
|
|
})();
|
|
},
|
|
getSystemPrompt: () => this.session.systemPrompt,
|
|
});
|
|
|
|
// Set up the extension shortcut handler on the default editor
|
|
this.defaultEditor.onExtensionShortcut = (data: string) => {
|
|
for (const [shortcutStr, shortcut] of shortcuts) {
|
|
// Cast to KeyId - extension shortcuts use the same format
|
|
if (matchesKey(data, shortcutStr as KeyId)) {
|
|
// Run handler async, don't block input
|
|
Promise.resolve(shortcut.handler(createContext())).catch((err) => {
|
|
this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`);
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set extension status text in the footer.
|
|
*/
|
|
private setExtensionStatus(key: string, text: string | undefined): void {
|
|
this.footerDataProvider.setExtensionStatus(key, text);
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private setEditorWorkingStatusIndicator(indicator: StatusIndicator | undefined): boolean {
|
|
this.defaultEditor.setWorkingStatusIndicator(undefined);
|
|
if (!isEmbeddedStatus(this.editor)) return false;
|
|
this.editor.setWorkingStatusIndicator(indicator);
|
|
return true;
|
|
}
|
|
|
|
private showStatusIndicator(indicator: StatusIndicator): void {
|
|
this.activeStatusIndicator?.dispose();
|
|
this.activeStatusIndicator = indicator;
|
|
this.activeWorkingIndicatorEmbedded = false;
|
|
this.statusContainer.clear();
|
|
this.setEditorWorkingStatusIndicator(undefined);
|
|
if (this.setEditorWorkingStatusIndicator(indicator)) {
|
|
this.activeWorkingIndicatorEmbedded = true;
|
|
return;
|
|
}
|
|
this.statusContainer.addChild(indicator);
|
|
}
|
|
|
|
private clearStatusIndicator(kind?: StatusIndicator["kind"]): void {
|
|
if (kind && this.activeStatusIndicator?.kind !== kind) {
|
|
return;
|
|
}
|
|
const clearedIndicator = this.activeStatusIndicator;
|
|
const clearedIndicatorWasEmbedded = this.activeWorkingIndicatorEmbedded;
|
|
clearedIndicator?.dispose();
|
|
this.activeStatusIndicator = undefined;
|
|
this.activeWorkingIndicatorEmbedded = false;
|
|
this.statusContainer.clear();
|
|
this.setEditorWorkingStatusIndicator(undefined);
|
|
if (
|
|
clearedIndicator &&
|
|
!clearedIndicatorWasEmbedded &&
|
|
this.options.tuiMode === "regular" &&
|
|
this.ui.getClearOnShrink()
|
|
) {
|
|
this.statusContainer.addChild(this.idleStatus);
|
|
}
|
|
}
|
|
|
|
private showWorkingStatusIndicator(): void {
|
|
const colorFn = isEmbeddedStatus(this.editor)
|
|
? (text: string) =>
|
|
(this.editor.borderColor ?? theme.getThinkingBorderColor(this.session.thinkingLevel || "off"))(text)
|
|
: undefined;
|
|
this.showStatusIndicator(
|
|
new WorkingStatusIndicator(
|
|
this.ui,
|
|
this.workingMessage ?? this.defaultWorkingMessage,
|
|
this.workingIndicatorOptions,
|
|
colorFn,
|
|
),
|
|
);
|
|
}
|
|
|
|
private setWorkingVisible(visible: boolean): void {
|
|
this.workingVisible = visible;
|
|
if (!visible) {
|
|
this.clearStatusIndicator("working");
|
|
this.ui.requestRender();
|
|
return;
|
|
}
|
|
if (this.session.isStreaming && this.activeStatusIndicator?.kind !== "working") {
|
|
this.showWorkingStatusIndicator();
|
|
}
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private setWorkingIndicator(options?: WorkingIndicatorOptions): void {
|
|
this.workingIndicatorOptions = options;
|
|
if (this.activeStatusIndicator?.kind === "working") {
|
|
this.activeStatusIndicator.setIndicator(options);
|
|
}
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private setHiddenThinkingLabel(label?: string): void {
|
|
this.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel;
|
|
for (const child of this.chatContainer.children) {
|
|
if (child instanceof AssistantMessageComponent) {
|
|
child.setHiddenThinkingLabel(this.hiddenThinkingLabel);
|
|
}
|
|
}
|
|
if (this.streamingComponent) {
|
|
this.streamingComponent.setHiddenThinkingLabel(this.hiddenThinkingLabel);
|
|
}
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Set an extension widget (string array or custom component).
|
|
*/
|
|
private setExtensionWidget(
|
|
key: string,
|
|
content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined,
|
|
options?: ExtensionWidgetOptions,
|
|
): void {
|
|
const placement = options?.placement ?? "aboveEditor";
|
|
const removeExisting = (map: Map<string, Component & { dispose?(): void }>) => {
|
|
const existing = map.get(key);
|
|
if (existing?.dispose) existing.dispose();
|
|
map.delete(key);
|
|
};
|
|
|
|
removeExisting(this.extensionWidgetsAbove);
|
|
removeExisting(this.extensionWidgetsBelow);
|
|
|
|
if (content === undefined) {
|
|
this.renderWidgets();
|
|
return;
|
|
}
|
|
|
|
let component: Component & { dispose?(): void };
|
|
|
|
if (Array.isArray(content)) {
|
|
// Wrap string array in a Container with Text components
|
|
const container = new Container();
|
|
for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) {
|
|
container.addChild(new Text(line, 1, 0));
|
|
}
|
|
if (content.length > InteractiveMode.MAX_WIDGET_LINES) {
|
|
container.addChild(new ThemedText(() => theme.fg("muted", "... (widget truncated)"), 1, 0));
|
|
}
|
|
component = container;
|
|
} else {
|
|
// Factory function - create component
|
|
component = content(this.ui, theme);
|
|
}
|
|
|
|
const targetMap = placement === "belowEditor" ? this.extensionWidgetsBelow : this.extensionWidgetsAbove;
|
|
targetMap.set(key, component);
|
|
this.renderWidgets();
|
|
}
|
|
|
|
private clearExtensionWidgets(): void {
|
|
for (const widget of this.extensionWidgetsAbove.values()) {
|
|
widget.dispose?.();
|
|
}
|
|
for (const widget of this.extensionWidgetsBelow.values()) {
|
|
widget.dispose?.();
|
|
}
|
|
this.extensionWidgetsAbove.clear();
|
|
this.extensionWidgetsBelow.clear();
|
|
this.renderWidgets();
|
|
}
|
|
|
|
private resetExtensionUI(): void {
|
|
if (this.extensionDialogs.length > 0) {
|
|
for (const dialog of this.extensionDialogs) {
|
|
dialog.component.dispose?.();
|
|
}
|
|
this.extensionDialogs = [];
|
|
this.showTopExtensionDialog();
|
|
}
|
|
this.ui.hideOverlay();
|
|
this.clearExtensionTerminalInputListeners();
|
|
this.setExtensionFooter(undefined);
|
|
this.setExtensionHeader(undefined);
|
|
this.clearExtensionWidgets();
|
|
this.footerDataProvider.clearExtensionStatuses();
|
|
this.footer.invalidate();
|
|
this.autocompleteProviderWrappers = [];
|
|
this.setCustomEditorComponent(undefined);
|
|
this.setupAutocompleteProvider();
|
|
this.defaultEditor.onExtensionShortcut = undefined;
|
|
this.updateTerminalTitle();
|
|
this.workingMessage = undefined;
|
|
this.workingVisible = true;
|
|
this.setWorkingIndicator();
|
|
if (this.activeStatusIndicator?.kind === "working") {
|
|
this.activeStatusIndicator.setMessage(
|
|
`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`,
|
|
);
|
|
}
|
|
this.setHiddenThinkingLabel();
|
|
}
|
|
|
|
// Maximum total widget lines to prevent viewport overflow
|
|
private static readonly MAX_WIDGET_LINES = 10;
|
|
|
|
/**
|
|
* Render all extension widgets to the widget container.
|
|
*/
|
|
private renderWidgets(): void {
|
|
if (!this.widgetContainerAbove || !this.widgetContainerBelow) return;
|
|
this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true, true);
|
|
this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false, false);
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private renderWidgetContainer(
|
|
container: Container,
|
|
widgets: Map<string, Component & { dispose?(): void }>,
|
|
spacerWhenEmpty: boolean,
|
|
leadingSpacer: boolean,
|
|
): void {
|
|
container.clear();
|
|
|
|
if (widgets.size === 0) {
|
|
if (spacerWhenEmpty) {
|
|
container.addChild(new Spacer(1));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (leadingSpacer) {
|
|
container.addChild(new Spacer(1));
|
|
}
|
|
for (const component of widgets.values()) {
|
|
container.addChild(component);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a custom footer component, or restore the built-in footer.
|
|
*/
|
|
private setExtensionFooter(
|
|
factory:
|
|
| ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })
|
|
| undefined,
|
|
): void {
|
|
// Dispose existing custom footer
|
|
if (this.customFooter?.dispose) {
|
|
this.customFooter.dispose();
|
|
}
|
|
|
|
this.footerContainer.clear();
|
|
if (factory) {
|
|
// Create and add custom footer, passing the data provider
|
|
this.customFooter = factory(this.ui, theme, this.footerDataProvider);
|
|
this.footerContainer.addChild(this.customFooter);
|
|
} else {
|
|
// Restore built-in footer
|
|
this.customFooter = undefined;
|
|
this.footerContainer.addChild(this.footer);
|
|
}
|
|
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Set a custom header component, or restore the built-in header.
|
|
*/
|
|
private setExtensionHeader(factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined): void {
|
|
// Header may not be initialized yet if called during early initialization
|
|
if (!this.builtInHeader) {
|
|
return;
|
|
}
|
|
|
|
// Dispose existing custom header
|
|
if (this.customHeader?.dispose) {
|
|
this.customHeader.dispose();
|
|
}
|
|
|
|
// Find the index of the current header in the header container
|
|
const currentHeader = this.customHeader || this.builtInHeader;
|
|
const index = this.headerContainer.children.indexOf(currentHeader);
|
|
|
|
if (factory) {
|
|
// Create and add custom header
|
|
this.customHeader = factory(this.ui, theme);
|
|
if (isExpandable(this.customHeader)) {
|
|
this.customHeader.setExpanded(this.toolOutputExpanded);
|
|
}
|
|
if (index !== -1) {
|
|
this.headerContainer.children[index] = this.customHeader;
|
|
} else {
|
|
// If not found (e.g. builtInHeader was never added), add at the top
|
|
this.headerContainer.children.unshift(this.customHeader);
|
|
}
|
|
} else {
|
|
// Restore built-in header
|
|
this.customHeader = undefined;
|
|
if (isExpandable(this.builtInHeader)) {
|
|
this.builtInHeader.setExpanded(this.toolOutputExpanded);
|
|
}
|
|
if (index !== -1) {
|
|
this.headerContainer.children[index] = this.builtInHeader;
|
|
}
|
|
}
|
|
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private addExtensionTerminalInputListener(
|
|
handler: (data: string) => { consume?: boolean; data?: string } | undefined,
|
|
): () => void {
|
|
const subscription = { handler, unsubscribe: this.ui.addInputListener(handler) };
|
|
this.extensionTerminalInputSubscriptions.add(subscription);
|
|
return () => {
|
|
subscription.unsubscribe();
|
|
this.extensionTerminalInputSubscriptions.delete(subscription);
|
|
};
|
|
}
|
|
|
|
private rebindExtensionTerminalInputListeners(): void {
|
|
for (const subscription of this.extensionTerminalInputSubscriptions) {
|
|
subscription.unsubscribe();
|
|
subscription.unsubscribe = this.ui.addInputListener(subscription.handler);
|
|
}
|
|
}
|
|
|
|
private clearExtensionTerminalInputListeners(): void {
|
|
for (const subscription of this.extensionTerminalInputSubscriptions) subscription.unsubscribe();
|
|
this.extensionTerminalInputSubscriptions.clear();
|
|
}
|
|
|
|
/**
|
|
* Create the ExtensionUIContext for extensions.
|
|
*/
|
|
private createProjectTrustContext(cwd: string): ProjectTrustContext {
|
|
const ui = this.createExtensionUIContext();
|
|
return {
|
|
cwd,
|
|
mode: "tui",
|
|
hasUI: true,
|
|
ui: {
|
|
select: ui.select,
|
|
confirm: ui.confirm,
|
|
input: ui.input,
|
|
notify: ui.notify,
|
|
},
|
|
};
|
|
}
|
|
|
|
private createExtensionUIContext(): ExtensionUIContext {
|
|
return {
|
|
select: (title, options, opts) => this.showExtensionSelector(title, options, opts),
|
|
confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts),
|
|
input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
|
|
notify: (message, type) => this.showExtensionNotify(message, type),
|
|
onTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler),
|
|
setStatus: (key, text) => this.setExtensionStatus(key, text),
|
|
setWorkingMessage: (message) => {
|
|
this.workingMessage = message;
|
|
if (this.activeStatusIndicator?.kind === "working") {
|
|
this.activeStatusIndicator.setMessage(message ?? this.defaultWorkingMessage);
|
|
}
|
|
},
|
|
setWorkingVisible: (visible) => this.setWorkingVisible(visible),
|
|
setWorkingIndicator: (options) => this.setWorkingIndicator(options),
|
|
setHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label),
|
|
setWidget: (key, content, options) => this.setExtensionWidget(key, content, options),
|
|
setFooter: (factory) => this.setExtensionFooter(factory),
|
|
setHeader: (factory) => this.setExtensionHeader(factory),
|
|
setTitle: (title) => this.ui.terminal.setTitle(title),
|
|
custom: (factory, options) => this.showExtensionCustom(factory, options),
|
|
pasteToEditor: (text) => this.editor.handleInput(`\x1b[200~${text}\x1b[201~`),
|
|
setEditorText: (text) => this.editor.setText(text),
|
|
getEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(),
|
|
editor: (title, prefill) => this.showExtensionEditor(title, prefill),
|
|
addAutocompleteProvider: (factory) => {
|
|
this.autocompleteProviderWrappers.push(factory);
|
|
this.setupAutocompleteProvider();
|
|
},
|
|
setEditorComponent: (factory) => this.setCustomEditorComponent(factory),
|
|
getEditorComponent: () => this.editorComponentFactory,
|
|
get theme() {
|
|
return theme;
|
|
},
|
|
getAllThemes: () => getAvailableThemesWithPaths(),
|
|
getTheme: (name) => getThemeByName(name),
|
|
setTheme: (themeOrName) => {
|
|
if (themeOrName instanceof Theme) {
|
|
return this.themeController.setThemeInstance(themeOrName);
|
|
}
|
|
const result = this.themeController.setThemeName(themeOrName);
|
|
if (result.success) {
|
|
if (this.settingsManager.getTheme() !== themeOrName) {
|
|
this.settingsManager.setTheme(themeOrName);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
getToolsExpanded: () => this.toolOutputExpanded,
|
|
setToolsExpanded: (expanded) => this.setToolsExpanded(expanded),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Show an extension dialog in the editor slot.
|
|
* Dialogs form a stack: a new dialog hides the visible one, and closing the visible dialog shows
|
|
* the previous one again. Every dialog stays answerable, so no dialog promise is left pending,
|
|
* and dialogs opened from inside another dialog (for example a confirm from a custom component) work.
|
|
*/
|
|
private pushExtensionDialog(component: ExtensionDialogComponent, blocked?: BlockedStatus): void {
|
|
this.disposeActiveSelector();
|
|
this.extensionDialogs.push({ component, blocked });
|
|
this.showTopExtensionDialog();
|
|
}
|
|
|
|
/** Remove an extension dialog. If it was visible, show the previous dialog or the editor. */
|
|
private removeExtensionDialog(component: ExtensionDialogComponent): void {
|
|
const index = this.extensionDialogs.findIndex((dialog) => dialog.component === component);
|
|
if (index === -1) return;
|
|
this.extensionDialogs.splice(index, 1);
|
|
if (index === this.extensionDialogs.length) this.showTopExtensionDialog();
|
|
}
|
|
|
|
private showTopExtensionDialog(): void {
|
|
const top = this.extensionDialogs.at(-1);
|
|
const component = top?.component ?? this.editor;
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(component);
|
|
this.ui.setFocus(component);
|
|
this.programStatus.setBlocked("extension-dialog", top?.blocked);
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Show a selector for extensions.
|
|
*/
|
|
private showExtensionSelector(
|
|
title: string,
|
|
options: string[],
|
|
opts?: ExtensionUIDialogOptions,
|
|
blocked: BlockedStatus = { kind: "question", message: title },
|
|
): Promise<string | undefined> {
|
|
return new Promise((resolve) => {
|
|
if (opts?.signal?.aborted) {
|
|
resolve(undefined);
|
|
return;
|
|
}
|
|
|
|
const finish = (option: string | undefined) => {
|
|
opts?.signal?.removeEventListener("abort", onAbort);
|
|
selector.dispose();
|
|
this.removeExtensionDialog(selector);
|
|
resolve(option);
|
|
};
|
|
const onAbort = () => finish(undefined);
|
|
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
|
|
const selector = new ExtensionSelectorComponent(
|
|
title,
|
|
options,
|
|
(option) => finish(option),
|
|
() => finish(undefined),
|
|
{ tui: this.ui, timeout: opts?.timeout, onToggleToolsExpanded: () => this.toggleToolOutputExpansion() },
|
|
);
|
|
this.pushExtensionDialog(selector, blocked);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show a confirmation dialog for extensions.
|
|
*/
|
|
private async showExtensionConfirm(
|
|
title: string,
|
|
message: string,
|
|
opts?: ExtensionUIDialogOptions,
|
|
): Promise<boolean> {
|
|
const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts, {
|
|
kind: "permission",
|
|
message: title,
|
|
});
|
|
return result === "Yes";
|
|
}
|
|
|
|
private async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise<string | undefined> {
|
|
const confirmed = await this.showExtensionConfirm(
|
|
"Session cwd not found",
|
|
formatMissingSessionCwdPrompt(error.issue),
|
|
);
|
|
return confirmed ? error.issue.fallbackCwd : undefined;
|
|
}
|
|
|
|
/**
|
|
* Show a text input for extensions.
|
|
*/
|
|
private showExtensionInput(
|
|
title: string,
|
|
placeholder?: string,
|
|
opts?: ExtensionUIDialogOptions,
|
|
): Promise<string | undefined> {
|
|
return new Promise((resolve) => {
|
|
if (opts?.signal?.aborted) {
|
|
resolve(undefined);
|
|
return;
|
|
}
|
|
|
|
const finish = (value: string | undefined) => {
|
|
opts?.signal?.removeEventListener("abort", onAbort);
|
|
input.dispose();
|
|
this.removeExtensionDialog(input);
|
|
resolve(value);
|
|
};
|
|
const onAbort = () => finish(undefined);
|
|
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
|
|
const input = new ExtensionInputComponent(
|
|
title,
|
|
placeholder,
|
|
(value) => finish(value),
|
|
() => finish(undefined),
|
|
{ tui: this.ui, timeout: opts?.timeout },
|
|
);
|
|
this.pushExtensionDialog(input, { kind: "question", message: title });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show a multi-line editor for extensions (with Ctrl+G support).
|
|
*/
|
|
private showExtensionEditor(title: string, prefill?: string): Promise<string | undefined> {
|
|
return new Promise((resolve) => {
|
|
const finish = (value: string | undefined) => {
|
|
this.removeExtensionDialog(editor);
|
|
resolve(value);
|
|
};
|
|
const editor = new ExtensionEditorComponent(
|
|
this.ui,
|
|
this.keybindings,
|
|
title,
|
|
prefill,
|
|
(value) => finish(value),
|
|
() => finish(undefined),
|
|
undefined,
|
|
this.settingsManager.getExternalEditorCommand(),
|
|
);
|
|
this.pushExtensionDialog(editor, { kind: "question", message: title });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set a custom editor component from an extension.
|
|
* Pass undefined to restore the default editor.
|
|
*/
|
|
private setCustomEditorComponent(factory: EditorFactory | undefined): void {
|
|
this.editorComponentFactory = factory;
|
|
|
|
// Save text from current editor before switching
|
|
const currentText = this.editor.getExpandedText?.() ?? this.editor.getText();
|
|
|
|
// An open extension dialog keeps the editor slot; the new editor is shown when the last dialog closes.
|
|
const dialogOpen = this.extensionDialogs.length > 0;
|
|
if (!dialogOpen) {
|
|
this.disposeActiveSelector();
|
|
this.editorContainer.clear();
|
|
}
|
|
|
|
if (factory) {
|
|
// Create the custom editor with tui, theme, and keybindings
|
|
const newEditor = factory(this.ui, getEditorTheme(), this.keybindings);
|
|
|
|
// Wire up callbacks from the default editor
|
|
newEditor.onSubmit = this.defaultEditor.onSubmit;
|
|
newEditor.onChange = this.defaultEditor.onChange;
|
|
|
|
// Copy text from previous editor
|
|
newEditor.setText(currentText);
|
|
|
|
// Copy appearance settings if supported
|
|
if (newEditor.borderColor !== undefined) {
|
|
newEditor.borderColor = this.defaultEditor.borderColor;
|
|
}
|
|
if (newEditor.setPaddingX !== undefined) {
|
|
newEditor.setPaddingX(this.defaultEditor.getPaddingX());
|
|
}
|
|
if (newEditor.setAutocompleteMaxVisible !== undefined) {
|
|
newEditor.setAutocompleteMaxVisible(this.defaultEditor.getAutocompleteMaxVisible());
|
|
}
|
|
|
|
// Set autocomplete if supported
|
|
if (newEditor.setAutocompleteProvider && this.autocompleteProvider) {
|
|
newEditor.setAutocompleteProvider(this.autocompleteProvider);
|
|
}
|
|
|
|
// If extending CustomEditor, copy app-level handlers
|
|
// Use duck typing since instanceof fails across jiti module boundaries
|
|
const customEditor = newEditor as unknown as Record<string, unknown>;
|
|
if ("actionHandlers" in customEditor && customEditor.actionHandlers instanceof Map) {
|
|
if (!customEditor.onEscape) {
|
|
customEditor.onEscape = () => this.defaultEditor.onEscape?.();
|
|
}
|
|
if (!customEditor.onCtrlD) {
|
|
customEditor.onCtrlD = () => this.defaultEditor.onCtrlD?.();
|
|
}
|
|
if (!customEditor.onPasteImage) {
|
|
customEditor.onPasteImage = () => this.defaultEditor.onPasteImage?.();
|
|
}
|
|
if (!customEditor.onExtensionShortcut) {
|
|
customEditor.onExtensionShortcut = (data: string) => this.defaultEditor.onExtensionShortcut?.(data);
|
|
}
|
|
// Copy action handlers (clear, suspend, model switching, etc.)
|
|
for (const [action, handler] of this.defaultEditor.actionHandlers) {
|
|
(customEditor.actionHandlers as Map<string, () => void>).set(action, handler);
|
|
}
|
|
}
|
|
|
|
this.editor = newEditor;
|
|
} else {
|
|
// Restore default editor with text from custom editor
|
|
this.defaultEditor.setText(currentText);
|
|
this.editor = this.defaultEditor;
|
|
}
|
|
|
|
if (!dialogOpen) this.editorContainer.addChild(this.editor as Component);
|
|
if (this.activeStatusIndicator) {
|
|
this.statusContainer.clear();
|
|
this.activeWorkingIndicatorEmbedded = this.setEditorWorkingStatusIndicator(this.activeStatusIndicator);
|
|
if (!this.activeWorkingIndicatorEmbedded) {
|
|
this.statusContainer.addChild(this.activeStatusIndicator);
|
|
}
|
|
}
|
|
if (!dialogOpen) this.ui.setFocus(this.editor as Component);
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Show a notification for extensions.
|
|
*/
|
|
private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void {
|
|
if (type === "error") {
|
|
this.showError(message);
|
|
} else if (type === "warning") {
|
|
this.showWarning(message);
|
|
} else {
|
|
this.showStatus(message);
|
|
}
|
|
}
|
|
|
|
/** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */
|
|
private async showExtensionCustom<T>(
|
|
factory: (
|
|
tui: TUI,
|
|
theme: Theme,
|
|
keybindings: KeybindingsManager,
|
|
done: (result: T) => void,
|
|
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
|
options?: {
|
|
overlay?: boolean;
|
|
overlayOptions?: OverlayOptions | (() => OverlayOptions);
|
|
onHandle?: (handle: OverlayHandle) => void;
|
|
blocked?: BlockedStatus;
|
|
},
|
|
): Promise<T> {
|
|
const savedText = this.editor.getExpandedText?.() ?? this.editor.getText();
|
|
const isOverlay = options?.overlay ?? false;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let component: ExtensionDialogComponent | undefined;
|
|
let overlayHandle: OverlayHandle | undefined;
|
|
// An overlay stays blocked until its interaction ends, also while setHidden() hides it.
|
|
const overlayStatusSource =
|
|
isOverlay && options?.blocked ? `extension-overlay:${this.nextBlockingOverlayId++}` : undefined;
|
|
let closed = false;
|
|
|
|
const restoreEditor = () => {
|
|
if (component) this.removeExtensionDialog(component);
|
|
this.editor.setText(savedText);
|
|
this.ui.requestRender();
|
|
};
|
|
|
|
const close = (result: T) => {
|
|
if (closed) return;
|
|
closed = true;
|
|
// Remove this overlay, not the topmost one: another overlay may have opened above it.
|
|
if (isOverlay) overlayHandle?.hide();
|
|
else restoreEditor();
|
|
if (overlayStatusSource) this.programStatus.setBlocked(overlayStatusSource, undefined);
|
|
// Note: both branches above already call requestRender
|
|
resolve(result);
|
|
try {
|
|
component?.dispose?.();
|
|
} catch {
|
|
/* ignore dispose errors */
|
|
}
|
|
};
|
|
|
|
Promise.resolve(factory(this.ui, theme, this.keybindings, close))
|
|
.then((c) => {
|
|
if (closed) return;
|
|
component = c;
|
|
if (isOverlay) {
|
|
// Resolve overlay options - can be static or dynamic function
|
|
const resolveOptions = (): OverlayOptions | undefined => {
|
|
if (options?.overlayOptions) {
|
|
const opts =
|
|
typeof options.overlayOptions === "function"
|
|
? options.overlayOptions()
|
|
: options.overlayOptions;
|
|
return opts;
|
|
}
|
|
// Fallback: use component's width property if available
|
|
const w = (c as { width?: number }).width;
|
|
return w ? { width: w } : undefined;
|
|
};
|
|
overlayHandle = this.ui.showOverlay(c, resolveOptions());
|
|
if (overlayStatusSource) this.programStatus.setBlocked(overlayStatusSource, options?.blocked);
|
|
// Expose handle to caller for visibility control
|
|
options?.onHandle?.(overlayHandle);
|
|
} else {
|
|
this.pushExtensionDialog(c, options?.blocked);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (closed) return;
|
|
if (!isOverlay) restoreEditor();
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show an extension error in the UI.
|
|
*/
|
|
private showExtensionError(extensionPath: string, error: string, stack?: string): void {
|
|
const errorMsg = `Extension "${extensionPath}" error: ${error}`;
|
|
const errorText = new ThemedText(() => theme.fg("error", errorMsg), 1, 0);
|
|
this.chatContainer.addChild(errorText);
|
|
if (stack) {
|
|
// Show stack trace in dim color, indented
|
|
const stackLines = stack.split("\n").slice(1); // Skip first line (duplicates error message)
|
|
if (stackLines.length > 0) {
|
|
const renderStack = () => stackLines.map((line) => theme.fg("dim", ` ${line.trim()}`)).join("\n");
|
|
this.chatContainer.addChild(new ThemedText(renderStack, 1, 0));
|
|
}
|
|
}
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
// =========================================================================
|
|
// Key Handlers
|
|
// =========================================================================
|
|
|
|
private setupKeyHandlers(): void {
|
|
// Set up handlers on defaultEditor - they use this.editor for text access
|
|
// so they work correctly regardless of which editor is active
|
|
this.defaultEditor.onEscape = () => {
|
|
if (this.session.isStreaming) {
|
|
this.restoreQueuedMessagesToEditor({ abort: true });
|
|
} else if (this.session.isBashRunning) {
|
|
this.session.abortBash();
|
|
} else if (this.isBashMode) {
|
|
this.editor.setText("");
|
|
this.isBashMode = false;
|
|
this.updateEditorBorderColor();
|
|
} else if (!this.editor.getText().trim()) {
|
|
// Double-escape with empty editor triggers /tree, /fork, or nothing based on setting
|
|
const action = this.settingsManager.getDoubleEscapeAction();
|
|
if (action !== "none") {
|
|
const now = Date.now();
|
|
if (now - this.lastEscapeTime < 500) {
|
|
if (action === "tree") {
|
|
this.showTreeSelector();
|
|
} else {
|
|
this.showUserMessageSelector();
|
|
}
|
|
this.lastEscapeTime = 0;
|
|
} else {
|
|
this.lastEscapeTime = now;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Register app action handlers
|
|
this.defaultEditor.onAction("app.clear", () => this.handleCtrlC());
|
|
this.defaultEditor.onCtrlD = () => this.handleCtrlD();
|
|
this.defaultEditor.onAction("app.suspend", () => this.handleCtrlZ());
|
|
this.defaultEditor.onAction("app.thinking.cycle", () => this.cycleThinkingLevel());
|
|
this.defaultEditor.onAction("app.model.cycleForward", () => this.cycleModel("forward"));
|
|
this.defaultEditor.onAction("app.model.cycleBackward", () => this.cycleModel("backward"));
|
|
|
|
// Global debug handler on TUI (works regardless of focus)
|
|
this.ui.onDebug = () => this.handleDebugCommand();
|
|
this.defaultEditor.onAction("app.model.select", () => this.showModelSelector());
|
|
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
|
|
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
|
|
this.defaultEditor.onAction("app.editor.external", () => void this.handleOpenExternalEditor());
|
|
this.defaultEditor.onAction(
|
|
"app.message.copy",
|
|
() => void this.handleCopyCommand({ flashConfirmation: true, preferSelection: true }),
|
|
);
|
|
this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
|
|
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
|
|
this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
|
|
this.defaultEditor.onAction("app.session.tree", () => this.showTreeSelector());
|
|
this.defaultEditor.onAction("app.session.fork", () => this.showUserMessageSelector());
|
|
this.defaultEditor.onAction("app.session.resume", () => this.showSessionSelector());
|
|
|
|
this.defaultEditor.onChange = (text: string) => {
|
|
const wasBashMode = this.isBashMode;
|
|
this.isBashMode = text.trimStart().startsWith("!");
|
|
if (wasBashMode !== this.isBashMode) {
|
|
this.updateEditorBorderColor();
|
|
}
|
|
};
|
|
|
|
// Handle clipboard paste (triggered on Ctrl+V). Copied files use their original paths,
|
|
// images are attached via temporary files, and plain text is the final fallback.
|
|
this.defaultEditor.onPasteImage = () => {
|
|
void this.handleClipboardPaste();
|
|
};
|
|
}
|
|
|
|
private async handleRightClickPaste(): Promise<void> {
|
|
const target = this.renderer.getFocusedComponent();
|
|
const handleInput = target?.handleInput;
|
|
if (!target || !handleInput) return;
|
|
try {
|
|
const text = await readClipboardText();
|
|
if (!text || this.renderer.getFocusedComponent() !== target) return;
|
|
handleInput.call(target, `\x1b[200~${text}\x1b[201~`);
|
|
this.ui.requestRender();
|
|
} catch {
|
|
// Silently ignore clipboard errors (may not have permission, etc.)
|
|
}
|
|
}
|
|
|
|
private async handleClipboardPaste(): Promise<void> {
|
|
try {
|
|
const filePaths = await readClipboardFilePaths();
|
|
if (filePaths) {
|
|
if (filePaths.some((filePath) => /\p{Cc}/u.test(filePath))) {
|
|
throw new Error("Clipboard file path contains control characters");
|
|
}
|
|
const paths = this.isBashMode ? filePaths.map(quoteIfNeeded).join(" ") : filePaths.join("\n");
|
|
const cursor = this.editor.getCursor?.();
|
|
const currentLine = cursor ? (this.editor.getText().split("\n")[cursor.line] ?? "") : "";
|
|
const characterBeforeCursor = cursor && cursor.col > 0 ? currentLine[cursor.col - 1] : "";
|
|
const characterAfterCursor = cursor ? currentLine[cursor.col] : "";
|
|
const leadingSpace = characterBeforeCursor && !/\s/.test(characterBeforeCursor) ? " " : "";
|
|
const trailingSpace = characterAfterCursor && !/\s/.test(characterAfterCursor) ? " " : "";
|
|
this.editor.insertTextAtCursor?.(`${leadingSpace}${paths}${trailingSpace}`);
|
|
this.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
const image = await readClipboardImage();
|
|
if (image) {
|
|
const tmpDir = os.tmpdir();
|
|
const ext = extensionForImageMimeType(image.mimeType) ?? "png";
|
|
const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
|
|
const filePath = path.join(tmpDir, fileName);
|
|
fs.writeFileSync(filePath, Buffer.from(image.bytes));
|
|
|
|
this.editor.insertTextAtCursor?.(filePath);
|
|
this.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
const text = await readClipboardText();
|
|
if (text) {
|
|
this.editor.insertTextAtCursor?.(text);
|
|
this.ui.requestRender();
|
|
}
|
|
} catch (error) {
|
|
this.showError(`Failed to paste from clipboard: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
private handleStartupSubmit(text: string): void {
|
|
this.editor.setText(text);
|
|
this.showStatus("Startup is still in progress");
|
|
}
|
|
|
|
private setupEditorSubmitHandler(): void {
|
|
this.defaultEditor.onSubmit = async (text: string) => {
|
|
text = text.trim();
|
|
if (!text) return;
|
|
|
|
// Handle commands
|
|
if (text === "/settings") {
|
|
this.showSettingsSelector();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/scoped-models") {
|
|
this.editor.setText("");
|
|
await this.showModelsSelector();
|
|
return;
|
|
}
|
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
|
this.editor.setText("");
|
|
await this.handleModelCommand(searchTerm);
|
|
return;
|
|
}
|
|
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
|
const searchTerm = text.startsWith("/thinking ") ? text.slice(10).trim() : undefined;
|
|
this.editor.setText("");
|
|
this.handleThinkingCommand(searchTerm);
|
|
return;
|
|
}
|
|
if (text === "/export" || text.startsWith("/export ")) {
|
|
await this.handleExportCommand(text);
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/import" || text.startsWith("/import ")) {
|
|
await this.handleImportCommand(text);
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/share") {
|
|
await this.handleShareCommand();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/bug" || text.startsWith("/bug ")) {
|
|
const hint = text.slice("/bug".length).trim();
|
|
this.editor.setText("");
|
|
await this.handleBugCommand(hint ? hint : undefined);
|
|
return;
|
|
}
|
|
if (text === "/copy") {
|
|
await this.handleCopyCommand();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/name" || text.startsWith("/name ")) {
|
|
this.handleNameCommand(text);
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/session") {
|
|
this.handleSessionCommand();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/changelog") {
|
|
this.handleChangelogCommand();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/hotkeys") {
|
|
this.handleHotkeysCommand();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/fork") {
|
|
this.showUserMessageSelector();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/clone") {
|
|
this.editor.setText("");
|
|
await this.handleCloneCommand();
|
|
return;
|
|
}
|
|
if (text === "/tree") {
|
|
this.showTreeSelector();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/trust") {
|
|
this.showTrustSelector();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/login" || text.startsWith("/login ")) {
|
|
const providerRef = text.startsWith("/login ") ? text.slice(7).trim() : undefined;
|
|
this.editor.setText("");
|
|
await this.handleLoginCommand(providerRef);
|
|
return;
|
|
}
|
|
if (text === "/logout") {
|
|
this.showOAuthSelector("logout");
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/new") {
|
|
this.editor.setText("");
|
|
await this.handleClearCommand();
|
|
return;
|
|
}
|
|
if (text === "/compact" || text.startsWith("/compact ")) {
|
|
const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
|
|
this.editor.setText("");
|
|
await this.handleCompactCommand(customInstructions);
|
|
return;
|
|
}
|
|
if (text === "/reload") {
|
|
this.editor.setText("");
|
|
await this.handleReloadCommand();
|
|
return;
|
|
}
|
|
if (text === "/debug") {
|
|
this.handleDebugCommand();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/arminsayshi") {
|
|
this.handleArminSaysHi();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/dementedelves") {
|
|
this.handleDementedDelves();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/resume") {
|
|
this.showSessionSelector();
|
|
this.editor.setText("");
|
|
return;
|
|
}
|
|
if (text === "/quit") {
|
|
this.editor.setText("");
|
|
await this.shutdown();
|
|
return;
|
|
}
|
|
|
|
// Handle bash command (! for normal, !! for excluded from context)
|
|
if (text.startsWith("!")) {
|
|
const isExcluded = text.startsWith("!!");
|
|
const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
|
|
if (command) {
|
|
if (this.session.isBashRunning) {
|
|
this.showWarning("A bash command is already running. Press Esc to cancel it first.");
|
|
this.editor.setText(text);
|
|
return;
|
|
}
|
|
this.editor.addToHistory?.(text);
|
|
await this.handleBashCommand(command, isExcluded);
|
|
this.isBashMode = false;
|
|
this.updateEditorBorderColor();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Queue input during compaction (extension commands execute immediately)
|
|
if (this.session.isCompacting) {
|
|
if (this.isExtensionCommand(text)) {
|
|
this.editor.addToHistory?.(text);
|
|
this.editor.setText("");
|
|
await this.session.prompt(text);
|
|
} else {
|
|
this.queueCompactionMessage(text, "steer");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If streaming, use prompt() with steer behavior
|
|
// This handles extension commands (execute immediately), prompt template expansion, and queueing
|
|
if (this.session.isStreaming) {
|
|
this.editor.addToHistory?.(text);
|
|
this.editor.setText("");
|
|
await this.session.prompt(text, { streamingBehavior: "steer" });
|
|
this.updatePendingMessagesDisplay();
|
|
this.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
// Normal message submission
|
|
// First, move any pending bash components to chat
|
|
this.flushPendingBashComponents();
|
|
|
|
if (this.onInputCallback) {
|
|
this.onInputCallback(text);
|
|
} else {
|
|
this.pendingUserInputs.push(text);
|
|
}
|
|
this.editor.addToHistory?.(text);
|
|
};
|
|
}
|
|
|
|
private subscribeToAgent(): void {
|
|
this.unsubscribe = this.session.subscribe(async (event) => {
|
|
await this.handleEvent(event);
|
|
});
|
|
}
|
|
|
|
private async handleEvent(event: AgentSessionEvent): Promise<void> {
|
|
if (!this.isInitialized) {
|
|
await this.init();
|
|
}
|
|
|
|
this.footer.invalidate();
|
|
this.programStatus.handleEvent(event);
|
|
|
|
switch (event.type) {
|
|
case "agent_start":
|
|
this.pendingTools.clear();
|
|
// Restore main escape handler if retry handler is still active
|
|
// (retry success event fires later, but we need main handler now)
|
|
if (this.retryEscapeHandler) {
|
|
this.defaultEditor.onEscape = this.retryEscapeHandler;
|
|
this.retryEscapeHandler = undefined;
|
|
}
|
|
break;
|
|
|
|
case "turn_start":
|
|
if (this.settingsManager.getShowTerminalProgress()) {
|
|
this.ui.terminal.setProgress(true);
|
|
}
|
|
if (this.workingVisible) {
|
|
if (this.activeStatusIndicator?.kind !== "working") {
|
|
this.showWorkingStatusIndicator();
|
|
}
|
|
} else {
|
|
this.clearStatusIndicator();
|
|
}
|
|
this.ui.requestRender();
|
|
break;
|
|
|
|
case "queue_update":
|
|
this.updatePendingMessagesDisplay();
|
|
this.ui.requestRender();
|
|
break;
|
|
|
|
case "entry_appended":
|
|
if (this.entriesRenderedByBoundaryCompaction.delete(event.entry.id)) break;
|
|
if (event.entry.type === "custom") {
|
|
this.addCustomEntryToChat(event.entry);
|
|
this.ui.requestRender();
|
|
} else if (event.entry.type === "usage" && event.entry.kind === "cache_warm") {
|
|
this.addCacheWarmingUsage(event.entry);
|
|
this.ui.requestRender();
|
|
} else if (event.entry.type === "custom_message" && event.entry.display) {
|
|
this.addMessageToChat(
|
|
createCustomMessage(
|
|
event.entry.customType,
|
|
event.entry.content,
|
|
event.entry.display,
|
|
event.entry.details,
|
|
event.entry.timestamp,
|
|
),
|
|
);
|
|
this.ui.requestRender();
|
|
} else if (event.entry.type === "compaction") {
|
|
const entries = this.sessionManager.buildContextEntries();
|
|
if (entries[0]?.id !== event.entry.id) break;
|
|
this.chatContainer.clear();
|
|
const branch = this.sessionManager.getBranch();
|
|
const compactionIndex = branch.findIndex((entry) => entry.id === event.entry.id);
|
|
const entriesAfterCompaction = new Set(branch.slice(compactionIndex + 1).map((entry) => entry.id));
|
|
const retainedEntries = entries.slice(1);
|
|
this.renderSessionEntries(retainedEntries.filter((entry) => !entriesAfterCompaction.has(entry.id)));
|
|
this.addMessageToChat(
|
|
createCompactionSummaryMessage(event.entry.summary, event.entry.tokensBefore, event.entry.timestamp),
|
|
);
|
|
if (event.entry.usage) {
|
|
this.addCompactionCostNotice({
|
|
type: "compaction_cost",
|
|
kind: "compaction",
|
|
usage: event.entry.usage,
|
|
});
|
|
}
|
|
this.renderSessionEntries(retainedEntries.filter((entry) => entriesAfterCompaction.has(entry.id)));
|
|
for (const entryId of entriesAfterCompaction) this.entriesRenderedByBoundaryCompaction.add(entryId);
|
|
this.footer.invalidate();
|
|
this.ui.requestRender();
|
|
}
|
|
break;
|
|
|
|
case "session_info_changed":
|
|
this.updateTerminalTitle();
|
|
this.footer.invalidate();
|
|
this.ui.requestRender();
|
|
break;
|
|
|
|
case "thinking_level_changed":
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
break;
|
|
|
|
case "message_start":
|
|
if (event.message.role === "custom") {
|
|
this.addMessageToChat(event.message);
|
|
this.ui.requestRender();
|
|
} else if (event.message.role === "user") {
|
|
this.addMessageToChat(event.message);
|
|
this.updatePendingMessagesDisplay();
|
|
this.ui.requestRender();
|
|
} else if (event.message.role === "assistant") {
|
|
this.streamingComponent = new AssistantMessageComponent(
|
|
undefined,
|
|
this.hideThinkingBlock,
|
|
this.getMarkdownThemeWithSettings(),
|
|
this.hiddenThinkingLabel,
|
|
this.outputPad,
|
|
this.getMarkdownTransformers(),
|
|
);
|
|
this.streamingMessage = event.message;
|
|
this.chatContainer.addChild(this.streamingComponent);
|
|
this.streamingComponent.updateContent(this.streamingMessage, true);
|
|
this.ui.requestRender();
|
|
}
|
|
break;
|
|
|
|
case "message_update":
|
|
if (this.streamingComponent && event.message.role === "assistant") {
|
|
this.streamingMessage = event.message;
|
|
this.streamingComponent.updateContent(this.streamingMessage, true);
|
|
|
|
for (const content of this.streamingMessage.content) {
|
|
if (content.type === "toolCall") {
|
|
if (!this.pendingTools.has(content.id)) {
|
|
const component = new ToolExecutionComponent(
|
|
content.name,
|
|
content.id,
|
|
content.arguments,
|
|
{
|
|
showImages: this.settingsManager.getShowImages(),
|
|
imageWidthCells: this.settingsManager.getImageWidthCells(),
|
|
outputPad: this.outputPad,
|
|
},
|
|
this.getRegisteredToolDefinition(content.name),
|
|
this.ui,
|
|
this.sessionManager.getCwd(),
|
|
);
|
|
component.setExpanded(this.toolOutputExpanded);
|
|
this.chatContainer.addChild(component);
|
|
this.pendingTools.set(content.id, component);
|
|
} else {
|
|
const component = this.pendingTools.get(content.id);
|
|
if (component) {
|
|
component.updateArgs(content.arguments);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.ui.requestRender();
|
|
}
|
|
break;
|
|
|
|
case "message_end":
|
|
if (event.message.role === "user") break;
|
|
if (this.streamingComponent && event.message.role === "assistant") {
|
|
this.streamingMessage = event.message;
|
|
let errorMessage: string | undefined;
|
|
if (this.streamingMessage.stopReason === "aborted") {
|
|
const retryAttempt = this.session.retryAttempt;
|
|
errorMessage =
|
|
retryAttempt > 0
|
|
? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
|
|
: "Operation aborted";
|
|
this.streamingMessage.errorMessage = errorMessage;
|
|
}
|
|
this.streamingComponent.updateContent(this.streamingMessage, false);
|
|
|
|
if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
|
|
if (!errorMessage) {
|
|
errorMessage = this.streamingMessage.errorMessage || "Error";
|
|
}
|
|
for (const [, component] of this.pendingTools.entries()) {
|
|
component.updateResult({
|
|
content: [{ type: "text", text: errorMessage }],
|
|
isError: true,
|
|
});
|
|
}
|
|
this.pendingTools.clear();
|
|
this.maybeSuggestBugReport(this.streamingMessage);
|
|
} else {
|
|
// Args are now complete - trigger diff computation for edit tools
|
|
for (const [, component] of this.pendingTools.entries()) {
|
|
component.setArgsComplete();
|
|
}
|
|
this.maybeShowThinkingDropNotice(this.streamingMessage);
|
|
this.maybeShowCacheMissNotice(this.streamingMessage);
|
|
}
|
|
this.streamingComponent = undefined;
|
|
this.streamingMessage = undefined;
|
|
this.footer.invalidate();
|
|
}
|
|
this.ui.requestRender();
|
|
break;
|
|
|
|
case "bash_execution_update":
|
|
// The bash execution callback handles TUI output rendering.
|
|
break;
|
|
|
|
case "tool_execution_start": {
|
|
// Nested calls (from codemode scripts) are shown inside their parent's row.
|
|
if (event.parentToolCallId) break;
|
|
let component = this.pendingTools.get(event.toolCallId);
|
|
if (!component) {
|
|
component = new ToolExecutionComponent(
|
|
event.toolName,
|
|
event.toolCallId,
|
|
event.args,
|
|
{
|
|
showImages: this.settingsManager.getShowImages(),
|
|
imageWidthCells: this.settingsManager.getImageWidthCells(),
|
|
outputPad: this.outputPad,
|
|
},
|
|
this.getRegisteredToolDefinition(event.toolName),
|
|
this.ui,
|
|
this.sessionManager.getCwd(),
|
|
);
|
|
component.setExpanded(this.toolOutputExpanded);
|
|
this.chatContainer.addChild(component);
|
|
this.pendingTools.set(event.toolCallId, component);
|
|
}
|
|
component.markExecutionStarted();
|
|
this.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "tool_execution_update": {
|
|
const component = this.pendingTools.get(event.toolCallId);
|
|
if (component) {
|
|
component.updateResult({ ...event.partialResult, isError: false }, true);
|
|
this.ui.requestRender();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "tool_execution_end": {
|
|
if (event.isError) this.maybeShowInstallChangeWarning();
|
|
const component = this.pendingTools.get(event.toolCallId);
|
|
if (component) {
|
|
component.updateResult({ ...event.result, isError: event.isError, durationMs: event.durationMs });
|
|
this.pendingTools.delete(event.toolCallId);
|
|
this.ui.requestRender();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "agent_end":
|
|
if (this.settingsManager.getShowTerminalProgress()) {
|
|
this.ui.terminal.setProgress(false);
|
|
}
|
|
this.clearStatusIndicator("working");
|
|
if (this.streamingComponent) {
|
|
this.chatContainer.removeChild(this.streamingComponent);
|
|
this.streamingComponent = undefined;
|
|
this.streamingMessage = undefined;
|
|
}
|
|
this.pendingTools.clear();
|
|
|
|
this.ui.requestRender();
|
|
break;
|
|
|
|
case "agent_settled":
|
|
await this.checkShutdownRequested();
|
|
break;
|
|
|
|
case "compaction_start": {
|
|
if (this.settingsManager.getShowTerminalProgress()) {
|
|
this.ui.terminal.setProgress(true);
|
|
}
|
|
// Keep editor active; submissions are queued during compaction.
|
|
this.autoCompactionEscapeHandler = this.defaultEditor.onEscape;
|
|
this.defaultEditor.onEscape = () => {
|
|
this.session.abortCompaction();
|
|
};
|
|
const colorFn = isEmbeddedStatus(this.editor)
|
|
? (text: string) =>
|
|
(this.editor.borderColor ?? theme.getThinkingBorderColor(this.session.thinkingLevel || "off"))(
|
|
text,
|
|
)
|
|
: undefined;
|
|
this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason, colorFn));
|
|
this.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "compaction_end": {
|
|
if (this.settingsManager.getShowTerminalProgress()) {
|
|
this.ui.terminal.setProgress(false);
|
|
}
|
|
if (this.autoCompactionEscapeHandler) {
|
|
this.defaultEditor.onEscape = this.autoCompactionEscapeHandler;
|
|
this.autoCompactionEscapeHandler = undefined;
|
|
}
|
|
this.clearStatusIndicator("compaction");
|
|
if (event.aborted) {
|
|
if (event.reason === "manual") {
|
|
this.showError("Compaction cancelled");
|
|
} else {
|
|
this.showStatus("Auto-compaction cancelled");
|
|
}
|
|
} else if (event.result) {
|
|
const entries = this.sessionManager.buildContextEntries();
|
|
if (entries[0]?.type !== "compaction") {
|
|
throw new Error("Completed compaction is missing from the session context");
|
|
}
|
|
this.chatContainer.clear();
|
|
// The latest compaction is prepended for model context; append it below at its chronological position.
|
|
this.renderSessionEntries(entries.slice(1));
|
|
this.addMessageToChat(
|
|
createCompactionSummaryMessage(
|
|
event.result.summary,
|
|
event.result.tokensBefore,
|
|
new Date().toISOString(),
|
|
),
|
|
);
|
|
if (event.result.usage) {
|
|
this.addCompactionCostNotice({
|
|
type: "compaction_cost",
|
|
kind: "compaction",
|
|
usage: event.result.usage,
|
|
});
|
|
}
|
|
this.footer.invalidate();
|
|
} else if (event.errorMessage) {
|
|
if (event.reason === "manual") {
|
|
this.showError(event.errorMessage);
|
|
} else {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
const errorMessage = event.errorMessage;
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg("error", errorMessage), 1, 0));
|
|
}
|
|
}
|
|
void this.flushCompactionQueue({ willRetry: event.willRetry });
|
|
this.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "auto_retry_start": {
|
|
// Set up escape to abort retry
|
|
this.retryEscapeHandler = this.defaultEditor.onEscape;
|
|
this.defaultEditor.onEscape = () => {
|
|
this.session.abortRetry();
|
|
};
|
|
this.showStatusIndicator(
|
|
new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs),
|
|
);
|
|
this.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "auto_retry_end": {
|
|
// Restore escape handler
|
|
if (this.retryEscapeHandler) {
|
|
this.defaultEditor.onEscape = this.retryEscapeHandler;
|
|
this.retryEscapeHandler = undefined;
|
|
}
|
|
this.clearStatusIndicator("retry");
|
|
// Show error only on final failure (success shows normal response)
|
|
if (!event.success) {
|
|
this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
|
|
}
|
|
this.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "summarization_retry_scheduled": {
|
|
this.showError(event.errorMessage);
|
|
this.showStatusIndicator(
|
|
new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs),
|
|
);
|
|
this.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "summarization_retry_attempt_start": {
|
|
this.clearStatusIndicator("retry");
|
|
if (event.source === "branchSummary") {
|
|
this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui));
|
|
} else {
|
|
const colorFn = isEmbeddedStatus(this.editor)
|
|
? (text: string) =>
|
|
(this.editor.borderColor ?? theme.getThinkingBorderColor(this.session.thinkingLevel || "off"))(
|
|
text,
|
|
)
|
|
: undefined;
|
|
this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason, colorFn));
|
|
}
|
|
this.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "summarization_retry_finished": {
|
|
this.clearStatusIndicator("retry");
|
|
this.ui.requestRender();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Extract text content from a user message */
|
|
private getUserMessageText(message: Message): string {
|
|
if (message.role !== "user") return "";
|
|
const textBlocks =
|
|
typeof message.content === "string"
|
|
? [{ type: "text", text: message.content }]
|
|
: message.content.filter((c: { type: string }) => c.type === "text");
|
|
return textBlocks.map((c) => (c as { text: string }).text).join("");
|
|
}
|
|
|
|
/** Show a managed-tool status update in the chat. */
|
|
private showManagedToolStatus(status: ToolStatus): void {
|
|
if (!this.managedToolStatusStarted) {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.managedToolStatusStarted = true;
|
|
}
|
|
const message = status.type === "warning" ? `Warning: ${status.message}` : status.message;
|
|
const color = status.type === "warning" ? "warning" : "dim";
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg(color, message), 1, 0));
|
|
this.lastStatusSpacer = undefined;
|
|
this.lastStatusText = undefined;
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Show a status message in the chat.
|
|
*
|
|
* If multiple status messages are emitted back-to-back (without anything else being added to the chat),
|
|
* we update the previous status line instead of appending new ones to avoid log spam.
|
|
*/
|
|
private showStatus(message: string): void {
|
|
const children = this.chatContainer.children;
|
|
const last = children.length > 0 ? children[children.length - 1] : undefined;
|
|
const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
|
|
|
|
if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
|
|
this.lastStatusMessage = message;
|
|
this.lastStatusText.invalidate();
|
|
this.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
const spacer = new Spacer(1);
|
|
this.lastStatusMessage = message;
|
|
const text = new ThemedText(() => theme.fg("dim", this.lastStatusMessage), 1, 0);
|
|
this.chatContainer.addChild(spacer);
|
|
this.chatContainer.addChild(text);
|
|
this.lastStatusSpacer = spacer;
|
|
this.lastStatusText = text;
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private addCustomEntryToChat(entry: Extract<SessionEntry, { type: "custom" }>): void {
|
|
const renderer = this.session.extensionRunner.getEntryRenderer(entry.customType);
|
|
if (!renderer) {
|
|
return;
|
|
}
|
|
const component = new CustomEntryComponent(entry, renderer, this.outputPad);
|
|
component.setExpanded(this.toolOutputExpanded);
|
|
if (!component.hasContent()) {
|
|
return;
|
|
}
|
|
|
|
if (this.streamingComponent) {
|
|
const streamingIndex = this.chatContainer.children.indexOf(this.streamingComponent);
|
|
if (streamingIndex >= 0) {
|
|
this.chatContainer.children.splice(streamingIndex, 0, component);
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.chatContainer.addChild(component);
|
|
}
|
|
|
|
private addMessageToChat(
|
|
message: AgentMessage,
|
|
options?: { populateHistory?: boolean; messageId?: string; timestamp?: string },
|
|
): void {
|
|
switch (message.role) {
|
|
case "bashExecution": {
|
|
const component = new BashExecutionComponent(
|
|
message.command,
|
|
this.ui,
|
|
message.excludeFromContext,
|
|
this.outputPad,
|
|
);
|
|
if (message.output) {
|
|
component.appendOutput(message.output);
|
|
}
|
|
component.setComplete(
|
|
message.exitCode,
|
|
message.cancelled,
|
|
message.truncated ? ({ truncated: true } as TruncationResult) : undefined,
|
|
message.fullOutputPath,
|
|
);
|
|
this.chatContainer.addChild(component);
|
|
break;
|
|
}
|
|
case "custom": {
|
|
if (message.display) {
|
|
const renderer = this.session.extensionRunner.getMessageRenderer(message.customType);
|
|
const component = new CustomMessageComponent(
|
|
message,
|
|
renderer,
|
|
this.getMarkdownThemeWithSettings(),
|
|
this.outputPad,
|
|
this.getMarkdownTransformers(),
|
|
options?.messageId,
|
|
options?.timestamp,
|
|
);
|
|
component.setExpanded(this.toolOutputExpanded);
|
|
this.chatContainer.addChild(component);
|
|
}
|
|
break;
|
|
}
|
|
case "compactionSummary": {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
const component = new CompactionSummaryMessageComponent(
|
|
message,
|
|
this.getMarkdownThemeWithSettings(),
|
|
this.outputPad,
|
|
this.getMarkdownTransformers(),
|
|
options?.messageId,
|
|
options?.timestamp,
|
|
);
|
|
component.setExpanded(this.toolOutputExpanded);
|
|
this.chatContainer.addChild(component);
|
|
break;
|
|
}
|
|
case "branchSummary": {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
const component = new BranchSummaryMessageComponent(
|
|
message,
|
|
this.getMarkdownThemeWithSettings(),
|
|
this.outputPad,
|
|
this.getMarkdownTransformers(),
|
|
options?.messageId,
|
|
options?.timestamp,
|
|
);
|
|
component.setExpanded(this.toolOutputExpanded);
|
|
this.chatContainer.addChild(component);
|
|
break;
|
|
}
|
|
case "system":
|
|
break;
|
|
case "user": {
|
|
const textContent = this.getUserMessageText(message);
|
|
if (textContent) {
|
|
if (this.chatContainer.children.length > 0) {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
}
|
|
const skillBlock = parseSkillBlock(textContent);
|
|
if (skillBlock) {
|
|
// Render skill block (collapsible)
|
|
const component = new SkillInvocationMessageComponent(
|
|
skillBlock,
|
|
this.getMarkdownThemeWithSettings(),
|
|
this.outputPad,
|
|
this.getMarkdownTransformers(),
|
|
options?.messageId,
|
|
options?.timestamp,
|
|
);
|
|
component.setExpanded(this.toolOutputExpanded);
|
|
this.chatContainer.addChild(component);
|
|
// Render user message separately if present
|
|
if (skillBlock.userMessage) {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
const userComponent = new UserMessageComponent(
|
|
skillBlock.userMessage,
|
|
this.getMarkdownThemeWithSettings(),
|
|
this.outputPad,
|
|
this.getMarkdownTransformers(),
|
|
options?.messageId,
|
|
options?.timestamp,
|
|
);
|
|
this.chatContainer.addChild(userComponent);
|
|
}
|
|
} else {
|
|
const userComponent = new UserMessageComponent(
|
|
textContent,
|
|
this.getMarkdownThemeWithSettings(),
|
|
this.outputPad,
|
|
this.getMarkdownTransformers(),
|
|
options?.messageId,
|
|
options?.timestamp,
|
|
);
|
|
this.chatContainer.addChild(userComponent);
|
|
}
|
|
if (options?.populateHistory) {
|
|
this.editor.addToHistory?.(textContent);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "assistant": {
|
|
const assistantComponent = new AssistantMessageComponent(
|
|
message,
|
|
this.hideThinkingBlock,
|
|
this.getMarkdownThemeWithSettings(),
|
|
this.hiddenThinkingLabel,
|
|
this.outputPad,
|
|
this.getMarkdownTransformers(),
|
|
options?.messageId,
|
|
options?.timestamp,
|
|
);
|
|
this.chatContainer.addChild(assistantComponent);
|
|
break;
|
|
}
|
|
case "toolResult": {
|
|
// Tool results are rendered inline with tool calls, handled separately
|
|
break;
|
|
}
|
|
default: {
|
|
const _exhaustive: never = message;
|
|
}
|
|
}
|
|
}
|
|
|
|
private renderSessionItems(
|
|
items: readonly RenderSessionItem[],
|
|
messageMeta?: Map<AgentMessage, { id: string; timestamp: string }>,
|
|
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
|
|
): void {
|
|
this.pendingTools.clear();
|
|
const renderedPendingTools = new Map<string, ToolExecutionComponent>();
|
|
// Cache misses are not persisted, unlike successful cache-warming usage.
|
|
// Re-derive them and inject them after the assistant messages that paid for them.
|
|
const cacheMisses = this.settingsManager.getShowCacheMissNotices()
|
|
? collectCacheMisses(this.sessionManager.getEntries(), this.session.modelRuntime)
|
|
: new Map<AssistantMessage, CacheMiss>();
|
|
|
|
if (options.updateFooter) {
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
}
|
|
|
|
for (const item of items) {
|
|
if (isCustomSessionEntry(item)) {
|
|
this.addCustomEntryToChat(item);
|
|
continue;
|
|
}
|
|
if (isUsageSessionEntry(item)) {
|
|
this.addCacheWarmingUsage(item);
|
|
continue;
|
|
}
|
|
if (isCompactionCostNotice(item)) {
|
|
this.addCompactionCostNotice(item);
|
|
continue;
|
|
}
|
|
|
|
const message = item;
|
|
// Assistant messages need special handling for tool calls
|
|
if (message.role === "assistant") {
|
|
const meta = messageMeta?.get(message);
|
|
this.addMessageToChat(message, {
|
|
messageId: meta?.id,
|
|
timestamp: meta?.timestamp,
|
|
});
|
|
// Render tool call components
|
|
for (const content of message.content) {
|
|
if (content.type === "toolCall") {
|
|
const component = new ToolExecutionComponent(
|
|
content.name,
|
|
content.id,
|
|
content.arguments,
|
|
{
|
|
showImages: this.settingsManager.getShowImages(),
|
|
imageWidthCells: this.settingsManager.getImageWidthCells(),
|
|
outputPad: this.outputPad,
|
|
},
|
|
this.getRegisteredToolDefinition(content.name),
|
|
this.ui,
|
|
this.sessionManager.getCwd(),
|
|
);
|
|
component.setExpanded(this.toolOutputExpanded);
|
|
this.chatContainer.addChild(component);
|
|
|
|
if (message.stopReason === "aborted" || message.stopReason === "error") {
|
|
let errorMessage: string;
|
|
if (message.stopReason === "aborted") {
|
|
const retryAttempt = this.session.retryAttempt;
|
|
errorMessage =
|
|
retryAttempt > 0
|
|
? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
|
|
: "Operation aborted";
|
|
} else {
|
|
errorMessage = message.errorMessage || "Error";
|
|
}
|
|
component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
|
|
} else {
|
|
renderedPendingTools.set(content.id, component);
|
|
}
|
|
}
|
|
}
|
|
if (message.stopReason !== "aborted" && message.stopReason !== "error") {
|
|
const miss = cacheMisses.get(message);
|
|
if (miss) this.addCacheMissNotice(miss);
|
|
}
|
|
} else if (message.role === "toolResult") {
|
|
// Match tool results to pending tool components
|
|
const component = renderedPendingTools.get(message.toolCallId);
|
|
if (component) {
|
|
component.updateResult(message);
|
|
renderedPendingTools.delete(message.toolCallId);
|
|
}
|
|
} else {
|
|
// All other messages use standard rendering
|
|
const meta = messageMeta?.get(message);
|
|
this.addMessageToChat(message, {
|
|
...options,
|
|
messageId: meta?.id,
|
|
timestamp: meta?.timestamp,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const [toolCallId, component] of renderedPendingTools) {
|
|
this.pendingTools.set(toolCallId, component);
|
|
}
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Render session entries to chat. Used for initial load and rebuild after compaction.
|
|
* @param entries Compaction-aware session entries to render
|
|
* @param options.updateFooter Update footer state
|
|
* @param options.populateHistory Add user messages to editor history
|
|
*/
|
|
private renderSessionEntries(
|
|
entries: SessionEntry[],
|
|
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
|
|
): void {
|
|
// Selection coordinates point into the transcript being replaced (#9311).
|
|
if (this.renderer instanceof TuiAltScreen) this.renderer.resetTextSelection();
|
|
const messageMeta = new Map<AgentMessage, { id: string; timestamp: string }>();
|
|
const items = entries.flatMap((entry): RenderSessionItem[] => {
|
|
if (entry.type === "custom" || (entry.type === "usage" && entry.kind === "cache_warm")) {
|
|
return [entry];
|
|
}
|
|
const messages = sessionEntryToContextMessages(entry);
|
|
for (const msg of messages) {
|
|
messageMeta.set(msg, { id: entry.id, timestamp: entry.timestamp });
|
|
}
|
|
if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && messages.length > 0) {
|
|
return [...messages, { type: "compaction_cost", kind: entry.type, usage: entry.usage }];
|
|
}
|
|
return messages;
|
|
});
|
|
this.renderSessionItems(items, messageMeta, options);
|
|
}
|
|
|
|
private addCacheWarmingUsage(entry: UsageEntry): void {
|
|
if (!this.settingsManager.getShowCacheMissNotices()) return;
|
|
// Back-to-back warms collapse into one line with the cumulative cost.
|
|
const previous = this.cacheWarmingNotice;
|
|
if (previous && this.chatContainer.children.at(-1) === previous.component && previous.note === entry.note) {
|
|
previous.total.cost += entry.usage.cost.total;
|
|
previous.component.invalidate();
|
|
return;
|
|
}
|
|
const total = { cost: entry.usage.cost.total };
|
|
const component = new ThemedText(() => theme.fg("dim", formatCacheWarmingUsage(total.cost, entry.note)), 1, 0);
|
|
this.cacheWarmingNotice = { component, total, note: entry.note };
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(component);
|
|
}
|
|
|
|
/**
|
|
* Render billing usage for a compaction or branch summary. The notice is derived
|
|
* from persisted summary usage and is not stored as a separate session entry.
|
|
*/
|
|
private addCompactionCostNotice(notice: CompactionCostNotice): void {
|
|
if (!this.settingsManager.getShowCacheMissNotices()) return;
|
|
|
|
const { usage } = notice;
|
|
const tokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
|
const cost = usage.cost.total >= 0.01 ? ` (~$${usage.cost.total.toFixed(2)})` : "";
|
|
const label = notice.kind === "compaction" ? "Compaction" : "Branch summary";
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(
|
|
new ThemedText(() => theme.fg("warning", `${label}: ${formatTokens(tokens)} tokens billed${cost}`), 1, 0),
|
|
);
|
|
}
|
|
|
|
private static countDroppedThinkingBlocks(message: AssistantMessage): number {
|
|
let count = 0;
|
|
for (const diagnostic of message.diagnostics ?? []) {
|
|
if (diagnostic.type !== "anthropic_input_transformations") continue;
|
|
const transformations = diagnostic.details?.transformations;
|
|
if (!Array.isArray(transformations)) continue;
|
|
count += transformations.filter(
|
|
(transformation) =>
|
|
typeof transformation === "object" &&
|
|
transformation !== null &&
|
|
(transformation as Record<string, unknown>).type === "thinking_dropped",
|
|
).length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private maybeShowThinkingDropNotice(message: AssistantMessage): void {
|
|
if (!this.settingsManager.getShowCacheMissNotices()) return;
|
|
|
|
const droppedCount = InteractiveMode.countDroppedThinkingBlocks(message);
|
|
if (droppedCount === 0) return;
|
|
|
|
let previousDroppedCount = 0;
|
|
// message_end reaches the UI before the current message is persisted,
|
|
// so the branch's last assistant message is the previous response.
|
|
const branch = this.sessionManager.getBranch();
|
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
const entry = branch[i];
|
|
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
previousDroppedCount = InteractiveMode.countDroppedThinkingBlocks(entry.message);
|
|
break;
|
|
}
|
|
}
|
|
if (droppedCount <= previousDroppedCount) return;
|
|
|
|
const noun = droppedCount === 1 ? "thinking block" : "thinking blocks";
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(
|
|
new ThemedText(
|
|
() => theme.fg("warning", `Anthropic dropped ${droppedCount} ${noun} (details in session)`),
|
|
1,
|
|
0,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Show a transcript notice when a completed assistant message paid for a
|
|
* significant cache miss. Only states observable facts: the miss itself,
|
|
* a model switch, or an idle gap past the cache TTL.
|
|
*/
|
|
private maybeShowCacheMissNotice(message: AssistantMessage): void {
|
|
if (!this.settingsManager.getShowCacheMissNotices()) return;
|
|
|
|
// Entries don't contain `message` yet: message_end fires before persistence.
|
|
const miss = detectCacheMiss(this.sessionManager.getEntries(), message, this.session.modelRuntime);
|
|
if (miss) this.addCacheMissNotice(miss);
|
|
}
|
|
|
|
private addCacheMissNotice(miss: CacheMiss): void {
|
|
if (miss.missedTokens < 20_000 && miss.missedCost < 0.1) return;
|
|
|
|
const cost = miss.missedCost >= 0.01 ? ` (~$${miss.missedCost.toFixed(2)})` : "";
|
|
const reBilled = `${formatTokens(miss.missedTokens)} tokens re-billed${cost}`;
|
|
let label = "Cache miss";
|
|
if (miss.modelChanged) {
|
|
label = "Cache miss after model switch";
|
|
} else if (miss.idleMs >= CACHE_TTL_MS) {
|
|
label = `Cache miss after ${Math.round(miss.idleMs / 60_000)}m idle`;
|
|
}
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg("warning", `${label}: ${reBilled}`), 1, 0));
|
|
}
|
|
|
|
renderInitialMessages(): void {
|
|
const entries = this.sessionManager.buildContextEntries();
|
|
this.renderSessionEntries(entries, {
|
|
updateFooter: true,
|
|
populateHistory: true,
|
|
});
|
|
this.renderProjectTrustWarningIfNeeded();
|
|
|
|
// Show compaction info if session was compacted
|
|
const allEntries = this.sessionManager.getEntries();
|
|
const compactionCount = allEntries.filter((e) => e.type === "compaction").length;
|
|
if (compactionCount > 0) {
|
|
const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
|
|
this.showStatus(`Session compacted ${times}`);
|
|
}
|
|
}
|
|
|
|
private renderProjectTrustWarningIfNeeded(): void {
|
|
if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) {
|
|
return;
|
|
}
|
|
|
|
if (this.chatContainer.children.length > 0) {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
}
|
|
this.chatContainer.addChild(
|
|
new ThemedText(
|
|
() =>
|
|
theme.fg(
|
|
"warning",
|
|
`This project is not trusted. Project ${CONFIG_DIR_NAME} resources and packages are ignored. Use /trust to save a trust decision, then restart pi.`,
|
|
),
|
|
1,
|
|
0,
|
|
),
|
|
);
|
|
}
|
|
|
|
async getUserInput(): Promise<string> {
|
|
const queuedInput = this.pendingUserInputs.shift();
|
|
if (queuedInput !== undefined) {
|
|
return queuedInput;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
this.onInputCallback = (text: string) => {
|
|
this.onInputCallback = undefined;
|
|
resolve(text);
|
|
};
|
|
});
|
|
}
|
|
|
|
private rebuildChatFromMessages(): void {
|
|
this.chatContainer.clear();
|
|
this.renderSessionEntries(this.sessionManager.buildContextEntries());
|
|
}
|
|
|
|
// =========================================================================
|
|
// Key handlers
|
|
// =========================================================================
|
|
|
|
private handleCtrlC(): void {
|
|
const now = Date.now();
|
|
if (now - this.lastSigintTime < 500) {
|
|
void this.shutdown();
|
|
} else {
|
|
this.clearEditor();
|
|
this.lastSigintTime = now;
|
|
}
|
|
}
|
|
|
|
private handleCtrlD(): void {
|
|
// Only called when editor is empty (enforced by CustomEditor)
|
|
void this.shutdown();
|
|
}
|
|
|
|
/**
|
|
* Gracefully shutdown the agent.
|
|
* Stops the TUI before emitting shutdown events so extension UI cleanup cannot
|
|
* repaint the final frame while the process is exiting.
|
|
*/
|
|
private isShuttingDown = false;
|
|
|
|
private async shutdown(options?: { fromSignal?: boolean }): Promise<void> {
|
|
if (this.isShuttingDown) return;
|
|
this.isShuttingDown = true;
|
|
// Keep signal handlers registered until terminal cleanup has completed.
|
|
// `signal-exit` checks the listener list during the same SIGTERM/SIGHUP
|
|
// dispatch and re-sends the signal if only its own listeners remain.
|
|
|
|
if (options?.fromSignal) {
|
|
// Signal-triggered shutdown (SIGTERM/SIGHUP). Emit extension cleanup
|
|
// (session_shutdown) BEFORE touching the terminal. Extension teardown
|
|
// such as removing sockets does not write to the tty, so it must not be
|
|
// skipped if a later terminal-restore write fails on a dead or stalled
|
|
// terminal. If the terminal is gone, the restore writes below emit EIO,
|
|
// which the stdout/stderr error handler turns into emergencyTerminalExit;
|
|
// the render loop is already idle, so this cannot hot-spin (see #4144).
|
|
await this.runtimeHost.dispose();
|
|
this.themeController.disableAutoSync();
|
|
await this.ui.terminal.drainInput(1000);
|
|
this.stop();
|
|
process.exit(0);
|
|
}
|
|
|
|
// Interactive quit (Ctrl+D, Ctrl+C, /quit, extension shutdown()). Stop the
|
|
// TUI before emitting shutdown events so extension UI cleanup cannot repaint
|
|
// the final frame while the process is exiting.
|
|
// Drain any in-flight Kitty key release events before stopping.
|
|
// This prevents escape sequences from leaking to the parent shell over slow SSH.
|
|
this.themeController.disableAutoSync();
|
|
await this.ui.terminal.drainInput(1000);
|
|
|
|
this.stop();
|
|
await this.runtimeHost.dispose();
|
|
|
|
const resumeCommand = formatResumeCommand(this.sessionManager);
|
|
if (resumeCommand) {
|
|
process.stdout.write(`${chalk.dim("To resume this session:")} ${resumeCommand}\n`);
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
private emergencyTerminalExit(): never {
|
|
this.isShuttingDown = true;
|
|
this.unregisterSignalHandlers();
|
|
killTrackedDetachedChildren();
|
|
// The terminal is gone. Do not run normal shutdown because TUI and
|
|
// extension cleanup can write restore sequences and re-trigger EIO.
|
|
process.exit(129);
|
|
}
|
|
|
|
/**
|
|
* Last-resort handler for uncaught exceptions. The TUI puts stdin into raw
|
|
* mode and hides the cursor; without this handler, an uncaught throw from
|
|
* anywhere (e.g. an extension's async `ChildProcess.on("exit")` callback)
|
|
* tears down the process while leaving the terminal in raw mode with no
|
|
* cursor, requiring `stty sane && reset` to recover.
|
|
*
|
|
* Unlike emergencyTerminalExit, the terminal is still alive here, so we
|
|
* call ui.stop() to restore cooked mode, the cursor, and disable bracketed
|
|
* paste / Kitty / modifyOtherKeys sequences.
|
|
*/
|
|
private uncaughtCrash(error: Error): never {
|
|
// A dead terminal is not a pi crash. Do not try to restore it or record it.
|
|
if (isDeadTerminalError(error)) {
|
|
this.emergencyTerminalExit();
|
|
}
|
|
if (this.isShuttingDown) {
|
|
process.exit(1);
|
|
}
|
|
this.isShuttingDown = true;
|
|
try {
|
|
this.unregisterSignalHandlers();
|
|
} catch {}
|
|
try {
|
|
killTrackedDetachedChildren();
|
|
} catch {}
|
|
try {
|
|
this.ui.stop();
|
|
} catch {}
|
|
console.error(`${APP_NAME} exiting due to uncaughtException:`);
|
|
console.error(error);
|
|
const extensionHint = this.getCrashExtensionHint(error);
|
|
if (extensionHint) console.error(`\n${extensionHint}`);
|
|
if (this.recordCrash("uncaught_exception", error)) {
|
|
console.error(`\n${this.crashReportInstructions()}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Check if shutdown was requested and perform shutdown if so.
|
|
*/
|
|
private async checkShutdownRequested(): Promise<void> {
|
|
if (!this.shutdownRequested) return;
|
|
await this.shutdown();
|
|
}
|
|
|
|
private registerSignalHandlers(): void {
|
|
this.unregisterSignalHandlers();
|
|
|
|
const signals: NodeJS.Signals[] = ["SIGTERM"];
|
|
if (process.platform !== "win32") {
|
|
signals.push("SIGHUP");
|
|
}
|
|
|
|
for (const signal of signals) {
|
|
const handler = () => {
|
|
// SIGHUP no longer hard-exits: graceful shutdown emits session_shutdown
|
|
// first, then attempts terminal restore. A genuinely dead terminal
|
|
// surfaces as an EIO on the restore writes, which the stdout/stderr
|
|
// error handler converts into emergencyTerminalExit (see #4144, #5080).
|
|
killTrackedDetachedChildren();
|
|
void this.shutdown({ fromSignal: true });
|
|
};
|
|
process.prependListener(signal, handler);
|
|
this.signalCleanupHandlers.push(() => process.off(signal, handler));
|
|
}
|
|
|
|
const terminalErrorHandler = (error: Error) => {
|
|
if (isDeadTerminalError(error)) {
|
|
this.emergencyTerminalExit();
|
|
}
|
|
throw error;
|
|
};
|
|
// stdin needs the handler too: once the terminal is gone, reads and setRawMode
|
|
// fail with EIO (orphaned background process group) or ENOTTY (revoked tty).
|
|
// Node emits these as stream errors, which are uncaught without a listener.
|
|
for (const stream of [process.stdin, process.stdout, process.stderr]) {
|
|
stream.on("error", terminalErrorHandler);
|
|
this.signalCleanupHandlers.push(() => stream.off("error", terminalErrorHandler));
|
|
}
|
|
|
|
// Restore the terminal before the process dies on any uncaught throw.
|
|
// Without this, an unhandled exception from extension code (or anywhere
|
|
// in pi) leaves the terminal in raw mode with no cursor.
|
|
const uncaughtExceptionHandler = (error: Error) => this.uncaughtCrash(error);
|
|
process.prependListener("uncaughtException", uncaughtExceptionHandler);
|
|
this.signalCleanupHandlers.push(() => process.off("uncaughtException", uncaughtExceptionHandler));
|
|
}
|
|
|
|
private unregisterSignalHandlers(): void {
|
|
for (const cleanup of this.signalCleanupHandlers) {
|
|
cleanup();
|
|
}
|
|
this.signalCleanupHandlers = [];
|
|
}
|
|
|
|
private handleCtrlZ(): void {
|
|
if (process.platform === "win32") {
|
|
this.showStatus("Suspend to background is not supported on Windows");
|
|
return;
|
|
}
|
|
|
|
// Keep the event loop alive while suspended. Without this, stopping the TUI
|
|
// can leave Node with no ref'ed handles, causing the process to exit on fg
|
|
// before the SIGCONT handler gets a chance to restore the terminal.
|
|
const suspendKeepAlive = setInterval(() => {}, 2 ** 30);
|
|
|
|
// Ignore SIGINT while suspended so Ctrl+C in the terminal does not
|
|
// kill the backgrounded process. The handler is removed on resume.
|
|
const ignoreSigint = () => {};
|
|
process.on("SIGINT", ignoreSigint);
|
|
|
|
// Set up handler to restore TUI when resumed
|
|
process.once("SIGCONT", () => {
|
|
clearInterval(suspendKeepAlive);
|
|
process.removeListener("SIGINT", ignoreSigint);
|
|
this.ui.start();
|
|
this.ui.requestRender(true);
|
|
});
|
|
|
|
try {
|
|
// Stop the TUI (restore terminal to normal mode)
|
|
this.ui.stop();
|
|
|
|
// Send SIGTSTP to process group (pid=0 means all processes in group)
|
|
process.kill(0, "SIGTSTP");
|
|
} catch (error) {
|
|
clearInterval(suspendKeepAlive);
|
|
process.removeListener("SIGINT", ignoreSigint);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async handleFollowUp(): Promise<void> {
|
|
const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim();
|
|
if (!text) return;
|
|
|
|
// Queue input during compaction (extension commands execute immediately)
|
|
if (this.session.isCompacting) {
|
|
if (this.isExtensionCommand(text)) {
|
|
this.editor.addToHistory?.(text);
|
|
this.editor.setText("");
|
|
await this.session.prompt(text);
|
|
} else {
|
|
this.queueCompactionMessage(text, "followUp");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Alt+Enter queues a follow-up message (waits until agent finishes)
|
|
// This handles extension commands (execute immediately), prompt template expansion, and queueing
|
|
if (this.session.isStreaming) {
|
|
this.editor.addToHistory?.(text);
|
|
this.editor.setText("");
|
|
await this.session.prompt(text, { streamingBehavior: "followUp" });
|
|
this.updatePendingMessagesDisplay();
|
|
this.ui.requestRender();
|
|
}
|
|
// If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
|
|
else if (this.editor.onSubmit) {
|
|
this.editor.setText("");
|
|
this.editor.onSubmit(text);
|
|
}
|
|
}
|
|
|
|
private handleDequeue(): void {
|
|
const restored = this.restoreQueuedMessagesToEditor();
|
|
if (restored === 0) {
|
|
this.showStatus("No queued messages to restore");
|
|
} else {
|
|
this.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`);
|
|
}
|
|
}
|
|
|
|
private updateEditorBorderColor(): void {
|
|
if (this.isBashMode) {
|
|
this.editor.borderColor = theme.getBashModeBorderColor();
|
|
} else {
|
|
const level = this.session.thinkingLevel || "off";
|
|
this.editor.borderColor = theme.getThinkingBorderColor(level);
|
|
}
|
|
this.activeStatusIndicator?.invalidate();
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private cycleThinkingLevel(): void {
|
|
const newLevel = this.session.cycleThinkingLevel();
|
|
if (newLevel === undefined) {
|
|
this.showStatus("Current model does not support thinking");
|
|
} else {
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
this.showStatus(`Thinking level: ${newLevel}`);
|
|
}
|
|
}
|
|
|
|
private async cycleModel(direction: "forward" | "backward"): Promise<void> {
|
|
try {
|
|
const result = await this.session.cycleModel(direction);
|
|
if (result === undefined) {
|
|
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
|
|
this.showStatus(msg);
|
|
} else {
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
const thinkingStr =
|
|
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
|
|
this.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`);
|
|
void this.maybeWarnAboutAnthropicSubscriptionAuth(result.model);
|
|
}
|
|
} catch (error) {
|
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
private toggleToolOutputExpansion(): void {
|
|
this.setToolsExpanded(!this.toolOutputExpanded);
|
|
}
|
|
|
|
private setToolsExpanded(expanded: boolean): void {
|
|
if (expanded === this.toolOutputExpanded) return;
|
|
|
|
this.toolOutputExpanded = expanded;
|
|
const activeHeader = this.customHeader ?? this.builtInHeader;
|
|
if (isExpandable(activeHeader)) {
|
|
activeHeader.setExpanded(expanded);
|
|
}
|
|
for (const container of [this.loadedResourcesContainer, this.chatContainer]) {
|
|
for (const child of container.children) {
|
|
if (isExpandable(child)) {
|
|
child.setExpanded(expanded);
|
|
}
|
|
}
|
|
}
|
|
this.showStatus(`Tool output: ${expanded ? "expanded" : "collapsed"}`);
|
|
}
|
|
|
|
/** Update rendered assistant messages without rebuilding live tool components. */
|
|
private updateThinkingBlockVisibility(): void {
|
|
for (const child of this.chatContainer.children) {
|
|
if (child instanceof AssistantMessageComponent) {
|
|
child.setHideThinkingBlock(this.hideThinkingBlock);
|
|
}
|
|
}
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private toggleThinkingBlockVisibility(): void {
|
|
this.hideThinkingBlock = !this.hideThinkingBlock;
|
|
this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
|
|
this.updateThinkingBlockVisibility();
|
|
this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
|
|
}
|
|
|
|
private async handleOpenExternalEditor(): Promise<void> {
|
|
const editorCmd = this.settingsManager.getExternalEditorCommand();
|
|
const content = this.editor.getExpandedText?.() ?? this.editor.getText();
|
|
this.ui.stop();
|
|
try {
|
|
const result = await editInExternalEditor({
|
|
command: editorCmd,
|
|
content,
|
|
});
|
|
if (result.status === "complete") {
|
|
this.editor.setText(result.content);
|
|
} else {
|
|
this.showStatus(result.message);
|
|
}
|
|
} finally {
|
|
this.ui.start();
|
|
this.ui.requestRender(true);
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// UI helpers
|
|
// =========================================================================
|
|
|
|
clearEditor(): void {
|
|
this.editor.setText("");
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
showError(errorMessage: string): void {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg("error", `Error: ${errorMessage}`), this.outputPad, 0));
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
showWarning(warningMessage: string): void {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
showNewVersionNotification(release: LatestPiRelease): void {
|
|
const updateInstruction = () =>
|
|
theme.fg("muted", `New version ${release.version} is available. Run `) +
|
|
theme.fg("accent", `${APP_NAME} update`);
|
|
const changelogUrl = "https://pi.dev/changelog";
|
|
const changelogLine = () => {
|
|
const changelogLink = getCapabilities().hyperlinks
|
|
? hyperlink(theme.fg("accent", changelogUrl), changelogUrl)
|
|
: theme.fg("accent", changelogUrl);
|
|
return theme.fg("muted", "Changelog: ") + changelogLink;
|
|
};
|
|
const note = release.note?.trim();
|
|
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
|
|
this.chatContainer.addChild(
|
|
new ThemedText(() => `${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction()}`, 1, 0),
|
|
);
|
|
if (note) {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(
|
|
this.createChangelogMarkdown(note, 1, 0, { color: (text) => theme.fg("muted", text) }),
|
|
);
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
}
|
|
this.chatContainer.addChild(new ThemedText(changelogLine, 1, 0));
|
|
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
showPackageUpdateNotification(packages: string[]): void {
|
|
const updateInstruction = () =>
|
|
theme.fg("muted", "Package updates are available. Run ") +
|
|
theme.fg("accent", `${APP_NAME} update --extensions`);
|
|
const packageLines = packages.map((pkg) => `- ${pkg}`).join("\n");
|
|
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
|
|
this.chatContainer.addChild(
|
|
new ThemedText(
|
|
() =>
|
|
`${theme.bold(theme.fg("warning", "Package Updates Available"))}\n${updateInstruction()}\n${theme.fg("muted", "Packages:")}\n${packageLines}`,
|
|
1,
|
|
0,
|
|
),
|
|
);
|
|
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Get all queued messages (read-only).
|
|
* Combines session queue and compaction queue.
|
|
*/
|
|
private getAllQueuedMessages(): { steering: string[]; followUp: string[] } {
|
|
return {
|
|
steering: [
|
|
...this.session.getSteeringMessages(),
|
|
...this.compactionQueuedMessages.filter((msg) => msg.mode === "steer").map((msg) => msg.text),
|
|
],
|
|
followUp: [
|
|
...this.session.getFollowUpMessages(),
|
|
...this.compactionQueuedMessages.filter((msg) => msg.mode === "followUp").map((msg) => msg.text),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear all queued messages and return their contents.
|
|
* Clears both session queue and compaction queue.
|
|
*/
|
|
private clearAllQueues(): { steering: string[]; followUp: string[] } {
|
|
const { steering, followUp } = this.session.clearQueue();
|
|
const compactionSteering = this.compactionQueuedMessages
|
|
.filter((msg) => msg.mode === "steer")
|
|
.map((msg) => msg.text);
|
|
const compactionFollowUp = this.compactionQueuedMessages
|
|
.filter((msg) => msg.mode === "followUp")
|
|
.map((msg) => msg.text);
|
|
this.compactionQueuedMessages = [];
|
|
return {
|
|
steering: [...steering, ...compactionSteering],
|
|
followUp: [...followUp, ...compactionFollowUp],
|
|
};
|
|
}
|
|
|
|
private updatePendingMessagesDisplay(): void {
|
|
this.pendingMessagesContainer.clear();
|
|
const { steering: steeringMessages, followUp: followUpMessages } = this.getAllQueuedMessages();
|
|
if (steeringMessages.length > 0 || followUpMessages.length > 0) {
|
|
this.pendingMessagesContainer.addChild(new Spacer(1));
|
|
for (const message of steeringMessages) {
|
|
const text = theme.fg("dim", `Steering: ${message}`);
|
|
this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0));
|
|
}
|
|
for (const message of followUpMessages) {
|
|
const text = theme.fg("dim", `Follow-up: ${message}`);
|
|
this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0));
|
|
}
|
|
const dequeueHint = this.getAppKeyDisplay("app.message.dequeue");
|
|
const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`);
|
|
this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
|
|
}
|
|
}
|
|
|
|
private restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
|
|
const { steering, followUp } = this.clearAllQueues();
|
|
const allQueued = [...steering, ...followUp];
|
|
if (allQueued.length === 0) {
|
|
this.updatePendingMessagesDisplay();
|
|
if (options?.abort) {
|
|
void this.session.abort();
|
|
}
|
|
return 0;
|
|
}
|
|
const queuedText = allQueued.join("\n\n");
|
|
const currentText = options?.currentText ?? this.editor.getExpandedText?.() ?? this.editor.getText();
|
|
const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
|
|
this.editor.setText(combinedText);
|
|
this.updatePendingMessagesDisplay();
|
|
if (options?.abort) {
|
|
void this.session.abort();
|
|
}
|
|
return allQueued.length;
|
|
}
|
|
|
|
private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
|
|
this.compactionQueuedMessages.push({ text, mode });
|
|
this.editor.addToHistory?.(text);
|
|
this.editor.setText("");
|
|
this.updatePendingMessagesDisplay();
|
|
this.showStatus("Queued message for after compaction");
|
|
}
|
|
|
|
private isExtensionCommand(text: string): boolean {
|
|
if (!text.startsWith("/")) return false;
|
|
|
|
const extensionRunner = this.session.extensionRunner;
|
|
|
|
const spaceIndex = text.indexOf(" ");
|
|
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
return !!extensionRunner.getCommand(commandName);
|
|
}
|
|
|
|
private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> {
|
|
if (this.compactionQueuedMessages.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const queuedMessages = [...this.compactionQueuedMessages];
|
|
this.compactionQueuedMessages = [];
|
|
this.updatePendingMessagesDisplay();
|
|
|
|
const restoreQueue = (error: unknown) => {
|
|
this.session.clearQueue();
|
|
this.compactionQueuedMessages = queuedMessages;
|
|
this.updatePendingMessagesDisplay();
|
|
this.showError(
|
|
`Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
};
|
|
|
|
try {
|
|
if (options?.willRetry) {
|
|
// When retry is pending, queue messages for the retry turn
|
|
for (const message of queuedMessages) {
|
|
if (this.isExtensionCommand(message.text)) {
|
|
await this.session.prompt(message.text);
|
|
} else if (message.mode === "followUp") {
|
|
await this.session.followUp(message.text);
|
|
} else {
|
|
await this.session.steer(message.text);
|
|
}
|
|
}
|
|
this.updatePendingMessagesDisplay();
|
|
return;
|
|
}
|
|
|
|
// Find first non-extension-command message to use as prompt
|
|
const firstPromptIndex = queuedMessages.findIndex((message) => !this.isExtensionCommand(message.text));
|
|
if (firstPromptIndex === -1) {
|
|
// All extension commands - execute them all
|
|
for (const message of queuedMessages) {
|
|
await this.session.prompt(message.text);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Execute any extension commands before the first prompt
|
|
const preCommands = queuedMessages.slice(0, firstPromptIndex);
|
|
const firstPrompt = queuedMessages[firstPromptIndex];
|
|
const rest = queuedMessages.slice(firstPromptIndex + 1);
|
|
|
|
for (const message of preCommands) {
|
|
await this.session.prompt(message.text);
|
|
}
|
|
|
|
// Start a prompt when idle, or queue it into a run still finishing compaction.
|
|
const promptPromise = this.session
|
|
.prompt(firstPrompt.text, { streamingBehavior: firstPrompt.mode })
|
|
.catch((error) => {
|
|
restoreQueue(error);
|
|
});
|
|
|
|
// Queue remaining messages
|
|
for (const message of rest) {
|
|
if (this.isExtensionCommand(message.text)) {
|
|
await this.session.prompt(message.text);
|
|
} else if (message.mode === "followUp") {
|
|
await this.session.followUp(message.text);
|
|
} else {
|
|
await this.session.steer(message.text);
|
|
}
|
|
}
|
|
this.updatePendingMessagesDisplay();
|
|
void promptPromise;
|
|
} catch (error) {
|
|
restoreQueue(error);
|
|
}
|
|
}
|
|
|
|
/** Move pending bash components from pending area to chat */
|
|
private flushPendingBashComponents(): void {
|
|
for (const component of this.pendingBashComponents) {
|
|
this.pendingMessagesContainer.removeChild(component);
|
|
this.chatContainer.addChild(component);
|
|
}
|
|
this.pendingBashComponents = [];
|
|
}
|
|
|
|
// =========================================================================
|
|
// Selectors
|
|
// =========================================================================
|
|
|
|
private disposeActiveSelector(): void {
|
|
const dispose = this.activeSelectorDispose;
|
|
this.activeSelectorToken = undefined;
|
|
this.activeSelectorDispose = undefined;
|
|
dispose?.();
|
|
}
|
|
|
|
/**
|
|
* Shows a selector component in place of the editor.
|
|
* @param create Factory that receives a `done` callback and returns the component and focus target
|
|
*/
|
|
private showSelector(
|
|
create: (done: () => void) => { component: Component; focus: Component; dispose?: () => void },
|
|
): void {
|
|
const token = {};
|
|
let dispose: (() => void) | undefined;
|
|
const done = () => {
|
|
dispose?.();
|
|
if (this.activeSelectorToken !== token) return;
|
|
this.activeSelectorToken = undefined;
|
|
this.activeSelectorDispose = undefined;
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(this.editor);
|
|
this.ui.setFocus(this.editor);
|
|
};
|
|
const created = create(done);
|
|
dispose = created.dispose;
|
|
this.disposeActiveSelector();
|
|
this.activeSelectorToken = token;
|
|
this.activeSelectorDispose = dispose;
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(created.component);
|
|
this.ui.setFocus(created.focus);
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private showSettingsSelector(): void {
|
|
this.showSelector((done) => {
|
|
let selector: SettingsSelectorComponent | undefined;
|
|
const defaultProvider = this.settingsManager.getDefaultProvider();
|
|
const defaultModelId = this.settingsManager.getDefaultModel();
|
|
const defaultModel = defaultProvider && defaultModelId ? `${defaultProvider}/${defaultModelId}` : "not set";
|
|
selector = new SettingsSelectorComponent(
|
|
{
|
|
autoCompact: this.session.autoCompactionEnabled,
|
|
defaultModel,
|
|
currentModel: this.session.model,
|
|
availableDefaultModels: this.session.modelRuntime.getAvailableSnapshot(),
|
|
showImages: this.settingsManager.getShowImages(),
|
|
imageWidthCells: this.settingsManager.getImageWidthCells(),
|
|
autoResizeImages: this.settingsManager.getImageAutoResize(),
|
|
blockImages: this.settingsManager.getBlockImages(),
|
|
enableSkillCommands: this.settingsManager.getEnableSkillCommands(),
|
|
steeringMode: this.session.steeringMode,
|
|
followUpMode: this.session.followUpMode,
|
|
transport: this.settingsManager.getTransport(),
|
|
sessionShareDestination: this.settingsManager.getSessionShareDestination(),
|
|
httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
|
|
cacheWarmingMode: this.settingsManager.getCacheWarmingMode(),
|
|
thinkingLevel: this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL,
|
|
availableThinkingLevels: [...THINKING_LEVEL_OPTIONS],
|
|
modelThinkingLevels: this.settingsManager.getAllModelThinkingLevels(),
|
|
currentTheme: this.themeController.getThemeSelection() || SYSTEM_THEME_NAME,
|
|
terminalTheme: this.themeController.getTerminalTheme(),
|
|
availableThemes: getAvailableThemes(),
|
|
hideThinkingBlock: this.hideThinkingBlock,
|
|
mermaidRenderingMode: this.settingsManager.getMermaidRenderingMode(),
|
|
collapseChangelog: this.settingsManager.getCollapseChangelog(),
|
|
enableInstallTelemetry: this.settingsManager.getEnableInstallTelemetry(),
|
|
doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),
|
|
treeFilterMode: this.settingsManager.getTreeFilterMode(),
|
|
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
|
|
showCacheMissNotices: this.settingsManager.getShowCacheMissNotices(),
|
|
defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(),
|
|
editorPaddingX: this.settingsManager.getEditorPaddingX(),
|
|
outputPad: this.settingsManager.getOutputPad(),
|
|
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
|
|
quietStartup: this.settingsManager.getQuietStartup(),
|
|
clearOnShrink: this.settingsManager.getClearOnShrink(),
|
|
showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
|
|
tuiMode: this.ui.mode,
|
|
fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(),
|
|
fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
|
|
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
|
|
fullscreenWheelScrollLines: this.settingsManager.getFullscreenWheelScrollLines(),
|
|
warnings: this.settingsManager.getWarnings(),
|
|
},
|
|
{
|
|
onAutoCompactChange: (enabled) => {
|
|
this.session.setAutoCompactionEnabled(enabled);
|
|
this.footer.setAutoCompactEnabled(enabled);
|
|
},
|
|
onShowImagesChange: (enabled) => {
|
|
this.settingsManager.setShowImages(enabled);
|
|
for (const child of this.chatContainer.children) {
|
|
if (child instanceof ToolExecutionComponent) {
|
|
child.setShowImages(enabled);
|
|
}
|
|
}
|
|
},
|
|
onImageWidthCellsChange: (width) => {
|
|
this.settingsManager.setImageWidthCells(width);
|
|
for (const child of this.chatContainer.children) {
|
|
if (child instanceof ToolExecutionComponent) {
|
|
child.setImageWidthCells(width);
|
|
}
|
|
}
|
|
},
|
|
onAutoResizeImagesChange: (enabled) => {
|
|
this.settingsManager.setImageAutoResize(enabled);
|
|
},
|
|
onBlockImagesChange: (blocked) => {
|
|
this.settingsManager.setBlockImages(blocked);
|
|
},
|
|
onEnableSkillCommandsChange: (enabled) => {
|
|
this.settingsManager.setEnableSkillCommands(enabled);
|
|
this.setupAutocompleteProvider();
|
|
},
|
|
onSteeringModeChange: (mode) => {
|
|
this.session.setSteeringMode(mode);
|
|
},
|
|
onFollowUpModeChange: (mode) => {
|
|
this.session.setFollowUpMode(mode);
|
|
},
|
|
onTransportChange: (transport) => {
|
|
this.settingsManager.setTransport(transport);
|
|
this.session.agent.transport = transport;
|
|
},
|
|
onSessionShareDestinationChange: (destination) => {
|
|
this.settingsManager.setSessionShareDestination(destination);
|
|
},
|
|
onHttpIdleTimeoutMsChange: (timeoutMs) => {
|
|
this.settingsManager.setHttpIdleTimeoutMs(timeoutMs);
|
|
configureHttpDispatcher(timeoutMs);
|
|
this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`);
|
|
},
|
|
onCacheWarmingModeChange: (mode) => {
|
|
this.session.setCacheWarmingMode(mode);
|
|
this.showStatus(`Cache warming: ${mode}`);
|
|
},
|
|
onModelThinkingLevelChange: (provider, modelId, level) => {
|
|
this.settingsManager.setModelThinkingLevel(provider, modelId, level);
|
|
// If the override is for the current model, apply it to the session too
|
|
const current = this.session.model;
|
|
if (current && current.provider === provider && current.id === modelId) {
|
|
this.session.setThinkingLevel(level);
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
}
|
|
},
|
|
onModelThinkingLevelRemove: (provider, modelId) => {
|
|
this.settingsManager.removeModelThinkingLevel(provider, modelId);
|
|
// If the override was for the current model, revert to global default
|
|
const current = this.session.model;
|
|
if (current && current.provider === provider && current.id === modelId) {
|
|
const globalDefault = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
|
|
this.session.setThinkingLevel(globalDefault);
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
}
|
|
},
|
|
onThemeChange: (themeSetting) => {
|
|
this.settingsManager.setTheme(themeSetting);
|
|
this.themeController.setThemeSetting(themeSetting);
|
|
},
|
|
onThemePreview: (themeName) => this.themeController.preview(themeName),
|
|
onHideThinkingBlockChange: (hidden) => {
|
|
this.hideThinkingBlock = hidden;
|
|
this.settingsManager.setHideThinkingBlock(hidden);
|
|
this.updateThinkingBlockVisibility();
|
|
},
|
|
onMermaidRenderingModeChange: (mode) => {
|
|
this.settingsManager.setMermaidRenderingMode(mode);
|
|
this.chatContainer.invalidate();
|
|
this.ui.requestRender();
|
|
},
|
|
onShowCacheMissNoticesChange: (shown) => {
|
|
this.settingsManager.setShowCacheMissNotices(shown);
|
|
this.rebuildChatFromMessages();
|
|
},
|
|
onCollapseChangelogChange: (collapsed) => {
|
|
this.settingsManager.setCollapseChangelog(collapsed);
|
|
},
|
|
onEnableInstallTelemetryChange: (enabled) => {
|
|
this.settingsManager.setEnableInstallTelemetry(enabled);
|
|
},
|
|
onQuietStartupChange: (quiet) => {
|
|
this.settingsManager.setQuietStartup(quiet);
|
|
},
|
|
onDefaultProjectTrustChange: (defaultProjectTrust) => {
|
|
this.settingsManager.setDefaultProjectTrust(defaultProjectTrust);
|
|
},
|
|
onDoubleEscapeActionChange: (action) => {
|
|
this.settingsManager.setDoubleEscapeAction(action);
|
|
},
|
|
onTreeFilterModeChange: (mode) => {
|
|
this.settingsManager.setTreeFilterMode(mode);
|
|
},
|
|
onShowHardwareCursorChange: (enabled) => {
|
|
this.settingsManager.setShowHardwareCursor(enabled);
|
|
this.ui.setShowHardwareCursor(enabled);
|
|
},
|
|
onEditorPaddingXChange: (padding) => {
|
|
this.settingsManager.setEditorPaddingX(padding);
|
|
this.defaultEditor.setPaddingX(padding);
|
|
if (this.editor !== this.defaultEditor && this.editor.setPaddingX !== undefined) {
|
|
this.editor.setPaddingX(padding);
|
|
}
|
|
},
|
|
onOutputPadChange: (padding) => {
|
|
this.settingsManager.setOutputPad(padding);
|
|
this.outputPad = padding;
|
|
for (const container of [this.chatContainer, this.pendingMessagesContainer]) {
|
|
for (const child of container.children) {
|
|
if ("setOutputPad" in child && typeof child.setOutputPad === "function") {
|
|
child.setOutputPad(padding);
|
|
}
|
|
}
|
|
}
|
|
this.ui.requestRender();
|
|
},
|
|
onAutocompleteMaxVisibleChange: (maxVisible) => {
|
|
this.settingsManager.setAutocompleteMaxVisible(maxVisible);
|
|
this.defaultEditor.setAutocompleteMaxVisible(maxVisible);
|
|
if (this.editor !== this.defaultEditor && this.editor.setAutocompleteMaxVisible !== undefined) {
|
|
this.editor.setAutocompleteMaxVisible(maxVisible);
|
|
}
|
|
},
|
|
onClearOnShrinkChange: (enabled) => {
|
|
this.settingsManager.setClearOnShrink(enabled);
|
|
this.ui.setClearOnShrink(enabled);
|
|
if (!enabled && !this.activeStatusIndicator) {
|
|
this.statusContainer.clear();
|
|
}
|
|
},
|
|
onShowTerminalProgressChange: (enabled) => {
|
|
this.settingsManager.setShowTerminalProgress(enabled);
|
|
},
|
|
onTuiModeChange: (mode) => {
|
|
if (!this.switchTuiMode(mode)) {
|
|
selector?.getSettingsList().updateValue("tui-mode", this.ui.mode);
|
|
this.showStatus("Close active overlays before changing TUI mode");
|
|
return;
|
|
}
|
|
this.settingsManager.setTuiMode(mode);
|
|
if (!this.activeStatusIndicator) this.statusContainer.clear();
|
|
this.showStatus(`TUI mode: ${mode}`);
|
|
},
|
|
onFullscreenExitOutputChange: (output) => {
|
|
this.settingsManager.setFullscreenExitOutput(output);
|
|
},
|
|
onFullscreenScrollbarChange: (mode) => {
|
|
this.settingsManager.setFullscreenScrollbar(mode);
|
|
this.applyFullscreenScrollbarSetting();
|
|
},
|
|
onFullscreenCopyOnSelectChange: (enabled) => {
|
|
this.settingsManager.setFullscreenCopyOnSelect(enabled);
|
|
if (this.renderer instanceof TuiAltScreen) this.renderer.setCopyOnSelect(enabled);
|
|
},
|
|
onFullscreenWheelScrollLinesChange: (lines) => {
|
|
this.settingsManager.setFullscreenWheelScrollLines(lines);
|
|
if (this.renderer instanceof TuiAltScreen) this.renderer.setWheelScrollLines(lines);
|
|
},
|
|
onWarningsChange: (warnings) => {
|
|
this.settingsManager.setWarnings(warnings);
|
|
},
|
|
onCancel: () => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
},
|
|
);
|
|
return { component: selector, focus: selector.getSettingsList() };
|
|
});
|
|
}
|
|
|
|
private handleThinkingCommand(searchTerm?: string): void {
|
|
const availableLevels = this.session.getAvailableThinkingLevels();
|
|
if (!searchTerm) {
|
|
this.showThinkingSelector();
|
|
return;
|
|
}
|
|
|
|
const normalized = searchTerm.trim().toLowerCase();
|
|
const level = availableLevels.find((candidate) => candidate.toLowerCase() === normalized);
|
|
if (!level) {
|
|
this.showError(`Unknown thinking level "${searchTerm}". Available levels: ${availableLevels.join(", ")}.`);
|
|
return;
|
|
}
|
|
|
|
this.selectThinkingLevel(level, false);
|
|
}
|
|
|
|
private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void {
|
|
try {
|
|
this.session.setThinkingLevel(level, { persist });
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`);
|
|
} catch (error) {
|
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
private showThinkingSelector(): void {
|
|
this.showSelector((done) => {
|
|
const selectLevel = (level: ThinkingLevel, persist: boolean) => {
|
|
this.selectThinkingLevel(level, persist);
|
|
done();
|
|
};
|
|
const selector = new ThinkingSelectorComponent(
|
|
this.session.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
|
|
this.session.getAvailableThinkingLevels(),
|
|
(level) => selectLevel(level, false),
|
|
() => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
(level) => selectLevel(level, true),
|
|
this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL,
|
|
);
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
private async handleModelCommand(searchTerm?: string): Promise<void> {
|
|
if (!searchTerm) {
|
|
this.showModelSelector();
|
|
return;
|
|
}
|
|
|
|
const model = await this.findExactModelMatch(searchTerm);
|
|
if (model) {
|
|
try {
|
|
await this.session.setModel(model, { persist: false });
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
this.showStatus(`Model: ${model.id}`);
|
|
void this.maybeWarnAboutAnthropicSubscriptionAuth(model);
|
|
} catch (error) {
|
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.showModelSelector(searchTerm);
|
|
}
|
|
|
|
private async findExactModelMatch(searchTerm: string): Promise<Model<any> | undefined> {
|
|
const cachedModels =
|
|
this.session.scopedModels.length > 0
|
|
? this.session.scopedModels.map((scoped) => scoped.model)
|
|
: [...this.session.modelRuntime.getAvailableSnapshot()];
|
|
const cachedMatch = findExactModelReferenceMatch(searchTerm, cachedModels);
|
|
if (cachedMatch || this.session.scopedModels.length > 0) return cachedMatch;
|
|
|
|
this.showStatus("Refreshing model catalogs…");
|
|
const controller = new AbortController();
|
|
let timedOut = false;
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
}, 15_000);
|
|
try {
|
|
const result = await refreshModelCatalogs(this.session.modelRuntime, controller.signal);
|
|
if (result.aborted && timedOut) {
|
|
this.showWarning("Model refresh timed out; searching cached models.");
|
|
} else if (result.errors.size > 0) {
|
|
this.showWarning(`Could not refresh ${[...result.errors.keys()].join(", ")}; searching cached models.`);
|
|
}
|
|
} catch (error) {
|
|
this.showWarning(
|
|
timedOut
|
|
? "Model refresh timed out; searching cached models."
|
|
: `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
return findExactModelReferenceMatch(searchTerm, [...this.session.modelRuntime.getAvailableSnapshot()]);
|
|
}
|
|
|
|
/** Update the footer's available provider count from the current snapshot without refreshing catalogs. */
|
|
private updateAvailableProviderCount(): void {
|
|
const models =
|
|
this.session.scopedModels.length > 0
|
|
? this.session.scopedModels.map((scoped) => scoped.model)
|
|
: this.session.modelRuntime.getAvailableSnapshot();
|
|
const uniqueProviders = new Set(models.map((model) => model.provider));
|
|
this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size);
|
|
}
|
|
|
|
private async maybeWarnAboutAnthropicSubscriptionAuth(
|
|
model: Model<any> | undefined = this.session.model,
|
|
): Promise<void> {
|
|
if (this.settingsManager.getWarnings().anthropicExtraUsage === false) {
|
|
return;
|
|
}
|
|
if (this.anthropicSubscriptionWarningShown) {
|
|
return;
|
|
}
|
|
if (!model || model.provider !== "anthropic") {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if ((await this.session.modelRuntime.checkAuth("anthropic"))?.type === "oauth") {
|
|
this.anthropicSubscriptionWarningShown = true;
|
|
this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
|
|
return;
|
|
}
|
|
const apiKey = (await this.session.modelRuntime.getAuth(model.provider))?.auth.apiKey;
|
|
if (!isAnthropicSubscriptionAuthKey(apiKey)) {
|
|
return;
|
|
}
|
|
this.anthropicSubscriptionWarningShown = true;
|
|
this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
|
|
} catch {
|
|
// Ignore auth lookup failures for warning-only checks.
|
|
}
|
|
}
|
|
|
|
private maybeSaveImplicitProjectTrustAfterReload(): boolean {
|
|
const cwd = this.sessionManager.getCwd();
|
|
if (this.autoTrustOnReloadCwd !== cwd) {
|
|
return false;
|
|
}
|
|
if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) {
|
|
return false;
|
|
}
|
|
|
|
const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir);
|
|
try {
|
|
if (trustStore.get(cwd) !== null) {
|
|
this.autoTrustOnReloadCwd = undefined;
|
|
return false;
|
|
}
|
|
trustStore.set(cwd, true);
|
|
this.autoTrustOnReloadCwd = undefined;
|
|
return true;
|
|
} catch (error) {
|
|
this.showWarning(
|
|
`Could not save project trust after reload: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private showTrustSelector(): void {
|
|
const cwd = this.sessionManager.getCwd();
|
|
const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir);
|
|
const savedDecision = trustStore.getEntry(cwd);
|
|
this.showSelector((done) => {
|
|
const selector = new TrustSelectorComponent({
|
|
cwd,
|
|
savedDecision,
|
|
projectTrusted: this.settingsManager.isProjectTrusted(),
|
|
onSelect: (selection) => {
|
|
trustStore.setMany(selection.updates);
|
|
done();
|
|
this.showStatus(
|
|
`Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart ${APP_NAME} for this to take effect.`,
|
|
);
|
|
},
|
|
onCancel: () => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
});
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
private showModelSelector(initialSearchInput?: string): void {
|
|
this.showSelector((done) => {
|
|
const selectModel = async (model: Model<any>, persist: boolean) => {
|
|
try {
|
|
await this.session.setModel(model, { persist });
|
|
this.updateAvailableProviderCount();
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
done();
|
|
this.showStatus(persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`);
|
|
void this.maybeWarnAboutAnthropicSubscriptionAuth(model);
|
|
} catch (error) {
|
|
done();
|
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
};
|
|
const defaultProvider = this.settingsManager.getDefaultProvider();
|
|
const defaultModel = this.settingsManager.getDefaultModel();
|
|
const selector = new ModelSelectorComponent(
|
|
this.ui,
|
|
this.session.model,
|
|
this.session.modelRuntime,
|
|
this.session.scopedModels,
|
|
(model) => selectModel(model, false),
|
|
() => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
initialSearchInput,
|
|
(model) => selectModel(model, true),
|
|
defaultProvider && defaultModel ? { provider: defaultProvider, id: defaultModel } : undefined,
|
|
);
|
|
return { component: selector, focus: selector, dispose: () => selector.dispose() };
|
|
});
|
|
}
|
|
|
|
private showModelsSelector(): void {
|
|
let availableModels = [...this.session.modelRuntime.getAvailableSnapshot()];
|
|
let availableModelIds = new Set(availableModels.map((model) => `${model.provider}/${model.id}`));
|
|
const configuredPatterns = this.settingsManager.getEnabledModels();
|
|
const sessionScopedModels = this.session.scopedModels;
|
|
const configuredEnabledIds = (models: readonly Model<any>[]): string[] | null => {
|
|
if (!configuredPatterns?.length) return null;
|
|
const resolved = resolveModelScopeFromModels(configuredPatterns, models);
|
|
const ids = resolved.scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
|
|
for (const diagnostic of resolved.diagnostics) {
|
|
if (diagnostic.code === "no-match" && !ids.includes(diagnostic.pattern)) ids.push(diagnostic.pattern);
|
|
}
|
|
return ids;
|
|
};
|
|
|
|
let currentEnabledIds =
|
|
sessionScopedModels.length > 0
|
|
? sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`)
|
|
: configuredEnabledIds(availableModels);
|
|
let selectionChanged = false;
|
|
|
|
const updateSessionModels = (enabledIds: string[] | null): void => {
|
|
currentEnabledIds = enabledIds === null ? null : [...enabledIds];
|
|
const hasEnabledAvailableModel = enabledIds?.some((id) => availableModelIds.has(id)) ?? false;
|
|
const allAvailableModelsEnabled =
|
|
enabledIds !== null && [...availableModelIds].every((id) => enabledIds.includes(id));
|
|
if (enabledIds && hasEnabledAvailableModel && !allAvailableModelsEnabled) {
|
|
const newScopedModels = resolveModelScopeFromModels(enabledIds, availableModels).scopedModels;
|
|
this.session.setScopedModels(
|
|
newScopedModels.map((scoped) => ({
|
|
model: scoped.model,
|
|
thinkingLevel: scoped.thinkingLevel,
|
|
})),
|
|
);
|
|
} else {
|
|
this.session.setScopedModels([]);
|
|
}
|
|
this.updateAvailableProviderCount();
|
|
this.ui.requestRender();
|
|
};
|
|
|
|
this.showSelector((done) => {
|
|
let disposed = false;
|
|
let timedOut = false;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
}, 15_000);
|
|
const selector = new ScopedModelsSelectorComponent(
|
|
{
|
|
allModels: availableModels,
|
|
enabledModelIds: currentEnabledIds,
|
|
refreshStatus: "Refreshing model catalogs…",
|
|
},
|
|
{
|
|
onChange: (enabledIds) => {
|
|
selectionChanged = true;
|
|
updateSessionModels(enabledIds);
|
|
},
|
|
onPersist: (enabledIds) => {
|
|
const allEnabled =
|
|
enabledIds !== null &&
|
|
enabledIds.length === availableModels.length &&
|
|
enabledIds.every((id) => availableModelIds.has(id));
|
|
const newPatterns = enabledIds === null || allEnabled ? undefined : enabledIds;
|
|
this.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined);
|
|
this.showStatus("Model selection saved to settings");
|
|
},
|
|
onCancel: () => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
},
|
|
);
|
|
void refreshModelCatalogs(this.session.modelRuntime, controller.signal)
|
|
.then((result) => {
|
|
if (disposed) return;
|
|
availableModels = [...this.session.modelRuntime.getAvailableSnapshot()];
|
|
availableModelIds = new Set(availableModels.map((model) => `${model.provider}/${model.id}`));
|
|
if (!selectionChanged && sessionScopedModels.length === 0) {
|
|
currentEnabledIds = configuredEnabledIds(availableModels);
|
|
selector.updateModels(availableModels, currentEnabledIds);
|
|
} else {
|
|
selector.updateModels(availableModels);
|
|
}
|
|
if (currentEnabledIds !== null) updateSessionModels(currentEnabledIds);
|
|
if (result.aborted && timedOut) {
|
|
selector.setRefreshStatus("Model refresh timed out; showing cached models.", "warning");
|
|
} else if (result.errors.size > 0) {
|
|
selector.setRefreshStatus(
|
|
`Could not refresh ${[...result.errors.keys()].join(", ")}; showing cached models.`,
|
|
"warning",
|
|
);
|
|
} else {
|
|
selector.setRefreshStatus("Model catalogs refreshed.", "success");
|
|
}
|
|
this.ui.requestRender();
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (disposed) return;
|
|
selector.setRefreshStatus(
|
|
timedOut
|
|
? "Model refresh timed out; showing cached models."
|
|
: `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`,
|
|
"warning",
|
|
);
|
|
this.ui.requestRender();
|
|
})
|
|
.finally(() => clearTimeout(timeout));
|
|
return {
|
|
component: selector,
|
|
focus: selector,
|
|
dispose: () => {
|
|
disposed = true;
|
|
clearTimeout(timeout);
|
|
controller.abort();
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
private showUserMessageSelector(): void {
|
|
const userMessages = this.session.getUserMessagesForForking();
|
|
|
|
if (userMessages.length === 0) {
|
|
this.showStatus("No messages to fork from");
|
|
return;
|
|
}
|
|
|
|
const initialSelectedId = userMessages[userMessages.length - 1]?.entryId;
|
|
|
|
this.showSelector((done) => {
|
|
const selector = new UserMessageSelectorComponent(
|
|
userMessages.map((m) => ({ id: m.entryId, text: m.text })),
|
|
async (entryId) => {
|
|
done();
|
|
try {
|
|
const result = await this.runtimeHost.fork(entryId);
|
|
if (result.cancelled) {
|
|
this.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
this.editor.setText(result.selectedText ?? "");
|
|
this.showStatus("Forked to new session");
|
|
} catch (error: unknown) {
|
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
},
|
|
() => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
initialSelectedId,
|
|
);
|
|
return { component: selector, focus: selector.getMessageList() };
|
|
});
|
|
}
|
|
|
|
private async handleCloneCommand(): Promise<void> {
|
|
const leafId = this.sessionManager.getLeafId();
|
|
if (!leafId) {
|
|
this.showStatus("Nothing to clone yet");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await this.runtimeHost.fork(leafId, { position: "at" });
|
|
if (result.cancelled) {
|
|
this.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
this.editor.setText("");
|
|
this.showStatus("Cloned to new session");
|
|
} catch (error: unknown) {
|
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
private showTreeSelector(initialSelectedId?: string): void {
|
|
const tree = this.sessionManager.getTree();
|
|
const realLeafId = this.sessionManager.getLeafId();
|
|
const initialFilterMode = this.settingsManager.getTreeFilterMode();
|
|
|
|
if (tree.length === 0) {
|
|
this.showStatus("No entries in session");
|
|
return;
|
|
}
|
|
|
|
this.showSelector((done) => {
|
|
const selector = new TreeSelectorComponent(
|
|
tree,
|
|
realLeafId,
|
|
this.ui.terminal.rows,
|
|
async (entryId) => {
|
|
// Selecting the current leaf is a no-op (already there)
|
|
if (entryId === this.sessionManager.getLeafId()) {
|
|
done();
|
|
this.showStatus("Already at this point");
|
|
return;
|
|
}
|
|
|
|
// Ask about summarization
|
|
done(); // Close selector first
|
|
|
|
// Loop until user makes a complete choice or cancels to tree
|
|
let wantsSummary = false;
|
|
let customInstructions: string | undefined;
|
|
|
|
// Check if we should skip the prompt (user preference to always default to no summary)
|
|
if (!this.settingsManager.getBranchSummarySkipPrompt()) {
|
|
while (true) {
|
|
const summaryChoice = await this.showExtensionSelector("Summarize branch?", [
|
|
"No summary",
|
|
"Summarize",
|
|
"Summarize with custom prompt",
|
|
]);
|
|
|
|
if (summaryChoice === undefined) {
|
|
// User pressed escape - re-show tree selector with same selection
|
|
this.showTreeSelector(entryId);
|
|
return;
|
|
}
|
|
|
|
wantsSummary = summaryChoice !== "No summary";
|
|
|
|
if (summaryChoice === "Summarize with custom prompt") {
|
|
customInstructions = await this.showExtensionEditor("Custom summarization instructions");
|
|
if (customInstructions === undefined) {
|
|
// User cancelled - loop back to summary selector
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// User made a complete choice
|
|
break;
|
|
}
|
|
}
|
|
|
|
// The user committed to navigating: stop the active response first.
|
|
if (this.session.isStreaming) {
|
|
this.restoreQueuedMessagesToEditor();
|
|
await this.session.abort();
|
|
}
|
|
|
|
// Recheck after the dialogs and streaming abort, before replacing another operation's UI.
|
|
if (this.session.isCompacting) {
|
|
this.showError(
|
|
"Wait for the current compaction or tree navigation to finish before navigating the session tree.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Set up escape handler and status indicator if summarizing
|
|
let showingSummaryIndicator = false;
|
|
const originalOnEscape = this.defaultEditor.onEscape;
|
|
|
|
if (wantsSummary) {
|
|
this.defaultEditor.onEscape = () => {
|
|
this.session.abortBranchSummary();
|
|
};
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui));
|
|
showingSummaryIndicator = true;
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
try {
|
|
const result = await this.session.navigateTree(entryId, {
|
|
summarize: wantsSummary,
|
|
customInstructions,
|
|
});
|
|
|
|
if (result.aborted) {
|
|
// Summarization aborted - re-show tree selector with same selection
|
|
this.showStatus("Branch summarization cancelled");
|
|
this.showTreeSelector(entryId);
|
|
return;
|
|
}
|
|
if (result.cancelled) {
|
|
this.showStatus("Navigation cancelled");
|
|
return;
|
|
}
|
|
|
|
// Update UI
|
|
this.chatContainer.clear();
|
|
this.renderInitialMessages();
|
|
if (result.editorText && !this.editor.getText().trim()) {
|
|
this.editor.setText(result.editorText);
|
|
}
|
|
this.showStatus("Navigated to selected point");
|
|
void this.flushCompactionQueue({ willRetry: false });
|
|
} catch (error) {
|
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
} finally {
|
|
if (showingSummaryIndicator) {
|
|
this.clearStatusIndicator("branchSummary");
|
|
}
|
|
this.defaultEditor.onEscape = originalOnEscape;
|
|
}
|
|
},
|
|
() => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
(entryId, label) => {
|
|
this.sessionManager.appendLabelChange(entryId, label);
|
|
this.ui.requestRender();
|
|
},
|
|
initialSelectedId,
|
|
initialFilterMode,
|
|
);
|
|
selector.onCopy = async (text) => {
|
|
if (!text) {
|
|
this.showError("Selected entry has no text to copy");
|
|
return;
|
|
}
|
|
try {
|
|
await copyToClipboard(text);
|
|
this.showStatus("Copied selected message to clipboard");
|
|
} catch (error) {
|
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
};
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
private showSessionSelector(): void {
|
|
this.showSelector((done) => {
|
|
const selector = new SessionSelectorComponent(
|
|
(onProgress, signal) =>
|
|
SessionManager.list(
|
|
this.sessionManager.getCwd(),
|
|
this.sessionManager.getSessionDir(),
|
|
onProgress,
|
|
signal,
|
|
),
|
|
(onProgress, signal) =>
|
|
this.sessionManager.usesDefaultSessionDir()
|
|
? SessionManager.listAll(onProgress, signal)
|
|
: SessionManager.listAll(this.sessionManager.getSessionDir(), onProgress, signal),
|
|
async (sessionPath) => {
|
|
done();
|
|
await this.handleResumeSession(sessionPath);
|
|
},
|
|
() => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
() => {
|
|
void this.shutdown();
|
|
},
|
|
() => this.ui.requestRender(),
|
|
{
|
|
renameSession: async (sessionFilePath: string, nextName: string | undefined) => {
|
|
const next = (nextName ?? "").trim();
|
|
if (!next) return;
|
|
const mgr = SessionManager.open(sessionFilePath);
|
|
mgr.appendSessionInfo(next);
|
|
},
|
|
showRenameHint: true,
|
|
keybindings: this.keybindings,
|
|
},
|
|
|
|
this.sessionManager.getSessionFile(),
|
|
);
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
private async handleResumeSession(
|
|
sessionPath: string,
|
|
options?: Parameters<ExtensionCommandContext["switchSession"]>[1],
|
|
): Promise<{ cancelled: boolean }> {
|
|
this.clearStatusIndicator();
|
|
try {
|
|
const result = await this.runtimeHost.switchSession(sessionPath, {
|
|
withSession: options?.withSession,
|
|
projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd),
|
|
});
|
|
if (result.cancelled) {
|
|
return result;
|
|
}
|
|
this.showStatus("Resumed session");
|
|
return result;
|
|
} catch (error: unknown) {
|
|
if (error instanceof MissingSessionCwdError) {
|
|
const selectedCwd = await this.promptForMissingSessionCwd(error);
|
|
if (!selectedCwd) {
|
|
this.showStatus("Resume cancelled");
|
|
return { cancelled: true };
|
|
}
|
|
const result = await this.runtimeHost.switchSession(sessionPath, {
|
|
cwdOverride: selectedCwd,
|
|
withSession: options?.withSession,
|
|
projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd),
|
|
});
|
|
if (result.cancelled) {
|
|
return result;
|
|
}
|
|
this.showStatus("Resumed session in current cwd");
|
|
return result;
|
|
}
|
|
return this.handleFatalRuntimeError("Failed to resume session", error);
|
|
}
|
|
}
|
|
|
|
private getLoginProviderOptions(authType?: "oauth" | "api_key"): AuthSelectorProvider[] {
|
|
const options: AuthSelectorProvider[] = [];
|
|
for (const provider of this.session.modelRuntime.getProviders()) {
|
|
const authStatus = this.session.modelRuntime.getProviderAuthStatus(provider.id);
|
|
const status = authStatus.configured
|
|
? {
|
|
type: this.session.modelRuntime.isUsingOAuth(provider.id) ? ("oauth" as const) : ("api_key" as const),
|
|
source: authStatus.label ?? authStatus.source,
|
|
}
|
|
: undefined;
|
|
const subscription = provider.auth.oauth?.isSubscription === true;
|
|
if ((!authType || authType === "oauth") && provider.auth.oauth) {
|
|
options.push({
|
|
id: provider.id,
|
|
name: provider.name,
|
|
authType: "oauth",
|
|
method: provider.auth.oauth,
|
|
status,
|
|
subscription,
|
|
});
|
|
}
|
|
if ((!authType || authType === "api_key") && provider.auth.apiKey) {
|
|
options.push({
|
|
id: provider.id,
|
|
name: provider.name,
|
|
authType: "api_key",
|
|
method: provider.auth.apiKey,
|
|
status,
|
|
subscription,
|
|
});
|
|
}
|
|
}
|
|
return options.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
private async getLogoutProviderOptions(): Promise<AuthSelectorProvider[]> {
|
|
return (await this.session.modelRuntime.listCredentials({ signal: AbortSignal.timeout(15_000) }))
|
|
.map(({ providerId, type }) => {
|
|
const provider = this.session.modelRuntime.getProvider(providerId);
|
|
return {
|
|
id: providerId,
|
|
name: provider?.name ?? providerId,
|
|
authType: type,
|
|
status: { type, source: "stored credential" },
|
|
subscription: provider?.auth.oauth?.isSubscription === true,
|
|
};
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
private async handleLoginCommand(providerRef?: string): Promise<void> {
|
|
if (!providerRef) {
|
|
this.showLoginAuthTypeSelector();
|
|
return;
|
|
}
|
|
|
|
const providerOptions = findMatchingProviders(this.getLoginProviderOptions(), providerRef);
|
|
if (providerOptions.length === 1) {
|
|
await this.startProviderLogin(providerOptions[0]!);
|
|
return;
|
|
}
|
|
|
|
if (providerOptions.length > 1) {
|
|
const providerIds = new Set(providerOptions.map((provider) => provider.id));
|
|
if (providerIds.size === 1) {
|
|
this.showLoginAuthTypeSelector(providerOptions);
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.showLoginProviderSelector(undefined, providerRef);
|
|
}
|
|
|
|
/** `onBack` reopens the selector the login was started from when the user cancels it. */
|
|
private async startProviderLogin(providerOption: AuthSelectorProvider, onBack?: () => void): Promise<void> {
|
|
if (providerOption.authType === "oauth") {
|
|
await this.showLoginDialog(providerOption.id, providerOption.name, onBack);
|
|
} else if (providerOption.method?.login) {
|
|
await this.showApiKeyLoginDialog(providerOption.id, providerOption.name, onBack);
|
|
} else {
|
|
this.showAmbientAuthDialog(providerOption, onBack);
|
|
}
|
|
}
|
|
|
|
private showLoginAuthTypeSelector(providerOptions?: AuthSelectorProvider[]): void {
|
|
// The top-level selector offers Radius directly, as its last option.
|
|
const radiusOption = providerOptions
|
|
? undefined
|
|
: this.getLoginProviderOptions("oauth").find((provider) => provider.id === RADIUS_PROVIDER_ID);
|
|
const radiusText = radiusOption ? `Sign in with ${radiusOption.name}` : undefined;
|
|
const radiusLabel = radiusOption ? `${radiusText}${formatAuthSelectorProviderStatus(radiusOption)}` : undefined;
|
|
const oauthProvider = providerOptions?.find((provider) => provider.authType === "oauth");
|
|
const oauthLoginLabel =
|
|
oauthProvider?.method && "loginLabel" in oauthProvider.method ? oauthProvider.method.loginLabel : undefined;
|
|
const subscriptionLabel = oauthLoginLabel ?? "Sign in with an account";
|
|
const apiKeyLabel = "Sign in with an API key";
|
|
const availableAuthTypes = providerOptions
|
|
? new Set(providerOptions.map((provider) => provider.authType))
|
|
: new Set<AuthSelectorProvider["authType"]>(["oauth", "api_key"]);
|
|
const options: string[] = [];
|
|
if (availableAuthTypes.has("oauth")) {
|
|
options.push(subscriptionLabel);
|
|
}
|
|
if (availableAuthTypes.has("api_key")) {
|
|
options.push(apiKeyLabel);
|
|
}
|
|
if (radiusLabel) options.push(radiusLabel);
|
|
|
|
if (options.length === 0) {
|
|
this.showStatus("No login methods available.");
|
|
return;
|
|
}
|
|
|
|
if (providerOptions && options.length === 1) {
|
|
const providerOption = providerOptions[0];
|
|
if (providerOption) {
|
|
void this.startProviderLogin(providerOption);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const title = providerOptions?.[0]
|
|
? `Select authentication method for ${providerOptions[0].name}:`
|
|
: "Select authentication method:";
|
|
this.showSelector((done) => {
|
|
const onSelect = (option: string) => {
|
|
done();
|
|
if (radiusOption && option === radiusLabel) {
|
|
void this.startProviderLogin(radiusOption, () => this.showLoginAuthTypeSelector());
|
|
return;
|
|
}
|
|
const authType = option === subscriptionLabel ? "oauth" : "api_key";
|
|
if (providerOptions) {
|
|
const providerOption = providerOptions.find((provider) => provider.authType === authType);
|
|
if (providerOption) {
|
|
void this.startProviderLogin(providerOption, () => this.showLoginAuthTypeSelector(providerOptions));
|
|
}
|
|
return;
|
|
}
|
|
this.showLoginProviderSelector(authType);
|
|
};
|
|
const onCancel = () => {
|
|
done();
|
|
this.ui.requestRender();
|
|
};
|
|
const selector =
|
|
radiusLabel && radiusText
|
|
? createLoginMenuSelector(
|
|
this.ui,
|
|
title,
|
|
options,
|
|
{ label: radiusLabel, text: radiusText },
|
|
onSelect,
|
|
onCancel,
|
|
)
|
|
: new ExtensionSelectorComponent(title, options, onSelect, onCancel);
|
|
return { component: selector, focus: selector, dispose: () => selector.dispose() };
|
|
});
|
|
}
|
|
|
|
private showLoginProviderSelector(authType?: AuthSelectorProvider["authType"], initialSearchInput?: string): void {
|
|
const providerOptions = this.getLoginProviderOptions(authType);
|
|
if (providerOptions.length === 0) {
|
|
const message =
|
|
authType === "oauth"
|
|
? "No account providers available."
|
|
: authType === "api_key"
|
|
? "No API key providers available."
|
|
: "No login providers available.";
|
|
this.showStatus(message);
|
|
return;
|
|
}
|
|
|
|
this.showSelector((done) => {
|
|
const selector = new OAuthSelectorComponent(
|
|
"login",
|
|
providerOptions,
|
|
async (providerId, selectedAuthType) => {
|
|
done();
|
|
|
|
const providerOption = providerOptions.find(
|
|
(provider) => provider.id === providerId && provider.authType === selectedAuthType,
|
|
);
|
|
if (!providerOption) {
|
|
return;
|
|
}
|
|
|
|
await this.startProviderLogin(providerOption, () =>
|
|
this.showLoginProviderSelector(authType, initialSearchInput),
|
|
);
|
|
},
|
|
() => {
|
|
done();
|
|
if (authType) {
|
|
this.showLoginAuthTypeSelector();
|
|
} else {
|
|
this.ui.requestRender();
|
|
}
|
|
},
|
|
initialSearchInput,
|
|
);
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
|
|
if (mode === "login") {
|
|
this.showLoginAuthTypeSelector();
|
|
return;
|
|
}
|
|
|
|
let providerOptions: AuthSelectorProvider[];
|
|
try {
|
|
providerOptions = await this.getLogoutProviderOptions();
|
|
} catch (error) {
|
|
this.showError(`Could not read stored credentials: ${error instanceof Error ? error.message : String(error)}`);
|
|
return;
|
|
}
|
|
if (providerOptions.length === 0) {
|
|
this.showStatus(
|
|
"No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.showSelector((done) => {
|
|
const selector = new OAuthSelectorComponent(
|
|
mode,
|
|
providerOptions,
|
|
async (providerId: string) => {
|
|
done();
|
|
|
|
const providerOption = providerOptions.find((provider) => provider.id === providerId);
|
|
if (!providerOption) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.session.modelRuntime.logout(providerOption.id, {
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
await this.updateAvailableProviderCount();
|
|
const message =
|
|
providerOption.authType === "oauth"
|
|
? `Logged out of ${providerOption.name}`
|
|
: `Removed stored API key for ${providerOption.name}. Environment variables and models.json config are unchanged.`;
|
|
this.showStatus(message);
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.showError(
|
|
error instanceof CredentialSynchronizationError
|
|
? `Credentials removed for ${providerOption.name}, but local model state could not be synchronized: ${message}`
|
|
: `Logout failed: ${message}`,
|
|
);
|
|
}
|
|
},
|
|
() => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
);
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
private async completeProviderAuthentication(
|
|
providerId: string,
|
|
providerName: string,
|
|
authType: "oauth" | "api_key",
|
|
previousModel: Model<any> | undefined,
|
|
): Promise<void> {
|
|
const actionLabel = authType === "oauth" ? `Logged in to ${providerName}` : `Saved API key for ${providerName}`;
|
|
|
|
const session = this.session;
|
|
// Dynamic catalogs may be empty until the first authenticated network refresh.
|
|
const deferSelection =
|
|
isUnknownModel(previousModel) &&
|
|
hasDefaultModelProvider(providerId) &&
|
|
!session.modelRuntime
|
|
.getAvailableSnapshot()
|
|
.some((model) => model.provider === providerId && model.id === defaultModelPerProvider[providerId]);
|
|
const finishAuthentication = async () => {
|
|
let selectedModel: Model<any> | undefined;
|
|
let selectionError: string | undefined;
|
|
if (isUnknownModel(previousModel)) {
|
|
const availableModels = this.session.modelRuntime.getAvailableSnapshot();
|
|
const providerModels = availableModels.filter((model) => model.provider === providerId);
|
|
// Matches LLAMA_PROVIDER_ID from extensions/llama/provider.ts; kept inline to avoid coupling interactive mode to the built-in extension.
|
|
if (providerId === "llama.cpp") {
|
|
selectionError = llamaCppPostLoginGuidance(actionLabel, providerModels.length);
|
|
} else if (!hasDefaultModelProvider(providerId)) {
|
|
selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`;
|
|
} else if (providerModels.length === 0) {
|
|
selectionError = `${actionLabel}, but no models are available for that provider. Use /model to select a model.`;
|
|
} else {
|
|
const defaultModelId = defaultModelPerProvider[providerId];
|
|
// Radius catalogs vary by account; prefer balanced, then use catalog order.
|
|
selectedModel =
|
|
providerModels.find((model) => model.id === defaultModelId) ??
|
|
(providerId === "radius" ? providerModels[0] : undefined);
|
|
if (!selectedModel) {
|
|
selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`;
|
|
} else {
|
|
try {
|
|
await this.session.setModel(selectedModel, { persist: true });
|
|
} catch (error: unknown) {
|
|
selectedModel = undefined;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
selectionError = `${actionLabel}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await this.updateAvailableProviderCount();
|
|
this.footer.invalidate();
|
|
this.updateEditorBorderColor();
|
|
if (selectedModel) {
|
|
this.showStatus(`${actionLabel}. Selected ${selectedModel.id}. Credentials saved to ${getAuthPath()}`);
|
|
void this.maybeWarnAboutAnthropicSubscriptionAuth(selectedModel);
|
|
} else {
|
|
this.showStatus(`${actionLabel}. Credentials saved to ${getAuthPath()}`);
|
|
if (selectionError) {
|
|
this.showError(selectionError);
|
|
} else {
|
|
void this.maybeWarnAboutAnthropicSubscriptionAuth();
|
|
}
|
|
}
|
|
};
|
|
if (deferSelection) {
|
|
this.showStatus(`${actionLabel}. Credentials saved to ${getAuthPath()}. Refreshing model catalog…`);
|
|
} else {
|
|
await finishAuthentication();
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
void session.modelRuntime
|
|
.refresh({ providers: [providerId], signal: controller.signal })
|
|
.then(async (result) => {
|
|
if (result.aborted) {
|
|
this.showWarning(`${actionLabel}, but its model catalog refresh timed out; using cached models.`);
|
|
} else if (result.errors.size > 0) {
|
|
this.showWarning(`${actionLabel}, but its model catalog could not be refreshed; using cached models.`);
|
|
}
|
|
// Do not replace a model or session selected while the refresh was running.
|
|
if (deferSelection && this.session === session && session.model === previousModel) {
|
|
await finishAuthentication();
|
|
}
|
|
this.updateAvailableProviderCount();
|
|
this.footer.invalidate();
|
|
this.ui.requestRender();
|
|
})
|
|
.catch((error: unknown) => {
|
|
this.showWarning(
|
|
`${actionLabel}, but its model catalog could not be refreshed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
})
|
|
.finally(() => clearTimeout(timeout));
|
|
}
|
|
|
|
private showAmbientAuthDialog(providerOption: AuthSelectorProvider, onBack?: () => void): void {
|
|
const restoreEditor = () => {
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(this.editor);
|
|
this.ui.setFocus(this.editor);
|
|
this.ui.requestRender();
|
|
};
|
|
|
|
const dialog = new LoginDialogComponent(
|
|
this.ui,
|
|
providerOption.id,
|
|
() => {
|
|
restoreEditor();
|
|
onBack?.();
|
|
},
|
|
providerOption.name,
|
|
`${providerOption.name} setup`,
|
|
);
|
|
dialog.showInfo(
|
|
`${providerOption.method?.name ?? "Authentication"} is configured outside ${APP_NAME}.`,
|
|
[],
|
|
true,
|
|
);
|
|
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(dialog);
|
|
this.ui.setFocus(dialog);
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private async showApiKeyLoginDialog(providerId: string, providerName: string, onBack?: () => void): Promise<void> {
|
|
const previousModel = this.session.model;
|
|
|
|
const dialog = new LoginDialogComponent(
|
|
this.ui,
|
|
providerId,
|
|
(_success, _message) => {
|
|
// Completion handled below
|
|
},
|
|
providerName,
|
|
);
|
|
|
|
if (providerId === "amazon-bedrock") {
|
|
dialog.showDetails([
|
|
theme.fg("text", "You can also use an AWS profile, IAM keys, or role-based credentials."),
|
|
theme.fg("muted", "See:"),
|
|
theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`),
|
|
]);
|
|
}
|
|
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(dialog);
|
|
this.ui.setFocus(dialog);
|
|
this.ui.requestRender();
|
|
|
|
const restoreEditor = () => {
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(this.editor);
|
|
this.ui.setFocus(this.editor);
|
|
this.ui.requestRender();
|
|
};
|
|
|
|
try {
|
|
await this.loginProvider(dialog, providerId, providerName, "api_key");
|
|
restoreEditor();
|
|
await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel);
|
|
} catch (error: unknown) {
|
|
restoreEditor();
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
if (error instanceof CredentialSynchronizationError) {
|
|
this.showError(
|
|
`Saved API key for ${providerName}, but local model state could not be synchronized: ${errorMsg}`,
|
|
);
|
|
} else if (errorMsg === "Login cancelled") {
|
|
onBack?.();
|
|
} else {
|
|
this.showError(`Failed to save API key for ${providerName}: ${errorMsg}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private showAuthSelect(
|
|
dialog: LoginDialogComponent,
|
|
prompt: Extract<AuthPrompt, { type: "select" }>,
|
|
providerId: string,
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const restoreDialog = () => {
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(dialog);
|
|
this.ui.setFocus(dialog);
|
|
this.ui.requestRender();
|
|
};
|
|
const labels = prompt.options.map((option) => option.label);
|
|
const selector = new ExtensionSelectorComponent(
|
|
prompt.message,
|
|
labels,
|
|
(optionLabel) => {
|
|
restoreDialog();
|
|
const id = prompt.options.find((option) => option.label === optionLabel)?.id;
|
|
if (id) resolve(id);
|
|
else reject(new Error("Login cancelled"));
|
|
},
|
|
() => {
|
|
restoreDialog();
|
|
reject(new Error("Login cancelled"));
|
|
},
|
|
{ description: providerId === RADIUS_PROVIDER_ID ? RADIUS_LOGIN_INTRO : undefined },
|
|
);
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(selector);
|
|
this.ui.setFocus(selector);
|
|
this.ui.requestRender();
|
|
});
|
|
}
|
|
|
|
private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt, providerId: string): Promise<string> {
|
|
let response: Promise<string>;
|
|
if (prompt.type === "select") {
|
|
response = this.showAuthSelect(dialog, prompt, providerId);
|
|
} else if (prompt.type === "manual_code") {
|
|
response = dialog.showManualInput(prompt.message);
|
|
} else {
|
|
response = dialog.showPrompt(prompt.message, prompt.placeholder);
|
|
}
|
|
if (!prompt.signal) return response;
|
|
if (prompt.signal.aborted) throw new Error("Login cancelled");
|
|
const signal = prompt.signal;
|
|
let onAbort: (() => void) | undefined;
|
|
const aborted = new Promise<string>((_resolve, reject) => {
|
|
onAbort = () => reject(new Error("Login cancelled"));
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
try {
|
|
return await Promise.race([response, aborted]);
|
|
} finally {
|
|
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
}
|
|
}
|
|
|
|
private notifyAuthDialog(dialog: LoginDialogComponent, event: AuthEvent): void {
|
|
if (event.type === "auth_url") {
|
|
dialog.showAuth(event.url, event.instructions);
|
|
} else if (event.type === "device_code") {
|
|
dialog.showDeviceCode(event);
|
|
dialog.showWaiting("Waiting for authentication...");
|
|
} else if (event.type === "info") {
|
|
dialog.showInfo(event.message, event.links);
|
|
} else {
|
|
dialog.showProgress(event.message);
|
|
}
|
|
}
|
|
|
|
private async loginProvider(
|
|
dialog: LoginDialogComponent,
|
|
providerId: string,
|
|
providerName: string,
|
|
method: "api_key" | "oauth",
|
|
): Promise<void> {
|
|
this.programStatus.setBlocked("login", { kind: "auth", message: `Log in to ${providerName}` });
|
|
try {
|
|
await this.session.modelRuntime.login(
|
|
providerId,
|
|
method,
|
|
{
|
|
signal: dialog.signal,
|
|
prompt: (prompt) => this.showAuthPrompt(dialog, prompt, providerId),
|
|
notify: (event) => this.notifyAuthDialog(dialog, event),
|
|
},
|
|
{ getDeviceId: () => this.settingsManager.getOrCreateDeviceId() },
|
|
);
|
|
} finally {
|
|
this.programStatus.setBlocked("login", undefined);
|
|
}
|
|
}
|
|
|
|
private async showLoginDialog(providerId: string, providerName: string, onBack?: () => void): Promise<void> {
|
|
const previousModel = this.session.model;
|
|
const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {}, providerName);
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(dialog);
|
|
this.ui.setFocus(dialog);
|
|
this.ui.requestRender();
|
|
|
|
const restoreEditor = () => {
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(this.editor);
|
|
this.ui.setFocus(this.editor);
|
|
this.ui.requestRender();
|
|
};
|
|
|
|
try {
|
|
await this.loginProvider(dialog, providerId, providerName, "oauth");
|
|
restoreEditor();
|
|
await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel);
|
|
if (providerId === RADIUS_PROVIDER_ID) this.offerRadiusMcpServer(providerId, providerName);
|
|
} catch (error: unknown) {
|
|
restoreEditor();
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
if (error instanceof CredentialSynchronizationError) {
|
|
this.showError(
|
|
`Logged in to ${providerName}, but local model state could not be synchronized: ${errorMsg}`,
|
|
);
|
|
} else if (errorMsg === "Login cancelled") {
|
|
onBack?.();
|
|
} else {
|
|
this.showError(`Failed to login to ${providerName}: ${errorMsg}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Offer to point the Radius MCP server in the global mcp.json at the Radius login, adding the server
|
|
* when missing. Nothing is asked when a global server already uses this login.
|
|
*/
|
|
private offerRadiusMcpServer(providerId: string, providerName: string): void {
|
|
const plan = planRadiusMcpServer(providerId, getAgentDir(), this.sessionManager.getCwd());
|
|
if (!plan) return;
|
|
const { mcpPath, name, config } = plan;
|
|
|
|
this.showSelector((done) => {
|
|
const selector = new ExtensionSelectorComponent(
|
|
`Configure ${providerName} MCP in ${mcpPath}?`,
|
|
["Yes", "No"],
|
|
(option) => {
|
|
done();
|
|
if (option !== "Yes") return;
|
|
try {
|
|
addMcpServerConfig(mcpPath, name, config);
|
|
} catch (error: unknown) {
|
|
this.showError(
|
|
`Could not update ${mcpPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
return;
|
|
}
|
|
// The MCP extension reads mcp.json when the session starts.
|
|
void this.handleReloadCommand();
|
|
},
|
|
() => {
|
|
done();
|
|
this.ui.requestRender();
|
|
},
|
|
);
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// Command handlers
|
|
// =========================================================================
|
|
|
|
private async handleReloadCommand(): Promise<void> {
|
|
if (this.session.isStreaming) {
|
|
this.showWarning("Wait for the current response to finish before reloading.");
|
|
return;
|
|
}
|
|
if (this.session.isCompacting) {
|
|
this.showWarning("Wait for compaction to finish before reloading.");
|
|
return;
|
|
}
|
|
|
|
this.resetExtensionUI();
|
|
|
|
const reloadBox = new Container();
|
|
const borderColor = (s: string) => theme.fg("border", s);
|
|
reloadBox.addChild(new DynamicBorder(borderColor));
|
|
reloadBox.addChild(new Spacer(1));
|
|
reloadBox.addChild(
|
|
new ThemedText(
|
|
() => theme.fg("muted", "Reloading keybindings, extensions, skills, prompts, themes, and context files..."),
|
|
1,
|
|
0,
|
|
),
|
|
);
|
|
reloadBox.addChild(new Spacer(1));
|
|
reloadBox.addChild(new DynamicBorder(borderColor));
|
|
|
|
const previousEditor = this.editor;
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(reloadBox);
|
|
this.ui.setFocus(reloadBox);
|
|
this.ui.requestRender(true);
|
|
await new Promise((resolve) => process.nextTick(resolve));
|
|
|
|
const dismissReloadBox = (editor: Component) => {
|
|
this.editorContainer.clear();
|
|
this.editorContainer.addChild(editor);
|
|
this.ui.setFocus(editor);
|
|
this.ui.requestRender();
|
|
};
|
|
|
|
let chatRestoredBeforeSessionStart = false;
|
|
let reloadBoxDismissed = false;
|
|
const restoreChatBeforeSessionStart = () => {
|
|
if (chatRestoredBeforeSessionStart) {
|
|
return;
|
|
}
|
|
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
|
this.outputPad = this.settingsManager.getOutputPad();
|
|
this.rebuildChatFromMessages();
|
|
chatRestoredBeforeSessionStart = true;
|
|
};
|
|
|
|
try {
|
|
await this.session.reload({ beforeSessionStart: restoreChatBeforeSessionStart });
|
|
restoreChatBeforeSessionStart();
|
|
this.keybindings.reload();
|
|
const activeHeader = this.customHeader ?? this.builtInHeader;
|
|
if (isExpandable(activeHeader)) {
|
|
activeHeader.setExpanded(this.toolOutputExpanded);
|
|
}
|
|
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
|
this.applyRuntimeSettings();
|
|
this.themeController.applyFromSettings();
|
|
this.setupAutocompleteProvider();
|
|
const runner = this.session.extensionRunner;
|
|
this.setupExtensionShortcuts(runner);
|
|
this.showLoadedResources({
|
|
force: false,
|
|
showDiagnosticsWhenQuiet: true,
|
|
});
|
|
const savedImplicitProjectTrust = this.maybeSaveImplicitProjectTrustAfterReload();
|
|
const modelsJsonError = this.session.modelRuntime.getError();
|
|
if (modelsJsonError) {
|
|
this.showError(`models.json error: ${modelsJsonError}`);
|
|
}
|
|
this.showStatus(
|
|
savedImplicitProjectTrust
|
|
? "Reloaded keybindings, extensions, skills, prompts, themes, and context files; saved project trust"
|
|
: "Reloaded keybindings, extensions, skills, prompts, themes, and context files",
|
|
);
|
|
dismissReloadBox(this.editor as Component);
|
|
reloadBoxDismissed = true;
|
|
} catch (error) {
|
|
if (!reloadBoxDismissed) {
|
|
dismissReloadBox(previousEditor as Component);
|
|
}
|
|
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
private async handleExportCommand(text: string): Promise<void> {
|
|
const outputPath = this.getPathCommandArgument(text, "/export");
|
|
|
|
try {
|
|
if (outputPath?.endsWith(".jsonl")) {
|
|
const filePath = this.session.exportToJsonl(outputPath);
|
|
this.showStatus(`Session exported to: ${filePath}`);
|
|
} else {
|
|
const filePath = await this.session.exportToHtml(outputPath, {
|
|
themeName: theme.name,
|
|
});
|
|
this.showStatus(`Session exported to: ${filePath}`);
|
|
}
|
|
} catch (error: unknown) {
|
|
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
}
|
|
}
|
|
|
|
private getPathCommandArgument(text: string, command: "/export" | "/import"): string | undefined {
|
|
if (text === command) {
|
|
return undefined;
|
|
}
|
|
if (!text.startsWith(`${command} `)) {
|
|
return undefined;
|
|
}
|
|
|
|
const argsString = text.slice(command.length + 1).trimStart();
|
|
if (!argsString) {
|
|
return undefined;
|
|
}
|
|
|
|
const firstChar = argsString[0];
|
|
if (firstChar === '"' || firstChar === "'") {
|
|
const closingQuoteIndex = argsString.indexOf(firstChar, 1);
|
|
if (closingQuoteIndex < 0) {
|
|
return undefined;
|
|
}
|
|
return argsString.slice(1, closingQuoteIndex);
|
|
}
|
|
|
|
const firstWhitespaceIndex = argsString.search(/\s/);
|
|
if (firstWhitespaceIndex < 0) {
|
|
return argsString;
|
|
}
|
|
return argsString.slice(0, firstWhitespaceIndex);
|
|
}
|
|
|
|
private async handleImportCommand(text: string): Promise<void> {
|
|
const inputPath = this.getPathCommandArgument(text, "/import");
|
|
if (!inputPath) {
|
|
this.showError("Usage: /import <path.jsonl>");
|
|
return;
|
|
}
|
|
|
|
const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`);
|
|
if (!confirmed) {
|
|
this.showStatus("Import cancelled");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.clearStatusIndicator();
|
|
const result = await this.runtimeHost.importFromJsonl(inputPath);
|
|
if (result.cancelled) {
|
|
this.showStatus("Import cancelled");
|
|
return;
|
|
}
|
|
this.showStatus(`Session imported from: ${inputPath}`);
|
|
} catch (error: unknown) {
|
|
if (error instanceof MissingSessionCwdError) {
|
|
const selectedCwd = await this.promptForMissingSessionCwd(error);
|
|
if (!selectedCwd) {
|
|
this.showStatus("Import cancelled");
|
|
return;
|
|
}
|
|
const result = await this.runtimeHost.importFromJsonl(inputPath, selectedCwd);
|
|
if (result.cancelled) {
|
|
this.showStatus("Import cancelled");
|
|
return;
|
|
}
|
|
this.showStatus(`Session imported from: ${inputPath}`);
|
|
return;
|
|
}
|
|
if (error instanceof SessionImportFileNotFoundError) {
|
|
this.showError(`Failed to import session: ${error.message}`);
|
|
return;
|
|
}
|
|
await this.handleFatalRuntimeError("Failed to import session", error);
|
|
}
|
|
}
|
|
|
|
private async handleShareCommand(): Promise<void> {
|
|
await shareSession({
|
|
session: this.session,
|
|
ui: this.ui,
|
|
editorContainer: this.editorContainer,
|
|
editor: this.editor,
|
|
showStatus: (message) => this.showStatus(message),
|
|
showError: (message) => this.showError(message),
|
|
});
|
|
}
|
|
|
|
private async handleBugCommand(hint: string | undefined): Promise<void> {
|
|
await reportBug(
|
|
{
|
|
session: this.session,
|
|
ui: this.ui,
|
|
editorContainer: this.editorContainer,
|
|
editor: this.editor,
|
|
keybindings: this.keybindings,
|
|
showStatus: (message) => this.showStatus(message),
|
|
showError: (message) => this.showError(message),
|
|
},
|
|
hint,
|
|
);
|
|
}
|
|
|
|
private async handleCopyCommand(
|
|
options: { flashConfirmation?: boolean; preferSelection?: boolean } = {},
|
|
): Promise<void> {
|
|
if (
|
|
options.preferSelection &&
|
|
this.ui instanceof TuiAltScreen &&
|
|
!this.ui.getCopyOnSelect() &&
|
|
this.ui.hasActiveSelection()
|
|
) {
|
|
await this.ui.copyActiveSelectionToClipboard();
|
|
return;
|
|
}
|
|
|
|
const text = this.session.getLastAssistantText();
|
|
if (!text) {
|
|
this.showError("No agent messages to copy yet.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await copyToClipboard(text);
|
|
if (options.flashConfirmation && this.ui instanceof TuiAltScreen) {
|
|
this.ui.flash("Copied!");
|
|
} else {
|
|
this.showStatus("Copied last agent message to clipboard");
|
|
}
|
|
} catch (error) {
|
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
private handleNameCommand(text: string): void {
|
|
const name = text.replace(/^\/name\s*/, "").trim();
|
|
if (!name) {
|
|
const currentName = this.sessionManager.getSessionName();
|
|
if (currentName) {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg("dim", `Session name: ${currentName}`), 1, 0));
|
|
} else {
|
|
this.showWarning("Usage: /name <name>");
|
|
}
|
|
this.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
this.session.setSessionName(name);
|
|
const sessionName = this.sessionManager.getSessionName();
|
|
if (sessionName !== name) {
|
|
this.showWarning(`Session name was normalized from ${JSON.stringify(name)} to ${JSON.stringify(sessionName)}`);
|
|
}
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
const displayName = sessionName ?? name;
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg("dim", `Session name set: ${displayName}`), 1, 0));
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private handleSessionCommand(): void {
|
|
const stats = this.session.getSessionStats();
|
|
const sessionName = this.sessionManager.getSessionName();
|
|
const entries = this.sessionManager.getEntries();
|
|
const cacheWaste = computeCacheWaste(entries, this.session.modelRuntime);
|
|
|
|
// Cost/token totals per provider/model actually used (e.g. OpenRouter `auto`
|
|
// resolves to a concrete responseModel). Usage without model attribution is
|
|
// grouped separately so the breakdown reconciles with the session total.
|
|
const usageBreakdown = getUsageCostBreakdown(entries);
|
|
|
|
// Snapshot the stats; the text is built on demand so it follows theme changes.
|
|
const cacheWarmingStatus = this.session.cacheWarmingStatus;
|
|
const cacheWarmingMode = this.settingsManager.getCacheWarmingMode();
|
|
const model = this.session.model;
|
|
const selectedModelKey = `${model?.provider}/${model?.id}`;
|
|
const renderInfo = (): string => {
|
|
let info = `${theme.bold("Session Info")}\n\n`;
|
|
if (sessionName) {
|
|
info += `${theme.fg("dim", "Name:")} ${sessionName}\n`;
|
|
}
|
|
info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
|
|
info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
|
|
info += `${theme.bold("Messages")}\n`;
|
|
info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n`;
|
|
info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
|
|
info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`;
|
|
info += `${theme.fg("dim", "Tools:")} ${stats.toolCalls} calls, ${stats.toolResults} results\n\n`;
|
|
info += `${theme.bold("Tokens")}\n`;
|
|
// "Input" is the full prompt volume. With cache activity, split it into
|
|
// cached (served from cache) vs uncached (everything else) - the only
|
|
// provider-independent split. Cache writes, where reported, are a detail
|
|
// of the uncached portion.
|
|
const { input, cacheRead, cacheWrite } = stats.tokens;
|
|
const promptTokens = input + cacheRead + cacheWrite;
|
|
info += `${theme.fg("dim", "Input:")} ${promptTokens.toLocaleString()}\n`;
|
|
if (promptTokens > 0 && (cacheRead > 0 || cacheWrite > 0)) {
|
|
const hitRate = theme.fg("dim", `(${((cacheRead / promptTokens) * 100).toFixed(1)}%)`);
|
|
info += ` ${theme.fg("dim", "Cached:")} ${cacheRead.toLocaleString()} ${hitRate}\n`;
|
|
const written =
|
|
cacheWrite > 0 ? ` ${theme.fg("dim", `(${cacheWrite.toLocaleString()} written to cache)`)}` : "";
|
|
info += ` ${theme.fg("dim", "Uncached:")} ${(input + cacheWrite).toLocaleString()}${written}\n`;
|
|
}
|
|
info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
|
|
info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
|
|
|
|
info += `\n${theme.bold("Cache Warming")}\n`;
|
|
info += `${theme.fg("dim", "Mode:")} ${cacheWarmingMode}\n`;
|
|
info += `${theme.fg("dim", "Status:")} ${cacheWarmingStatus ? formatCacheWarmingStatus(cacheWarmingStatus) : "Inactive (cache warming unavailable)"}\n`;
|
|
const decision = cacheWarmingStatus?.decision;
|
|
if (decision?.economicsAvailable) {
|
|
info += `${theme.fg("dim", "Cache miss penalty:")} $${decision.missCost.toFixed(3)}\n`;
|
|
info += `${theme.fg("dim", "Refresh cost:")} $${decision.warmCost.toFixed(3)}\n`;
|
|
}
|
|
|
|
if (stats.cost > 0 || cacheWaste.missedTokens > 0) {
|
|
info += `\n${theme.bold("Cost")}\n`;
|
|
info += `${theme.fg("dim", "Total:")} $${stats.cost.toFixed(3)}`;
|
|
// A single entry repeats the total, unless it names a model other than the selected one.
|
|
if (usageBreakdown.length > 1 || usageBreakdown[0]?.key !== selectedModelKey) {
|
|
for (const entry of usageBreakdown) {
|
|
info += `\n ${theme.fg("dim", `${entry.key}:`)} $${entry.cost.toFixed(3)} ${theme.fg("dim", `(${formatTokens(entry.tokens)} tokens)`)}`;
|
|
}
|
|
}
|
|
if (cacheWaste.missedTokens > 0) {
|
|
const missLabel = cacheWaste.missCount === 1 ? "1 miss" : `${cacheWaste.missCount} misses`;
|
|
const detail = `${cacheWaste.missedTokens.toLocaleString()} tokens, ${missLabel}`;
|
|
info +=
|
|
cacheWaste.missedCost >= 0.0001
|
|
? `\n${theme.fg("dim", "Cache Re-billed:")} $${cacheWaste.missedCost.toFixed(3)} ${theme.fg("dim", `(${detail})`)}`
|
|
: `\n${theme.fg("dim", "Cache Re-billed:")} ${detail}`;
|
|
}
|
|
}
|
|
return info;
|
|
};
|
|
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new ThemedText(renderInfo, 1, 0));
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private handleChangelogCommand(): void {
|
|
const changelogPath = getChangelogPath();
|
|
const allEntries = parseChangelog(changelogPath);
|
|
|
|
const changelogMarkdown =
|
|
allEntries.length > 0
|
|
? allEntries
|
|
.reverse()
|
|
.map((e) => normalizeChangelogLinks(e.content, e))
|
|
.join("\n\n")
|
|
: "No changelog entries found.";
|
|
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new DynamicBorder());
|
|
this.chatContainer.addChild(new ThemedText(() => theme.bold(theme.fg("accent", "What's New")), 1, 0));
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(this.createChangelogMarkdown(changelogMarkdown, 1, 1));
|
|
this.chatContainer.addChild(new DynamicBorder());
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Get capitalized display string for an app keybinding action.
|
|
*/
|
|
private getAppKeyDisplay(action: AppKeybinding): string {
|
|
return keyDisplayText(action);
|
|
}
|
|
|
|
/**
|
|
* Get capitalized display string for an editor keybinding action.
|
|
*/
|
|
private getEditorKeyDisplay(action: Keybinding): string {
|
|
return keyDisplayText(action);
|
|
}
|
|
|
|
private handleHotkeysCommand(): void {
|
|
// Navigation keybindings
|
|
const cursorUp = this.getEditorKeyDisplay("tui.editor.cursorUp");
|
|
const cursorDown = this.getEditorKeyDisplay("tui.editor.cursorDown");
|
|
const cursorLeft = this.getEditorKeyDisplay("tui.editor.cursorLeft");
|
|
const cursorRight = this.getEditorKeyDisplay("tui.editor.cursorRight");
|
|
const cursorWordLeft = this.getEditorKeyDisplay("tui.editor.cursorWordLeft");
|
|
const cursorWordRight = this.getEditorKeyDisplay("tui.editor.cursorWordRight");
|
|
const cursorLineStart = this.getEditorKeyDisplay("tui.editor.cursorLineStart");
|
|
const cursorLineEnd = this.getEditorKeyDisplay("tui.editor.cursorLineEnd");
|
|
const jumpForward = this.getEditorKeyDisplay("tui.editor.jumpForward");
|
|
const jumpBackward = this.getEditorKeyDisplay("tui.editor.jumpBackward");
|
|
const pageUp = this.getEditorKeyDisplay("tui.editor.pageUp");
|
|
const pageDown = this.getEditorKeyDisplay("tui.editor.pageDown");
|
|
|
|
// Editing keybindings
|
|
const submit = this.getEditorKeyDisplay("tui.input.submit");
|
|
const newLine = this.getEditorKeyDisplay("tui.input.newLine");
|
|
const deleteWordBackward = this.getEditorKeyDisplay("tui.editor.deleteWordBackward");
|
|
const deleteWordForward = this.getEditorKeyDisplay("tui.editor.deleteWordForward");
|
|
const deleteToLineStart = this.getEditorKeyDisplay("tui.editor.deleteToLineStart");
|
|
const deleteToLineEnd = this.getEditorKeyDisplay("tui.editor.deleteToLineEnd");
|
|
const yank = this.getEditorKeyDisplay("tui.editor.yank");
|
|
const yankPop = this.getEditorKeyDisplay("tui.editor.yankPop");
|
|
const undo = this.getEditorKeyDisplay("tui.editor.undo");
|
|
const tab = this.getEditorKeyDisplay("tui.input.tab");
|
|
|
|
// App keybindings
|
|
const interrupt = this.getAppKeyDisplay("app.interrupt");
|
|
const clear = this.getAppKeyDisplay("app.clear");
|
|
const exit = this.getAppKeyDisplay("app.exit");
|
|
const suspend = this.getAppKeyDisplay("app.suspend");
|
|
const cycleThinkingLevel = this.getAppKeyDisplay("app.thinking.cycle");
|
|
const cycleModelForward = this.getAppKeyDisplay("app.model.cycleForward");
|
|
const selectModel = this.getAppKeyDisplay("app.model.select");
|
|
const expandTools = this.getAppKeyDisplay("app.tools.expand");
|
|
const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle");
|
|
const externalEditor = this.getAppKeyDisplay("app.editor.external");
|
|
const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward");
|
|
const copyMessage = this.getAppKeyDisplay("app.message.copy");
|
|
const followUp = this.getAppKeyDisplay("app.message.followUp");
|
|
const dequeue = this.getAppKeyDisplay("app.message.dequeue");
|
|
const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage");
|
|
|
|
let hotkeys = `
|
|
**Navigation**
|
|
| Key | Action |
|
|
|-----|--------|
|
|
| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history |
|
|
| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
|
|
| \`${cursorLineStart}\` | Start of line |
|
|
| \`${cursorLineEnd}\` | End of line |
|
|
| \`${jumpForward}\` | Jump forward to character |
|
|
| \`${jumpBackward}\` | Jump backward to character |
|
|
| \`${pageUp}\` / \`${pageDown}\` | Scroll by page |
|
|
|
|
**Editing**
|
|
| Key | Action |
|
|
|-----|--------|
|
|
| \`${submit}\` | Send message |
|
|
| \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} |
|
|
| \`${deleteWordBackward}\` | Delete word backwards |
|
|
| \`${deleteWordForward}\` | Delete word forwards |
|
|
| \`${deleteToLineStart}\` | Delete to start of line |
|
|
| \`${deleteToLineEnd}\` | Delete to end of line |
|
|
| \`${yank}\` | Paste the most-recently-deleted text |
|
|
| \`${yankPop}\` | Cycle through the deleted text after pasting |
|
|
| \`${undo}\` | Undo |
|
|
|
|
**Other**
|
|
| Key | Action |
|
|
|-----|--------|
|
|
| \`${tab}\` | Path completion / accept autocomplete |
|
|
| \`${interrupt}\` | Cancel autocomplete / abort streaming |
|
|
| \`${clear}\` | Clear editor (first) / exit (second) |
|
|
| \`${exit}\` | Exit (when editor is empty) |
|
|
| \`${suspend}\` | Suspend to background |
|
|
| \`${cycleThinkingLevel}\` | Cycle thinking level |
|
|
| \`${cycleModelForward}\` / \`${cycleModelBackward}\` | Cycle models |
|
|
| \`${selectModel}\` | Open model selector |
|
|
| \`${expandTools}\` | Toggle tool output expansion |
|
|
| \`${toggleThinking}\` | Toggle thinking block visibility |
|
|
| \`${externalEditor}\` | Edit message in external editor |
|
|
| \`${copyMessage}\` | Copy selection or last assistant message |
|
|
| \`${followUp}\` | Queue follow-up message |
|
|
| \`${dequeue}\` | Restore queued messages |
|
|
| \`${pasteImage}\` | Paste files on macOS, images, or text from clipboard |
|
|
| \`/\` | Slash commands |
|
|
| \`!\` | Run bash command |
|
|
| \`!!\` | Run bash command (excluded from context) |
|
|
`;
|
|
|
|
// Add extension-registered shortcuts
|
|
const extensionRunner = this.session.extensionRunner;
|
|
const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());
|
|
if (shortcuts.size > 0) {
|
|
hotkeys += `
|
|
**Extensions**
|
|
| Key | Action |
|
|
|-----|--------|
|
|
`;
|
|
for (const [key, shortcut] of shortcuts) {
|
|
const description = shortcut.description ?? shortcut.extensionPath;
|
|
const keyDisplay = formatKeyText(key, { capitalize: true });
|
|
hotkeys += `| \`${keyDisplay}\` | ${description} |\n`;
|
|
}
|
|
}
|
|
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new DynamicBorder());
|
|
this.chatContainer.addChild(new ThemedText(() => theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings()));
|
|
this.chatContainer.addChild(new DynamicBorder());
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private async handleClearCommand(): Promise<void> {
|
|
this.clearStatusIndicator();
|
|
try {
|
|
const result = await this.runtimeHost.newSession();
|
|
if (result.cancelled) {
|
|
return;
|
|
}
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new ThemedText(() => theme.fg("accent", "✓ New session started"), 1, 1));
|
|
this.ui.requestRender();
|
|
} catch (error: unknown) {
|
|
await this.handleFatalRuntimeError("Failed to create session", error);
|
|
}
|
|
}
|
|
|
|
private handleDebugCommand(): void {
|
|
const width = this.ui.terminal.columns;
|
|
const height = this.ui.terminal.rows;
|
|
const allLines = this.ui.render(width);
|
|
|
|
const debugLogPath = getDebugLogPath();
|
|
const debugData = [
|
|
`Debug output at ${new Date().toISOString()}`,
|
|
`Terminal: ${width}x${height}`,
|
|
`Total lines: ${allLines.length}`,
|
|
"",
|
|
"=== All rendered lines with visible widths ===",
|
|
...allLines.map((line, idx) => {
|
|
const vw = visibleWidth(line);
|
|
const escaped = JSON.stringify(line);
|
|
return `[${idx}] (w=${vw}) ${escaped}`;
|
|
}),
|
|
"",
|
|
"=== Agent messages (JSONL) ===",
|
|
...this.session.messages.map((msg) => JSON.stringify(msg)),
|
|
"",
|
|
].join("\n");
|
|
|
|
fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
|
|
fs.writeFileSync(debugLogPath, debugData);
|
|
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(
|
|
new ThemedText(() => `${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`, 1, 1),
|
|
);
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private handleArminSaysHi(): void {
|
|
if (playArmin3d(this.renderer)) return;
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new ArminComponent(this.ui));
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private handleDementedDelves(): void {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new EarendilAnnouncementComponent());
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
|
|
const extensionRunner = this.session.extensionRunner;
|
|
|
|
// Emit user_bash event to let extensions intercept
|
|
let eventResult: UserBashEventResult | undefined;
|
|
try {
|
|
eventResult = await extensionRunner.emitUserBash({
|
|
type: "user_bash",
|
|
command,
|
|
excludeFromContext,
|
|
cwd: this.sessionManager.getCwd(),
|
|
});
|
|
} catch {
|
|
// The extension runner already reported the error. Do not fall back to local execution.
|
|
return;
|
|
}
|
|
|
|
// If extension returned a full result, use it directly
|
|
if (eventResult?.result) {
|
|
const result = eventResult.result;
|
|
|
|
// Create UI component for display
|
|
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext, this.outputPad);
|
|
if (this.session.isStreaming) {
|
|
this.pendingMessagesContainer.addChild(this.bashComponent);
|
|
this.pendingBashComponents.push(this.bashComponent);
|
|
} else {
|
|
this.chatContainer.addChild(this.bashComponent);
|
|
}
|
|
|
|
// Show output and complete
|
|
if (result.output) {
|
|
this.bashComponent.appendOutput(result.output);
|
|
}
|
|
this.bashComponent.setComplete(
|
|
result.exitCode,
|
|
result.cancelled,
|
|
result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined,
|
|
result.fullOutputPath,
|
|
);
|
|
|
|
// Record the result in session
|
|
this.session.recordBashResult(command, result, { excludeFromContext });
|
|
this.bashComponent = undefined;
|
|
this.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
// Normal execution path (possibly with custom operations)
|
|
const isDeferred = this.session.isStreaming;
|
|
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext, this.outputPad);
|
|
|
|
if (isDeferred) {
|
|
// Show in pending area when agent is streaming
|
|
this.pendingMessagesContainer.addChild(this.bashComponent);
|
|
this.pendingBashComponents.push(this.bashComponent);
|
|
} else {
|
|
// Show in chat immediately when agent is idle
|
|
this.chatContainer.addChild(this.bashComponent);
|
|
}
|
|
this.ui.requestRender();
|
|
|
|
try {
|
|
const result = await this.session.executeBash(
|
|
command,
|
|
(chunk) => {
|
|
if (this.bashComponent) {
|
|
this.bashComponent.appendOutput(chunk);
|
|
this.ui.requestRender();
|
|
}
|
|
},
|
|
{ excludeFromContext, operations: eventResult?.operations },
|
|
);
|
|
|
|
if (this.bashComponent) {
|
|
this.bashComponent.setComplete(
|
|
result.exitCode,
|
|
result.cancelled,
|
|
result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined,
|
|
result.fullOutputPath,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (this.bashComponent) {
|
|
this.bashComponent.setComplete(undefined, false);
|
|
}
|
|
this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
}
|
|
|
|
this.bashComponent = undefined;
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
private async handleCompactCommand(customInstructions?: string): Promise<void> {
|
|
this.clearStatusIndicator();
|
|
|
|
try {
|
|
await this.session.compact(customInstructions);
|
|
} catch {
|
|
// Ignore, will be emitted as an event
|
|
}
|
|
}
|
|
|
|
stop(fullscreenExitOutput = this.settingsManager.getFullscreenExitOutput()): void {
|
|
this.disposeActiveSelector();
|
|
if (this.settingsManager.getShowTerminalProgress()) {
|
|
this.ui.terminal.setProgress(false);
|
|
}
|
|
this.clearStatusIndicator();
|
|
this.themeController.disableAutoSync();
|
|
this.clearExtensionTerminalInputListeners();
|
|
this.footer.dispose();
|
|
this.footerDataProvider.dispose();
|
|
if (this.unsubscribe) {
|
|
this.unsubscribe();
|
|
}
|
|
if (this.isInitialized) {
|
|
this.stopInteractiveTui(fullscreenExitOutput);
|
|
this.isInitialized = false;
|
|
}
|
|
this.unregisterSignalHandlers();
|
|
}
|
|
}
|