From b4f7a957c4bfa0323d81fae4406fbc2055360c63 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 25 Dec 2025 15:39:42 +0100 Subject: [PATCH] Add /settings command with unified settings menu (#312) * Add /settings command with unified settings menu - Add SettingsList component to tui package with support for: - Inline value cycling (Enter/Space toggles) - Submenus for complex selections - Selection preservation when returning from submenu - Add /settings slash command consolidating: - Auto-compact (toggle) - Show images (toggle) - Queue mode (cycle) - Hide thinking (toggle) - Collapse changelog (toggle) - Thinking level (submenu) - Theme (submenu with preview) - Update AGENTS.md to clarify no inline imports rule Fixes #310 * Add /settings to README slash commands table * Remove old settings slash commands, consolidate into /settings - Remove /thinking, /queue, /theme, /autocompact, /show-images commands - Remove unused selector methods and imports - Update README references to use /settings --- AGENTS.md | 2 +- packages/coding-agent/README.md | 18 +- .../components/settings-selector.ts | 251 ++++++++++++++++++ .../src/modes/interactive/interactive-mode.ts | 209 +++++---------- .../src/modes/interactive/theme/theme.ts | 10 + packages/tui/src/components/settings-list.ts | 188 +++++++++++++ packages/tui/src/index.ts | 1 + 7 files changed, 527 insertions(+), 152 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/settings-selector.ts create mode 100644 packages/tui/src/components/settings-list.ts diff --git a/AGENTS.md b/AGENTS.md index 780d382f..3990816f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t ## Code Quality - No `any` types unless absolutely necessary - Check node_modules for external API type definitions instead of guessing -- No inline imports like `await import("./foo.js")` +- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead - Always ask before removing functionality or code that appears to be intentional diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 2b404b14..cec54e94 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -186,9 +186,8 @@ The agent reads, writes, and edits files, and executes commands via bash. | Command | Description | |---------|-------------| +| `/settings` | Open settings menu (thinking, theme, queue mode, toggles) | | `/model` | Switch models mid-session (fuzzy search, arrow keys, Enter to select) | -| `/thinking` | Adjust thinking level for reasoning models (off/minimal/low/medium/high) | -| `/queue` | Set message queue mode: one-at-a-time (default) or all-at-once | | `/export [file]` | Export session to self-contained HTML | | `/session` | Show session info: path, message counts, token usage, cost | | `/hotkeys` | Show all keyboard shortcuts | @@ -200,9 +199,6 @@ The agent reads, writes, and edits files, and executes commands via bash. | `/new` | Start a new session | | `/copy` | Copy last agent message to clipboard | | `/compact [instructions]` | Manually compact conversation context | -| `/autocompact` | Toggle automatic context compaction | -| `/theme` | Select color theme | -| `/show-images` | Toggle inline image display (supported terminals only) | ### Editor Features @@ -214,7 +210,7 @@ The agent reads, writes, and edits files, and executes commands via bash. **Multi-line paste:** Pasted content is collapsed to `[paste #N lines]` but sent in full. -**Message queuing:** Submit messages while the agent is working. They queue and process based on `/queue` mode. Press Escape to abort and restore queued messages to editor. +**Message queuing:** Submit messages while the agent is working. They queue and process based on queue mode (configurable via `/settings`). Press Escape to abort and restore queued messages to editor. ### Keyboard Shortcuts @@ -287,7 +283,7 @@ Supported formats: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp` **Inline rendering:** On terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images, images in tool output are rendered inline. On unsupported terminals, a text placeholder is shown instead. -Toggle inline images with `/show-images` or set `terminal.showImages: false` in settings. +Toggle inline images via `/settings` or set `terminal.showImages: false` in settings. --- @@ -315,7 +311,7 @@ Long sessions can exhaust context windows. Compaction summarizes older messages **Manual:** `/compact` or `/compact Focus on the API changes` -**Automatic:** Enable with `/autocompact`. When enabled, triggers in two cases: +**Automatic:** Enable via `/settings`. When enabled, triggers in two cases: - **Overflow recovery**: LLM returns context overflow error. Compacts and auto-retries. - **Threshold maintenance**: Context exceeds `contextWindow - reserveTokens` after a successful turn. Compacts without retry. @@ -459,9 +455,7 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`: Built-in themes: `dark` (default), `light`. Auto-detected on first run. -```bash -/theme # Interactive selector -``` +Select theme via `/settings` or set in `~/.pi/agent/settings.json`. **Custom themes:** Create `~/.pi/agent/themes/*.json`. Custom themes support live reload. @@ -470,7 +464,7 @@ mkdir -p ~/.pi/agent/themes cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json ``` -Select with `/theme`, then edit the file. Changes apply on save. +Select with `/settings`, then edit the file. Changes apply on save. > See [Theme Documentation](docs/theme.md) on how to create custom themes in detail. Pi can help you create a new one. diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts new file mode 100644 index 00000000..1202e3ee --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -0,0 +1,251 @@ +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; + queueMode: "all" | "one-at-a-time"; + thinkingLevel: ThinkingLevel; + availableThinkingLevels: ThinkingLevel[]; + currentTheme: string; + availableThemes: string[]; + hideThinkingBlock: boolean; + collapseChangelog: boolean; +} + +export interface SettingsCallbacks { + onAutoCompactChange: (enabled: boolean) => void; + onShowImagesChange: (enabled: boolean) => void; + onQueueModeChange: (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; + 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: "queue-mode", + label: "Queue mode", + description: "How to process queued messages while agent is working", + currentValue: config.queueMode, + 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: "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"], + }); + } + + // 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 "queue-mode": + callbacks.onQueueModeChange(newValue as "all" | "one-at-a-time"); + break; + case "hide-thinking": + callbacks.onHideThinkingBlockChange(newValue === "true"); + break; + case "collapse-changelog": + callbacks.onCollapseChangelogChange(newValue === "true"); + break; + } + }, + callbacks.onCancel, + ); + + this.addChild(this.settingsList); + this.addChild(new DynamicBorder()); + } + + getSettingsList(): SettingsList { + return this.settingsList; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 228148d5..17155587 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -13,7 +13,6 @@ import { CombinedAutocompleteProvider, type Component, Container, - getCapabilities, Input, Loader, Markdown, @@ -52,15 +51,12 @@ import { HookInputComponent } from "./components/hook-input.js"; import { HookSelectorComponent } from "./components/hook-selector.js"; import { ModelSelectorComponent } from "./components/model-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js"; -import { QueueModeSelectorComponent } from "./components/queue-mode-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; -import { ShowImagesSelectorComponent } from "./components/show-images-selector.js"; -import { ThemeSelectorComponent } from "./components/theme-selector.js"; -import { ThinkingSelectorComponent } from "./components/thinking-selector.js"; +import { SettingsSelectorComponent } from "./components/settings-selector.js"; import { ToolExecutionComponent } from "./components/tool-execution.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; -import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; +import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; export class InteractiveMode { private session: AgentSession; @@ -157,7 +153,7 @@ export class InteractiveMode { // Define slash commands for autocomplete const slashCommands: SlashCommand[] = [ - { name: "thinking", description: "Select reasoning level (opens selector UI)" }, + { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)" }, { name: "export", description: "Export session to HTML file" }, { name: "copy", description: "Copy last agent message to clipboard" }, @@ -167,19 +163,11 @@ export class InteractiveMode { { name: "branch", description: "Create a new branch from a previous message" }, { name: "login", description: "Login with OAuth provider" }, { name: "logout", description: "Logout from OAuth provider" }, - { name: "queue", description: "Select message queue mode (opens selector UI)" }, - { name: "theme", description: "Select color theme (opens selector UI)" }, { name: "new", description: "Start a new session" }, { name: "compact", description: "Manually compact the session context" }, - { name: "autocompact", description: "Toggle automatic context compaction" }, { name: "resume", description: "Resume a different session" }, ]; - // Add image toggle command only if terminal supports images - if (getCapabilities().images) { - slashCommands.push({ name: "show-images", description: "Toggle inline image display" }); - } - // Load hide thinking block setting this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); @@ -612,8 +600,8 @@ export class InteractiveMode { if (!text) return; // Handle slash commands - if (text === "/thinking") { - this.showThinkingSelector(); + if (text === "/settings") { + this.showSettingsSelector(); this.editor.setText(""); return; } @@ -662,16 +650,6 @@ export class InteractiveMode { this.editor.setText(""); return; } - if (text === "/queue") { - this.showQueueModeSelector(); - this.editor.setText(""); - return; - } - if (text === "/theme") { - this.showThemeSelector(); - this.editor.setText(""); - return; - } if (text === "/new") { this.editor.setText(""); await this.handleClearCommand(); @@ -688,16 +666,6 @@ export class InteractiveMode { } return; } - if (text === "/autocompact") { - this.handleAutocompactCommand(); - this.editor.setText(""); - return; - } - if (text === "/show-images") { - this.showShowImagesSelector(); - this.editor.setText(""); - return; - } if (text === "/debug") { this.handleDebugCommand(); this.editor.setText(""); @@ -1405,74 +1373,77 @@ export class InteractiveMode { this.ui.requestRender(); } - private showThinkingSelector(): void { + private showSettingsSelector(): void { this.showSelector((done) => { - const selector = new ThinkingSelectorComponent( - this.session.thinkingLevel, - this.session.getAvailableThinkingLevels(), - (level) => { - this.session.setThinkingLevel(level); - this.footer.updateState(this.session.state); - this.updateEditorBorderColor(); - done(); - this.showStatus(`Thinking level: ${level}`); + const selector = new SettingsSelectorComponent( + { + autoCompact: this.session.autoCompactionEnabled, + showImages: this.settingsManager.getShowImages(), + queueMode: this.session.queueMode, + thinkingLevel: this.session.thinkingLevel, + availableThinkingLevels: this.session.getAvailableThinkingLevels(), + currentTheme: this.settingsManager.getTheme() || "dark", + availableThemes: getAvailableThemes(), + hideThinkingBlock: this.hideThinkingBlock, + collapseChangelog: this.settingsManager.getCollapseChangelog(), }, - () => { - done(); - this.ui.requestRender(); - }, - ); - return { component: selector, focus: selector.getSelectList() }; - }); - } - - private showQueueModeSelector(): void { - this.showSelector((done) => { - const selector = new QueueModeSelectorComponent( - this.session.queueMode, - (mode) => { - this.session.setQueueMode(mode); - done(); - this.showStatus(`Queue mode: ${mode}`); - }, - () => { - done(); - this.ui.requestRender(); - }, - ); - return { component: selector, focus: selector.getSelectList() }; - }); - } - - private showThemeSelector(): void { - const currentTheme = this.settingsManager.getTheme() || "dark"; - this.showSelector((done) => { - const selector = new ThemeSelectorComponent( - currentTheme, - (themeName) => { - const result = setTheme(themeName, true); - this.settingsManager.setTheme(themeName); - this.ui.invalidate(); - done(); - if (result.success) { - this.showStatus(`Theme: ${themeName}`); - } else { - this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`); - } - }, - () => { - done(); - this.ui.requestRender(); - }, - (themeName) => { - const result = setTheme(themeName, true); - if (result.success) { + { + 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); + } + } + }, + onQueueModeChange: (mode) => { + this.session.setQueueMode(mode); + }, + onThinkingLevelChange: (level) => { + this.session.setThinkingLevel(level); + this.footer.updateState(this.session.state); + this.updateEditorBorderColor(); + }, + onThemeChange: (themeName) => { + const result = setTheme(themeName, true); + this.settingsManager.setTheme(themeName); this.ui.invalidate(); + if (!result.success) { + this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`); + } + }, + onThemePreview: (themeName) => { + const result = setTheme(themeName, true); + if (result.success) { + this.ui.invalidate(); + this.ui.requestRender(); + } + }, + onHideThinkingBlockChange: (hidden) => { + this.hideThinkingBlock = hidden; + this.settingsManager.setHideThinkingBlock(hidden); + for (const child of this.chatContainer.children) { + if (child instanceof AssistantMessageComponent) { + child.setHideThinkingBlock(hidden); + } + } + this.chatContainer.clear(); + this.rebuildChatFromMessages(); + }, + onCollapseChangelogChange: (collapsed) => { + this.settingsManager.setCollapseChangelog(collapsed); + }, + onCancel: () => { + done(); this.ui.requestRender(); - } + }, }, ); - return { component: selector, focus: selector.getSelectList() }; + return { component: selector, focus: selector.getSettingsList() }; }); } @@ -1938,46 +1909,6 @@ export class InteractiveMode { await this.executeCompaction(customInstructions, false); } - private handleAutocompactCommand(): void { - const newState = !this.session.autoCompactionEnabled; - this.session.setAutoCompactionEnabled(newState); - this.footer.setAutoCompactEnabled(newState); - this.showStatus(`Auto-compaction: ${newState ? "on" : "off"}`); - } - - private showShowImagesSelector(): void { - // Only available if terminal supports images - const caps = getCapabilities(); - if (!caps.images) { - this.showWarning("Your terminal does not support inline images"); - return; - } - - this.showSelector((done) => { - const selector = new ShowImagesSelectorComponent( - this.settingsManager.getShowImages(), - (newValue) => { - this.settingsManager.setShowImages(newValue); - - // Update all existing tool execution components with new setting - for (const child of this.chatContainer.children) { - if (child instanceof ToolExecutionComponent) { - child.setShowImages(newValue); - } - } - - done(); - this.showStatus(`Inline images: ${newValue ? "on" : "off"}`); - }, - () => { - done(); - this.ui.requestRender(); - }, - ); - return { component: selector, focus: selector.getSelectList() }; - }); - } - private async executeCompaction(customInstructions?: string, isAuto = false): Promise { // Stop loading animation if (this.loadingAnimation) { diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index ad8d3fb1..8f56b8e6 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -807,3 +807,13 @@ export function getEditorTheme(): EditorTheme { selectList: getSelectListTheme(), }; } + +export function getSettingsListTheme(): import("@mariozechner/pi-tui").SettingsListTheme { + return { + label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text), + value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)), + description: (text: string) => theme.fg("dim", text), + cursor: theme.fg("accent", "→ "), + hint: (text: string) => theme.fg("dim", text), + }; +} diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts new file mode 100644 index 00000000..a96bd0eb --- /dev/null +++ b/packages/tui/src/components/settings-list.ts @@ -0,0 +1,188 @@ +import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js"; +import type { Component } from "../tui.js"; +import { truncateToWidth, visibleWidth } from "../utils.js"; + +export interface SettingItem { + /** Unique identifier for this setting */ + id: string; + /** Display label (left side) */ + label: string; + /** Optional description shown when selected */ + description?: string; + /** Current value to display (right side) */ + currentValue: string; + /** If provided, Enter/Space cycles through these values */ + values?: string[]; + /** If provided, Enter opens this submenu. Receives current value and done callback. */ + submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; +} + +export interface SettingsListTheme { + label: (text: string, selected: boolean) => string; + value: (text: string, selected: boolean) => string; + description: (text: string) => string; + cursor: string; + hint: (text: string) => string; +} + +export class SettingsList implements Component { + private items: SettingItem[]; + private theme: SettingsListTheme; + private selectedIndex = 0; + private maxVisible: number; + private onChange: (id: string, newValue: string) => void; + private onCancel: () => void; + + // Submenu state + private submenuComponent: Component | null = null; + private submenuItemIndex: number | null = null; + + constructor( + items: SettingItem[], + maxVisible: number, + theme: SettingsListTheme, + onChange: (id: string, newValue: string) => void, + onCancel: () => void, + ) { + this.items = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.onChange = onChange; + this.onCancel = onCancel; + } + + /** Update an item's currentValue */ + updateValue(id: string, newValue: string): void { + const item = this.items.find((i) => i.id === id); + if (item) { + item.currentValue = newValue; + } + } + + invalidate(): void { + this.submenuComponent?.invalidate?.(); + } + + render(width: number): string[] { + // If submenu is active, render it instead + if (this.submenuComponent) { + return this.submenuComponent.render(width); + } + + return this.renderMainList(width); + } + + private renderMainList(width: number): string[] { + const lines: string[] = []; + + if (this.items.length === 0) { + lines.push(this.theme.hint(" No settings available")); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.items.length); + + // Calculate max label width for alignment + const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = this.items[i]; + if (!item) continue; + + const isSelected = i === this.selectedIndex; + const prefix = isSelected ? this.theme.cursor : " "; + const prefixWidth = visibleWidth(prefix); + + // Pad label to align values + const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); + const labelText = this.theme.label(labelPadded, isSelected); + + // Calculate space for value + const separator = " "; + const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); + const valueMaxWidth = width - usedWidth - 2; + + const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected); + + lines.push(prefix + labelText + separator + valueText); + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < this.items.length) { + const scrollText = ` (${this.selectedIndex + 1}/${this.items.length})`; + lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); + } + + // Add description for selected item + const selectedItem = this.items[this.selectedIndex]; + if (selectedItem?.description) { + lines.push(""); + lines.push(this.theme.description(` ${truncateToWidth(selectedItem.description, width - 4, "")}`)); + } + + // Add hint + lines.push(""); + lines.push(this.theme.hint(" Enter/Space to change · Esc to cancel")); + + return lines; + } + + handleInput(data: string): void { + // If submenu is active, delegate all input to it + // The submenu's onCancel (triggered by escape) will call done() which closes it + if (this.submenuComponent) { + this.submenuComponent.handleInput?.(data); + return; + } + + // Main list input handling + if (isArrowUp(data)) { + this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1; + } else if (isArrowDown(data)) { + this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1; + } else if (isEnter(data) || data === " ") { + this.activateItem(); + } else if (isEscape(data) || isCtrlC(data)) { + this.onCancel(); + } + } + + private activateItem(): void { + const item = this.items[this.selectedIndex]; + if (!item) return; + + if (item.submenu) { + // Open submenu, passing current value so it can pre-select correctly + this.submenuItemIndex = this.selectedIndex; + this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + this.closeSubmenu(); + }); + } else if (item.values && item.values.length > 0) { + // Cycle through values + const currentIndex = item.values.indexOf(item.currentValue); + const nextIndex = (currentIndex + 1) % item.values.length; + const newValue = item.values[nextIndex]; + item.currentValue = newValue; + this.onChange(item.id, newValue); + } + } + + private closeSubmenu(): void { + this.submenuComponent = null; + // Restore selection to the item that opened the submenu + if (this.submenuItemIndex !== null) { + this.selectedIndex = this.submenuItemIndex; + this.submenuItemIndex = null; + } + } +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 1e8bb605..62e52b13 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -15,6 +15,7 @@ export { Input } from "./components/input.js"; export { Loader } from "./components/loader.js"; export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js"; export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list.js"; +export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.js"; export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js"; export { TruncatedText } from "./components/truncated-text.js";