mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-22 01:02:16 +00:00
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
This commit is contained in:
parent
58c02ce02b
commit
b4f7a957c4
7 changed files with 527 additions and 152 deletions
|
|
@ -14,7 +14,7 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t
|
||||||
## Code Quality
|
## Code Quality
|
||||||
- No `any` types unless absolutely necessary
|
- No `any` types unless absolutely necessary
|
||||||
- Check node_modules for external API type definitions instead of guessing
|
- 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
|
- 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
|
- Always ask before removing functionality or code that appears to be intentional
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,9 +186,8 @@ The agent reads, writes, and edits files, and executes commands via bash.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
|
| `/settings` | Open settings menu (thinking, theme, queue mode, toggles) |
|
||||||
| `/model` | Switch models mid-session (fuzzy search, arrow keys, Enter to select) |
|
| `/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 |
|
| `/export [file]` | Export session to self-contained HTML |
|
||||||
| `/session` | Show session info: path, message counts, token usage, cost |
|
| `/session` | Show session info: path, message counts, token usage, cost |
|
||||||
| `/hotkeys` | Show all keyboard shortcuts |
|
| `/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 |
|
| `/new` | Start a new session |
|
||||||
| `/copy` | Copy last agent message to clipboard |
|
| `/copy` | Copy last agent message to clipboard |
|
||||||
| `/compact [instructions]` | Manually compact conversation context |
|
| `/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
|
### 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> lines]` but sent in full.
|
**Multi-line paste:** Pasted content is collapsed to `[paste #N <lines> 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
|
### 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.
|
**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`
|
**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.
|
- **Overflow recovery**: LLM returns context overflow error. Compacts and auto-retries.
|
||||||
- **Threshold maintenance**: Context exceeds `contextWindow - reserveTokens` after a successful turn. Compacts without retry.
|
- **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.
|
Built-in themes: `dark` (default), `light`. Auto-detected on first run.
|
||||||
|
|
||||||
```bash
|
Select theme via `/settings` or set in `~/.pi/agent/settings.json`.
|
||||||
/theme # Interactive selector
|
|
||||||
```
|
|
||||||
|
|
||||||
**Custom themes:** Create `~/.pi/agent/themes/*.json`. Custom themes support live reload.
|
**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
|
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.
|
> See [Theme Documentation](docs/theme.md) on how to create custom themes in detail. Pi can help you create a new one.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<ThinkingLevel, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
CombinedAutocompleteProvider,
|
CombinedAutocompleteProvider,
|
||||||
type Component,
|
type Component,
|
||||||
Container,
|
Container,
|
||||||
getCapabilities,
|
|
||||||
Input,
|
Input,
|
||||||
Loader,
|
Loader,
|
||||||
Markdown,
|
Markdown,
|
||||||
|
|
@ -52,15 +51,12 @@ import { HookInputComponent } from "./components/hook-input.js";
|
||||||
import { HookSelectorComponent } from "./components/hook-selector.js";
|
import { HookSelectorComponent } from "./components/hook-selector.js";
|
||||||
import { ModelSelectorComponent } from "./components/model-selector.js";
|
import { ModelSelectorComponent } from "./components/model-selector.js";
|
||||||
import { OAuthSelectorComponent } from "./components/oauth-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 { SessionSelectorComponent } from "./components/session-selector.js";
|
||||||
import { ShowImagesSelectorComponent } from "./components/show-images-selector.js";
|
import { SettingsSelectorComponent } from "./components/settings-selector.js";
|
||||||
import { ThemeSelectorComponent } from "./components/theme-selector.js";
|
|
||||||
import { ThinkingSelectorComponent } from "./components/thinking-selector.js";
|
|
||||||
import { ToolExecutionComponent } from "./components/tool-execution.js";
|
import { ToolExecutionComponent } from "./components/tool-execution.js";
|
||||||
import { UserMessageComponent } from "./components/user-message.js";
|
import { UserMessageComponent } from "./components/user-message.js";
|
||||||
import { UserMessageSelectorComponent } from "./components/user-message-selector.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 {
|
export class InteractiveMode {
|
||||||
private session: AgentSession;
|
private session: AgentSession;
|
||||||
|
|
@ -157,7 +153,7 @@ export class InteractiveMode {
|
||||||
|
|
||||||
// Define slash commands for autocomplete
|
// Define slash commands for autocomplete
|
||||||
const slashCommands: SlashCommand[] = [
|
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: "model", description: "Select model (opens selector UI)" },
|
||||||
{ name: "export", description: "Export session to HTML file" },
|
{ name: "export", description: "Export session to HTML file" },
|
||||||
{ name: "copy", description: "Copy last agent message to clipboard" },
|
{ 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: "branch", description: "Create a new branch from a previous message" },
|
||||||
{ name: "login", description: "Login with OAuth provider" },
|
{ name: "login", description: "Login with OAuth provider" },
|
||||||
{ name: "logout", description: "Logout from 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: "new", description: "Start a new session" },
|
||||||
{ name: "compact", description: "Manually compact the session context" },
|
{ name: "compact", description: "Manually compact the session context" },
|
||||||
{ name: "autocompact", description: "Toggle automatic context compaction" },
|
|
||||||
{ name: "resume", description: "Resume a different session" },
|
{ 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
|
// Load hide thinking block setting
|
||||||
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
||||||
|
|
||||||
|
|
@ -612,8 +600,8 @@ export class InteractiveMode {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
// Handle slash commands
|
// Handle slash commands
|
||||||
if (text === "/thinking") {
|
if (text === "/settings") {
|
||||||
this.showThinkingSelector();
|
this.showSettingsSelector();
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -662,16 +650,6 @@ export class InteractiveMode {
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (text === "/queue") {
|
|
||||||
this.showQueueModeSelector();
|
|
||||||
this.editor.setText("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (text === "/theme") {
|
|
||||||
this.showThemeSelector();
|
|
||||||
this.editor.setText("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (text === "/new") {
|
if (text === "/new") {
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
await this.handleClearCommand();
|
await this.handleClearCommand();
|
||||||
|
|
@ -688,16 +666,6 @@ export class InteractiveMode {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (text === "/autocompact") {
|
|
||||||
this.handleAutocompactCommand();
|
|
||||||
this.editor.setText("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (text === "/show-images") {
|
|
||||||
this.showShowImagesSelector();
|
|
||||||
this.editor.setText("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (text === "/debug") {
|
if (text === "/debug") {
|
||||||
this.handleDebugCommand();
|
this.handleDebugCommand();
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
|
|
@ -1405,74 +1373,77 @@ export class InteractiveMode {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
private showThinkingSelector(): void {
|
private showSettingsSelector(): void {
|
||||||
this.showSelector((done) => {
|
this.showSelector((done) => {
|
||||||
const selector = new ThinkingSelectorComponent(
|
const selector = new SettingsSelectorComponent(
|
||||||
this.session.thinkingLevel,
|
{
|
||||||
this.session.getAvailableThinkingLevels(),
|
autoCompact: this.session.autoCompactionEnabled,
|
||||||
(level) => {
|
showImages: this.settingsManager.getShowImages(),
|
||||||
this.session.setThinkingLevel(level);
|
queueMode: this.session.queueMode,
|
||||||
this.footer.updateState(this.session.state);
|
thinkingLevel: this.session.thinkingLevel,
|
||||||
this.updateEditorBorderColor();
|
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
|
||||||
done();
|
currentTheme: this.settingsManager.getTheme() || "dark",
|
||||||
this.showStatus(`Thinking level: ${level}`);
|
availableThemes: getAvailableThemes(),
|
||||||
|
hideThinkingBlock: this.hideThinkingBlock,
|
||||||
|
collapseChangelog: this.settingsManager.getCollapseChangelog(),
|
||||||
},
|
},
|
||||||
() => {
|
{
|
||||||
done();
|
onAutoCompactChange: (enabled) => {
|
||||||
this.ui.requestRender();
|
this.session.setAutoCompactionEnabled(enabled);
|
||||||
},
|
this.footer.setAutoCompactEnabled(enabled);
|
||||||
);
|
},
|
||||||
return { component: selector, focus: selector.getSelectList() };
|
onShowImagesChange: (enabled) => {
|
||||||
});
|
this.settingsManager.setShowImages(enabled);
|
||||||
}
|
for (const child of this.chatContainer.children) {
|
||||||
|
if (child instanceof ToolExecutionComponent) {
|
||||||
private showQueueModeSelector(): void {
|
child.setShowImages(enabled);
|
||||||
this.showSelector((done) => {
|
}
|
||||||
const selector = new QueueModeSelectorComponent(
|
}
|
||||||
this.session.queueMode,
|
},
|
||||||
(mode) => {
|
onQueueModeChange: (mode) => {
|
||||||
this.session.setQueueMode(mode);
|
this.session.setQueueMode(mode);
|
||||||
done();
|
},
|
||||||
this.showStatus(`Queue mode: ${mode}`);
|
onThinkingLevelChange: (level) => {
|
||||||
},
|
this.session.setThinkingLevel(level);
|
||||||
() => {
|
this.footer.updateState(this.session.state);
|
||||||
done();
|
this.updateEditorBorderColor();
|
||||||
this.ui.requestRender();
|
},
|
||||||
},
|
onThemeChange: (themeName) => {
|
||||||
);
|
const result = setTheme(themeName, true);
|
||||||
return { component: selector, focus: selector.getSelectList() };
|
this.settingsManager.setTheme(themeName);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
this.ui.invalidate();
|
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();
|
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);
|
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<void> {
|
private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
|
||||||
// Stop loading animation
|
// Stop loading animation
|
||||||
if (this.loadingAnimation) {
|
if (this.loadingAnimation) {
|
||||||
|
|
|
||||||
|
|
@ -807,3 +807,13 @@ export function getEditorTheme(): EditorTheme {
|
||||||
selectList: getSelectListTheme(),
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
188
packages/tui/src/components/settings-list.ts
Normal file
188
packages/tui/src/components/settings-list.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ export { Input } from "./components/input.js";
|
||||||
export { Loader } from "./components/loader.js";
|
export { Loader } from "./components/loader.js";
|
||||||
export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";
|
export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";
|
||||||
export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list.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 { Spacer } from "./components/spacer.js";
|
||||||
export { Text } from "./components/text.js";
|
export { Text } from "./components/text.js";
|
||||||
export { TruncatedText } from "./components/truncated-text.js";
|
export { TruncatedText } from "./components/truncated-text.js";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue