b877464575
- Switch all UI components and tests from sharp box corners (`boxSharp`) to rounded ones (`boxRound`). - Update `Theme` to re-export sharp junction symbols (tees and cross) under `boxRound` to ensure consistent divider rendering in rounded boxes. - Remove outdated architectural notes regarding forced tool-choice queues in documentation.
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
/**
|
|
* ExtensionDashboard - Tabbed layout for the Extension Control Center.
|
|
*
|
|
* Layout:
|
|
* - Top: Horizontal tab bar for provider selection
|
|
* - Body: 2-column grid (inventory list | preview panel)
|
|
*
|
|
* Navigation:
|
|
* - TAB/Shift+TAB: Cycle through provider tabs
|
|
* - Up/Down/j/k: Navigate list
|
|
* - Space: Toggle selected item (or master switch)
|
|
* - Esc: Close dashboard (clears search first if active)
|
|
*/
|
|
import {
|
|
type Component,
|
|
Container,
|
|
matchesKey,
|
|
padding,
|
|
Spacer,
|
|
Text,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
wrapTextWithAnsi,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { Settings } from "../../../config/settings";
|
|
import { DynamicBorder } from "../../../modes/components/dynamic-border";
|
|
import { theme } from "../../../modes/theme/theme";
|
|
import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
|
|
import { ExtensionList } from "./extension-list";
|
|
import { InspectorPanel } from "./inspector-panel";
|
|
import {
|
|
applyDisabledExtensionsToState,
|
|
applyFilter,
|
|
createInitialState,
|
|
filterByProvider,
|
|
refreshState,
|
|
toggleProvider,
|
|
} from "./state-manager";
|
|
import type { DashboardState } from "./types";
|
|
|
|
const EXT_FOOTER = " ↑/↓: navigate Space: toggle ←/→: provider Esc: close";
|
|
|
|
export class ExtensionDashboard extends Container {
|
|
#state!: DashboardState;
|
|
#mainList!: ExtensionList;
|
|
#inspector!: InspectorPanel;
|
|
#refreshToken = 0;
|
|
#builtRows = -1;
|
|
#builtCols = -1;
|
|
|
|
onClose?: () => void;
|
|
onRequestRender?: () => void;
|
|
|
|
private constructor(
|
|
private readonly cwd: string,
|
|
private readonly settings: Settings | null,
|
|
private readonly terminalHeight: number,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
static async create(
|
|
cwd: string,
|
|
settings: Settings | null = null,
|
|
terminalHeight?: number,
|
|
): Promise<ExtensionDashboard> {
|
|
const dashboard = new ExtensionDashboard(cwd, settings, terminalHeight ?? process.stdout.rows ?? 24);
|
|
await dashboard.#init();
|
|
return dashboard;
|
|
}
|
|
|
|
async #init(): Promise<void> {
|
|
const sm = this.settings ?? (await Settings.init());
|
|
const disabledIds = sm ? ((sm.get("disabledExtensions") as string[]) ?? []) : [];
|
|
this.#state = await createInitialState(this.cwd, disabledIds);
|
|
|
|
// Calculate max visible items based on terminal height
|
|
// Reserve ~10 lines for header, tabs, help text, borders
|
|
const maxVisible = this.#maxVisibleItems();
|
|
|
|
// Create main list - always focused
|
|
this.#mainList = new ExtensionList(
|
|
this.#state.searchFiltered,
|
|
{
|
|
onSelectionChange: ext => {
|
|
this.#state.selected = ext;
|
|
this.#inspector.setExtension(ext);
|
|
},
|
|
onToggle: (extensionId, enabled) => {
|
|
this.#handleExtensionToggle(extensionId, enabled);
|
|
},
|
|
onMasterToggle: providerId => {
|
|
this.#handleProviderToggle(providerId);
|
|
},
|
|
masterSwitchProvider: this.#getActiveProviderId(),
|
|
},
|
|
maxVisible,
|
|
);
|
|
this.#mainList.setFocused(true);
|
|
|
|
// Create inspector
|
|
this.#inspector = new InspectorPanel();
|
|
if (this.#state.selected) {
|
|
this.#inspector.setExtension(this.#state.selected);
|
|
}
|
|
|
|
this.#buildLayout();
|
|
}
|
|
|
|
#getActiveProviderId(): string | null {
|
|
const tab = this.#state.tabs[this.#state.activeTabIndex];
|
|
return tab && tab.id !== "all" ? tab.id : null;
|
|
}
|
|
|
|
/** Live terminal height so the dashboard tracks resize while open. */
|
|
#terminalRows(): number {
|
|
return process.stdout.rows || this.terminalHeight || 24;
|
|
}
|
|
|
|
#uiWidth(): number {
|
|
return Math.max(20, process.stdout.columns || 80);
|
|
}
|
|
|
|
#footerLines(): number {
|
|
return Math.max(1, wrapTextWithAnsi(theme.fg("dim", EXT_FOOTER), this.#uiWidth()).length);
|
|
}
|
|
|
|
/** Height budget for the two-column body, sized to the live terminal. */
|
|
#computeBodyHeight(): number {
|
|
// Chrome: top border + title + tab bar + spacer (4), then spacer + footer + bottom border.
|
|
const chrome = 4 + 1 + this.#footerLines() + 1;
|
|
return Math.max(5, this.#terminalRows() - chrome);
|
|
}
|
|
|
|
#maxVisibleItems(): number {
|
|
// List chrome inside the body: search line, blank line, scroll indicator.
|
|
return Math.max(3, this.#computeBodyHeight() - 3);
|
|
}
|
|
|
|
override render(width: number): readonly string[] {
|
|
// Rebuild when terminal geometry changes so the full-screen overlay
|
|
// re-fits on resize.
|
|
if (this.#terminalRows() !== this.#builtRows || this.#uiWidth() !== this.#builtCols) {
|
|
this.#buildLayout();
|
|
}
|
|
const lines = super.render(width);
|
|
// Pad to the full viewport so the dashboard covers the screen instead of
|
|
// letting the transcript peek through below it. Copy before padding — the
|
|
// container's render result is component-owned and must not be mutated.
|
|
const rows = this.#terminalRows();
|
|
if (lines.length >= rows) return lines;
|
|
const padded = lines.slice();
|
|
while (padded.length < rows) padded.push("");
|
|
return padded;
|
|
}
|
|
|
|
#buildLayout(): void {
|
|
this.clear();
|
|
|
|
// Top border
|
|
this.addChild(new DynamicBorder());
|
|
|
|
// Title
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", " Extension Control Center")), 0, 0));
|
|
|
|
// Tab bar
|
|
this.addChild(new Text(this.#renderTabBar(), 0, 0));
|
|
this.addChild(new Spacer(1));
|
|
|
|
// 2-column body sized to fill the live terminal viewport.
|
|
const bodyMaxHeight = this.#computeBodyHeight();
|
|
this.#mainList.setMaxVisible(this.#maxVisibleItems());
|
|
this.addChild(new TwoColumnBody(this.#mainList, this.#inspector, bodyMaxHeight));
|
|
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", EXT_FOOTER), 0, 0));
|
|
|
|
// Bottom border
|
|
this.addChild(new DynamicBorder());
|
|
this.#builtRows = this.#terminalRows();
|
|
this.#builtCols = this.#uiWidth();
|
|
}
|
|
|
|
#renderTabBar(): string {
|
|
const parts: string[] = [" "];
|
|
|
|
for (let i = 0; i < this.#state.tabs.length; i++) {
|
|
const tab = this.#state.tabs[i];
|
|
const isActive = i === this.#state.activeTabIndex;
|
|
const isEmpty = tab.count === 0 && tab.id !== "all";
|
|
const isDisabled = !tab.enabled && tab.id !== "all";
|
|
|
|
// Build label with count
|
|
let label = tab.label;
|
|
if (tab.count > 0) {
|
|
label += ` (${tab.count})`;
|
|
}
|
|
|
|
const displayLabel = isDisabled ? `${theme.status.disabled} ${label}` : label;
|
|
|
|
if (isActive) {
|
|
// Active tab: background highlight
|
|
parts.push(theme.bg("selectedBg", ` ${displayLabel} `));
|
|
} else if (isDisabled) {
|
|
// Disabled provider: dim
|
|
parts.push(theme.fg("dim", ` ${displayLabel} `));
|
|
} else if (isEmpty) {
|
|
// Empty enabled provider: very dim, unselectable
|
|
parts.push(theme.fg("dim", ` ${label} `));
|
|
} else {
|
|
// Normal enabled provider
|
|
parts.push(theme.fg("muted", ` ${label} `));
|
|
}
|
|
}
|
|
|
|
return parts.join("");
|
|
}
|
|
|
|
#handleProviderToggle(providerId: string): void {
|
|
toggleProvider(providerId);
|
|
void this.#refreshFromState();
|
|
}
|
|
|
|
#handleExtensionToggle(extensionId: string, enabled: boolean): void {
|
|
const sm = this.settings ?? Settings.instance;
|
|
if (!sm) return;
|
|
|
|
const disabled = ((sm.get("disabledExtensions") as string[]) ?? []).slice();
|
|
if (enabled) {
|
|
const index = disabled.indexOf(extensionId);
|
|
if (index !== -1) {
|
|
disabled.splice(index, 1);
|
|
sm.set("disabledExtensions", disabled);
|
|
}
|
|
} else {
|
|
if (!disabled.includes(extensionId)) {
|
|
disabled.push(extensionId);
|
|
sm.set("disabledExtensions", disabled);
|
|
}
|
|
}
|
|
|
|
this.#applyDisabledExtensions(disabled);
|
|
void this.#refreshFromState();
|
|
}
|
|
|
|
async #refreshFromState(): Promise<void> {
|
|
const refreshToken = ++this.#refreshToken;
|
|
// Remember current tab ID before refresh
|
|
const currentTabId = this.#state.tabs[this.#state.activeTabIndex]?.id;
|
|
|
|
const sm = this.settings ?? Settings.instance;
|
|
const disabledIds = sm ? ((sm.get("disabledExtensions") as string[]) ?? []) : [];
|
|
const nextState = await refreshState(this.#state, this.cwd, disabledIds);
|
|
if (refreshToken !== this.#refreshToken) return;
|
|
this.#state = nextState;
|
|
|
|
// Find the same tab in the new (re-sorted) list
|
|
if (currentTabId) {
|
|
const newIndex = this.#state.tabs.findIndex(t => t.id === currentTabId);
|
|
if (newIndex >= 0) {
|
|
this.#state.activeTabIndex = newIndex;
|
|
}
|
|
}
|
|
|
|
this.#mainList.setExtensions(this.#state.searchFiltered);
|
|
this.#mainList.setMasterSwitchProvider(this.#getActiveProviderId());
|
|
|
|
if (this.#state.selected) {
|
|
this.#inspector.setExtension(this.#state.selected);
|
|
}
|
|
|
|
this.#buildLayout();
|
|
this.onRequestRender?.();
|
|
}
|
|
|
|
#applyDisabledExtensions(disabledIds: string[]): void {
|
|
this.#state = applyDisabledExtensionsToState(this.#state, disabledIds);
|
|
this.#mainList.setExtensions(this.#state.searchFiltered);
|
|
if (this.#state.selected) {
|
|
this.#inspector.setExtension(this.#state.selected);
|
|
}
|
|
this.#buildLayout();
|
|
this.onRequestRender?.();
|
|
}
|
|
|
|
#switchTab(direction: 1 | -1): void {
|
|
const numTabs = this.#state.tabs.length;
|
|
if (numTabs === 0) return;
|
|
|
|
// Find next selectable tab (skip empty+enabled providers)
|
|
let nextIndex = this.#state.activeTabIndex;
|
|
for (let i = 0; i < numTabs; i++) {
|
|
nextIndex = (nextIndex + direction + numTabs) % numTabs;
|
|
const tab = this.#state.tabs[nextIndex];
|
|
const isEmptyEnabled = tab.count === 0 && tab.enabled && tab.id !== "all";
|
|
if (!isEmptyEnabled) break;
|
|
}
|
|
this.#state.activeTabIndex = nextIndex;
|
|
|
|
// Re-filter for new tab
|
|
const tab = this.#state.tabs[this.#state.activeTabIndex];
|
|
this.#state.tabFiltered = filterByProvider(this.#state.extensions, tab.id);
|
|
this.#state.searchFiltered = applyFilter(this.#state.tabFiltered, this.#state.searchQuery);
|
|
this.#state.listIndex = 0;
|
|
this.#state.scrollOffset = 0;
|
|
this.#state.selected = this.#state.searchFiltered[0] ?? null;
|
|
|
|
// Update list
|
|
this.#mainList.setExtensions(this.#state.searchFiltered);
|
|
this.#mainList.setMasterSwitchProvider(this.#getActiveProviderId());
|
|
this.#mainList.resetSelection();
|
|
|
|
if (this.#state.selected) {
|
|
this.#inspector.setExtension(this.#state.selected);
|
|
}
|
|
|
|
this.#buildLayout();
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
// Ctrl+C - close immediately
|
|
if (matchesKey(data, "ctrl+c")) {
|
|
this.onClose?.();
|
|
return;
|
|
}
|
|
|
|
// Escape - clear search first, then close
|
|
if (matchesAppInterrupt(data)) {
|
|
if (this.#state.searchQuery.length > 0) {
|
|
this.#state.searchQuery = "";
|
|
this.#state.searchFiltered = this.#state.tabFiltered;
|
|
this.#mainList.setExtensions(this.#state.searchFiltered);
|
|
this.#mainList.clearSearch();
|
|
this.#buildLayout();
|
|
return;
|
|
}
|
|
this.onClose?.();
|
|
return;
|
|
}
|
|
|
|
// Tab/Shift+Tab or Left/Right: Cycle through tabs
|
|
if (matchesKey(data, "tab") || matchesKey(data, "right")) {
|
|
this.#switchTab(1);
|
|
return;
|
|
}
|
|
if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
|
|
this.#switchTab(-1);
|
|
return;
|
|
}
|
|
|
|
// All other input goes to the list
|
|
this.#mainList.handleInput(data);
|
|
|
|
// Sync search query back to state
|
|
const query = this.#mainList.getSearchQuery();
|
|
if (query !== this.#state.searchQuery) {
|
|
this.#state.searchQuery = query;
|
|
this.#state.searchFiltered = applyFilter(this.#state.tabFiltered, query);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Two-column body component for side-by-side rendering.
|
|
*/
|
|
class TwoColumnBody implements Component {
|
|
constructor(
|
|
private readonly leftPane: ExtensionList,
|
|
private readonly rightPane: InspectorPanel,
|
|
private readonly maxHeight: number,
|
|
) {}
|
|
|
|
render(width: number): readonly string[] {
|
|
const leftWidth = Math.floor(width * 0.5);
|
|
const rightWidth = Math.max(0, width - leftWidth - 3);
|
|
|
|
const leftLines = this.leftPane.render(leftWidth);
|
|
const rightLines = this.rightPane.render(rightWidth);
|
|
|
|
// Fill the full body height so the dashboard reads as a full-screen view.
|
|
const numLines = this.maxHeight;
|
|
const combined: string[] = [];
|
|
const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `);
|
|
|
|
for (let i = 0; i < numLines; i++) {
|
|
const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
|
|
const leftPadded = left + padding(Math.max(0, leftWidth - visibleWidth(left)));
|
|
const right = truncateToWidth(rightLines[i] ?? "", rightWidth);
|
|
combined.push(leftPadded + separator + right);
|
|
}
|
|
|
|
return combined;
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.leftPane.invalidate?.();
|
|
this.rightPane.invalidate?.();
|
|
}
|
|
}
|