import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; import { Container, getCapabilities, type SelectItem, SelectList, type SettingItem, SettingsList, Spacer, Text, } from "@mariozechner/pi-tui"; import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", minimal: "Very brief reasoning (~1k tokens)", low: "Light reasoning (~2k tokens)", medium: "Moderate reasoning (~8k tokens)", high: "Deep reasoning (~16k tokens)", xhigh: "Maximum reasoning (~32k tokens)", }; export interface SettingsConfig { autoCompact: boolean; showImages: boolean; autoResizeImages: boolean; blockImages: boolean; enableSkillCommands: boolean; steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; currentTheme: string; availableThemes: string[]; hideThinkingBlock: boolean; collapseChangelog: boolean; doubleEscapeAction: "fork" | "tree" | "none"; showHardwareCursor: boolean; editorPaddingX: number; autocompleteMaxVisible: number; quietStartup: boolean; } export interface SettingsCallbacks { onAutoCompactChange: (enabled: boolean) => void; onShowImagesChange: (enabled: boolean) => void; onAutoResizeImagesChange: (enabled: boolean) => void; onBlockImagesChange: (blocked: boolean) => void; onEnableSkillCommandsChange: (enabled: boolean) => void; onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onThinkingLevelChange: (level: ThinkingLevel) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; onHideThinkingBlockChange: (hidden: boolean) => void; onCollapseChangelogChange: (collapsed: boolean) => void; onDoubleEscapeActionChange: (action: "fork" | "tree" | "none") => void; onShowHardwareCursorChange: (enabled: boolean) => void; onEditorPaddingXChange: (padding: number) => void; onAutocompleteMaxVisibleChange: (maxVisible: number) => void; onQuietStartupChange: (enabled: boolean) => void; onCancel: () => void; } /** * A submenu component for selecting from a list of options. */ class SelectSubmenu extends Container { private selectList: SelectList; constructor( title: string, description: string, options: SelectItem[], currentValue: string, onSelect: (value: string) => void, onCancel: () => void, onSelectionChange?: (value: string) => void, ) { super(); // Title this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); // Description if (description) { this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("muted", description), 0, 0)); } // Spacer this.addChild(new Spacer(1)); // Select list this.selectList = new SelectList(options, Math.min(options.length, 10), getSelectListTheme()); // Pre-select current value const currentIndex = options.findIndex((o) => o.value === currentValue); if (currentIndex !== -1) { this.selectList.setSelectedIndex(currentIndex); } this.selectList.onSelect = (item) => { onSelect(item.value); }; this.selectList.onCancel = onCancel; if (onSelectionChange) { this.selectList.onSelectionChange = (item) => { onSelectionChange(item.value); }; } this.addChild(this.selectList); // Hint this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", " Enter to select ยท Esc to go back"), 0, 0)); } handleInput(data: string): void { this.selectList.handleInput(data); } } /** * Main settings selector component. */ export class SettingsSelectorComponent extends Container { private settingsList: SettingsList; constructor(config: SettingsConfig, callbacks: SettingsCallbacks) { super(); const supportsImages = getCapabilities().images; const items: SettingItem[] = [ { id: "autocompact", label: "Auto-compact", description: "Automatically compact context when it gets too large", currentValue: config.autoCompact ? "true" : "false", values: ["true", "false"], }, { id: "steering-mode", label: "Steering mode", description: "Enter while streaming queues steering messages. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.", currentValue: config.steeringMode, values: ["one-at-a-time", "all"], }, { id: "follow-up-mode", label: "Follow-up mode", description: "Alt+Enter queues follow-up messages until agent stops. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.", currentValue: config.followUpMode, values: ["one-at-a-time", "all"], }, { id: "hide-thinking", label: "Hide thinking", description: "Hide thinking blocks in assistant responses", currentValue: config.hideThinkingBlock ? "true" : "false", values: ["true", "false"], }, { id: "collapse-changelog", label: "Collapse changelog", description: "Show condensed changelog after updates", currentValue: config.collapseChangelog ? "true" : "false", values: ["true", "false"], }, { id: "quiet-startup", label: "Quiet startup", description: "Disable verbose printing at startup", currentValue: config.quietStartup ? "true" : "false", values: ["true", "false"], }, { id: "double-escape-action", label: "Double-escape action", description: "Action when pressing Escape twice with empty editor", currentValue: config.doubleEscapeAction, values: ["tree", "fork", "none"], }, { id: "thinking", label: "Thinking level", description: "Reasoning depth for thinking-capable models", currentValue: config.thinkingLevel, submenu: (currentValue, done) => new SelectSubmenu( "Thinking Level", "Select reasoning depth for thinking-capable models", config.availableThinkingLevels.map((level) => ({ value: level, label: level, description: THINKING_DESCRIPTIONS[level], })), currentValue, (value) => { callbacks.onThinkingLevelChange(value as ThinkingLevel); done(value); }, () => done(), ), }, { id: "theme", label: "Theme", description: "Color theme for the interface", currentValue: config.currentTheme, submenu: (currentValue, done) => new SelectSubmenu( "Theme", "Select color theme", config.availableThemes.map((t) => ({ value: t, label: t, })), currentValue, (value) => { callbacks.onThemeChange(value); done(value); }, () => { // Restore original theme on cancel callbacks.onThemePreview?.(currentValue); done(); }, (value) => { // Preview theme on selection change callbacks.onThemePreview?.(value); }, ), }, ]; // Only show image toggle if terminal supports it if (supportsImages) { // Insert after autocompact items.splice(1, 0, { id: "show-images", label: "Show images", description: "Render images inline in terminal", currentValue: config.showImages ? "true" : "false", values: ["true", "false"], }); } // Image auto-resize toggle (always available, affects both attached and read images) items.splice(supportsImages ? 2 : 1, 0, { id: "auto-resize-images", label: "Auto-resize images", description: "Resize large images to 2000x2000 max for better model compatibility", currentValue: config.autoResizeImages ? "true" : "false", values: ["true", "false"], }); // Block images toggle (always available, insert after auto-resize-images) const autoResizeIndex = items.findIndex((item) => item.id === "auto-resize-images"); items.splice(autoResizeIndex + 1, 0, { id: "block-images", label: "Block images", description: "Prevent images from being sent to LLM providers", currentValue: config.blockImages ? "true" : "false", values: ["true", "false"], }); // Skill commands toggle (insert after block-images) const blockImagesIndex = items.findIndex((item) => item.id === "block-images"); items.splice(blockImagesIndex + 1, 0, { id: "skill-commands", label: "Skill commands", description: "Register skills as /skill:name commands", currentValue: config.enableSkillCommands ? "true" : "false", values: ["true", "false"], }); // Hardware cursor toggle (insert after skill-commands) const skillCommandsIndex = items.findIndex((item) => item.id === "skill-commands"); items.splice(skillCommandsIndex + 1, 0, { id: "show-hardware-cursor", label: "Show hardware cursor", description: "Show the terminal cursor while still positioning it for IME support", currentValue: config.showHardwareCursor ? "true" : "false", values: ["true", "false"], }); // Editor padding toggle (insert after show-hardware-cursor) const hardwareCursorIndex = items.findIndex((item) => item.id === "show-hardware-cursor"); items.splice(hardwareCursorIndex + 1, 0, { id: "editor-padding", label: "Editor padding", description: "Horizontal padding for input editor (0-3)", currentValue: String(config.editorPaddingX), values: ["0", "1", "2", "3"], }); // Autocomplete max visible toggle (insert after editor-padding) const editorPaddingIndex = items.findIndex((item) => item.id === "editor-padding"); items.splice(editorPaddingIndex + 1, 0, { id: "autocomplete-max-visible", label: "Autocomplete max items", description: "Max visible items in autocomplete dropdown (3-20)", currentValue: String(config.autocompleteMaxVisible), values: ["3", "5", "7", "10", "15", "20"], }); // Add borders this.addChild(new DynamicBorder()); this.settingsList = new SettingsList( items, 10, getSettingsListTheme(), (id, newValue) => { switch (id) { case "autocompact": callbacks.onAutoCompactChange(newValue === "true"); break; case "show-images": callbacks.onShowImagesChange(newValue === "true"); break; case "auto-resize-images": callbacks.onAutoResizeImagesChange(newValue === "true"); break; case "block-images": callbacks.onBlockImagesChange(newValue === "true"); break; case "skill-commands": callbacks.onEnableSkillCommandsChange(newValue === "true"); break; case "steering-mode": callbacks.onSteeringModeChange(newValue as "all" | "one-at-a-time"); break; case "follow-up-mode": callbacks.onFollowUpModeChange(newValue as "all" | "one-at-a-time"); break; case "hide-thinking": callbacks.onHideThinkingBlockChange(newValue === "true"); break; case "collapse-changelog": callbacks.onCollapseChangelogChange(newValue === "true"); break; case "quiet-startup": callbacks.onQuietStartupChange(newValue === "true"); break; case "double-escape-action": callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree"); break; case "show-hardware-cursor": callbacks.onShowHardwareCursorChange(newValue === "true"); break; case "editor-padding": callbacks.onEditorPaddingXChange(parseInt(newValue, 10)); break; case "autocomplete-max-visible": callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10)); break; } }, callbacks.onCancel, { enableSearch: true }, ); this.addChild(this.settingsList); this.addChild(new DynamicBorder()); } getSettingsList(): SettingsList { return this.settingsList; } }