mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 02:01:29 +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
|
|
@ -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,
|
||||
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<void> {
|
||||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue