mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 19:05:11 +00:00
191 lines
5.9 KiB
TypeScript
191 lines
5.9 KiB
TypeScript
import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js";
|
|
import type { Component } from "../tui.js";
|
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } 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("");
|
|
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
|
|
for (const line of wrappedDesc) {
|
|
lines.push(this.theme.description(` ${line}`));
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|