feat(coding-agent): replaced 3-column layout with tabbed interface
- Redesigned Extension Control Center from 3-column layout to tabbed interface with provider tabs. - Replaced sidebar tree navigation with horizontal tab bar supporting TAB/Shift+TAB cycling. - Added session header/footer output in text mode showing version, model, provider, and thinking level. - Removed HolyGrailLayout and SidebarTree components in favor of TwoColumnBody with tabs.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 62 KiB |
@@ -4,12 +4,18 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added session header and footer output in text mode showing version, model, provider, thinking level, and session ID
|
||||
- Added Extension Control Center dashboard accessible via `/extensions` command for unified management of all providers and extensions
|
||||
- Added ability to enable/disable individual extensions with persistent settings
|
||||
- Added three-column dashboard layout with sidebar tree, extension list, and inspector panel
|
||||
- Added fuzzy search filtering for extensions in the dashboard
|
||||
- Added keyboard navigation with Tab to cycle panes, j/k for navigation, Space to toggle, Enter to expand/collapse
|
||||
|
||||
### Changed
|
||||
|
||||
- Redesigned Extension Control Center from 3-column layout to tabbed interface with horizontal provider tabs and 2-column grid
|
||||
- Replaced sidebar tree navigation with provider tabs using TAB/Shift+TAB cycling
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed title generation flag not resetting when starting a new session
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { contextFileCapability } from "../capability/context-file";
|
||||
import type { Rule } from "../capability/rule";
|
||||
import { systemPromptCapability } from "../capability/system-prompt";
|
||||
import { getDocsPath, getExamplesPath, getReadmePath } from "../config";
|
||||
import { type ContextFile, loadSync, type SystemPrompt as SystemPromptFile } from "../discovery/index";
|
||||
import type { Rule } from "../capability/rule";
|
||||
import type { SkillsSettings } from "./settings-manager";
|
||||
import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills";
|
||||
import { formatRulesForPrompt } from "./tools/rulebook";
|
||||
import type { ToolName } from "./tools/index";
|
||||
import { formatRulesForPrompt } from "./tools/rulebook";
|
||||
|
||||
/**
|
||||
* Execute a git command synchronously and return stdout or null on failure.
|
||||
|
||||
@@ -24,6 +24,12 @@ export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./no
|
||||
export { createOutputTool, type OutputToolDetails, outputTool } from "./output";
|
||||
export { createReadTool, type ReadToolDetails, readTool } from "./read";
|
||||
export { createReportFindingTool, createSubmitReviewTool, reportFindingTool, submitReviewTool } from "./review";
|
||||
export {
|
||||
createRulebookTool,
|
||||
filterRulebookRules,
|
||||
formatRulesForPrompt,
|
||||
type RulebookToolDetails,
|
||||
} from "./rulebook";
|
||||
export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index";
|
||||
export type { TruncationResult } from "./truncate";
|
||||
export { createWebFetchTool, type WebFetchToolDetails, webFetchCustomTool, webFetchTool } from "./web-fetch";
|
||||
@@ -46,12 +52,6 @@ export {
|
||||
webSearchTool,
|
||||
} from "./web-search/index";
|
||||
export { createWriteTool, type WriteToolDetails, type WriteToolOptions, writeTool } from "./write";
|
||||
export {
|
||||
createRulebookTool,
|
||||
filterRulebookRules,
|
||||
formatRulesForPrompt,
|
||||
type RulebookToolDetails,
|
||||
} from "./rulebook";
|
||||
|
||||
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import { askTool, createAskTool } from "./ask";
|
||||
|
||||
+263
-162
@@ -1,196 +1,297 @@
|
||||
/**
|
||||
* ExtensionDashboard - Main container for the Extension Control Center.
|
||||
* ExtensionDashboard - Tabbed layout for the Extension Control Center.
|
||||
*
|
||||
* Orchestrates the 3-column layout, handles keyboard navigation between panes,
|
||||
* and manages state updates.
|
||||
* 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 {
|
||||
Container,
|
||||
isCtrlC,
|
||||
isEnter,
|
||||
isEscape,
|
||||
isShiftTab,
|
||||
isTab,
|
||||
Spacer,
|
||||
Text,
|
||||
type Component,
|
||||
Container,
|
||||
isCtrlC,
|
||||
isEscape,
|
||||
isShiftTab,
|
||||
isTab,
|
||||
Spacer,
|
||||
Text,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import type { SettingsManager } from "../../../../core/settings-manager";
|
||||
import { theme } from "../../theme/theme";
|
||||
import { DynamicBorder } from "../dynamic-border";
|
||||
import { ExtensionList } from "./extension-list";
|
||||
import { HolyGrailLayout } from "./holy-grail-layout";
|
||||
import { InspectorPanel } from "./inspector-panel";
|
||||
import { SidebarTree } from "./sidebar-tree";
|
||||
import { createInitialState, refreshState } from "./state-manager";
|
||||
import type { DashboardCallbacks, DashboardState, FocusPane } from "./types";
|
||||
import { applyFilter, createInitialState, filterByProvider, refreshState, toggleProvider } from "./state-manager";
|
||||
import type { DashboardState } from "./types";
|
||||
|
||||
export class ExtensionDashboard extends Container {
|
||||
private state: DashboardState;
|
||||
private layout: HolyGrailLayout;
|
||||
private sidebar: SidebarTree;
|
||||
private mainList: ExtensionList;
|
||||
private inspector: InspectorPanel;
|
||||
private settingsManager: SettingsManager | null;
|
||||
private cwd: string;
|
||||
private state: DashboardState;
|
||||
private mainList: ExtensionList;
|
||||
private inspector: InspectorPanel;
|
||||
private settingsManager: SettingsManager | null;
|
||||
private cwd: string;
|
||||
|
||||
public onClose?: () => void;
|
||||
public onClose?: () => void;
|
||||
|
||||
constructor(cwd: string, settingsManager: SettingsManager | null = null) {
|
||||
super();
|
||||
this.cwd = cwd;
|
||||
this.settingsManager = settingsManager;
|
||||
const disabledIds = settingsManager?.getDisabledExtensions() ?? [];
|
||||
this.state = createInitialState(cwd, disabledIds);
|
||||
constructor(cwd: string, settingsManager: SettingsManager | null = null) {
|
||||
super();
|
||||
this.cwd = cwd;
|
||||
this.settingsManager = settingsManager;
|
||||
const disabledIds = settingsManager?.getDisabledExtensions() ?? [];
|
||||
this.state = createInitialState(cwd, disabledIds);
|
||||
|
||||
// Create sidebar
|
||||
this.sidebar = new SidebarTree(this.state.sidebarTree, {
|
||||
onProviderToggle: (providerId, enabled) => {
|
||||
this.refreshFromState();
|
||||
},
|
||||
onSelectionChange: (nodeId) => {
|
||||
// Could filter main list by provider
|
||||
},
|
||||
onTreeChange: () => {
|
||||
// Refresh flat tree in state
|
||||
},
|
||||
});
|
||||
// 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(),
|
||||
});
|
||||
this.mainList.setFocused(true);
|
||||
|
||||
// Create main list
|
||||
this.mainList = new ExtensionList(this.state.extensions, {
|
||||
onSelectionChange: (ext) => {
|
||||
this.state.selected = ext;
|
||||
this.inspector.setExtension(ext);
|
||||
},
|
||||
onToggle: (extensionId, enabled) => {
|
||||
this.handleExtensionToggle(extensionId, enabled);
|
||||
},
|
||||
});
|
||||
// Create inspector
|
||||
this.inspector = new InspectorPanel();
|
||||
if (this.state.selected) {
|
||||
this.inspector.setExtension(this.state.selected);
|
||||
}
|
||||
|
||||
// Create inspector
|
||||
this.inspector = new InspectorPanel();
|
||||
if (this.state.selected) {
|
||||
this.inspector.setExtension(this.state.selected);
|
||||
}
|
||||
this.buildLayout();
|
||||
}
|
||||
|
||||
// Create layout
|
||||
this.layout = new HolyGrailLayout(this.sidebar, this.mainList, this.inspector);
|
||||
private getActiveProviderId(): string | null {
|
||||
const tab = this.state.tabs[this.state.activeTabIndex];
|
||||
return tab && tab.id !== "all" ? tab.id : null;
|
||||
}
|
||||
|
||||
// Set initial focus
|
||||
this.updateFocus();
|
||||
private buildLayout(): void {
|
||||
this.clear();
|
||||
|
||||
// Build component tree
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Text(theme.bold(theme.fg("accent", " Extension Control Center")), 0, 0));
|
||||
this.addChild(
|
||||
new Text(
|
||||
theme.fg("dim", " Tab: pane j/k: nav Space: toggle Enter: expand type: search Esc: close"),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.layout);
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
// Top border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
private updateFocus(): void {
|
||||
this.sidebar.setFocused(this.state.focusPane === "sidebar");
|
||||
this.mainList.setFocused(this.state.focusPane === "main");
|
||||
this.layout.setFocusedPane(this.state.focusPane);
|
||||
}
|
||||
// Title
|
||||
this.addChild(new Text(theme.bold(theme.fg("accent", " Extension Control Center")), 0, 0));
|
||||
|
||||
private cycleFocusRight(): void {
|
||||
switch (this.state.focusPane) {
|
||||
case "sidebar":
|
||||
this.state.focusPane = "main";
|
||||
break;
|
||||
case "main":
|
||||
this.state.focusPane = "inspector";
|
||||
break;
|
||||
case "inspector":
|
||||
this.state.focusPane = "sidebar";
|
||||
break;
|
||||
}
|
||||
this.updateFocus();
|
||||
}
|
||||
// Tab bar
|
||||
this.addChild(new Text(this.renderTabBar(), 0, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
private cycleFocusLeft(): void {
|
||||
switch (this.state.focusPane) {
|
||||
case "sidebar":
|
||||
this.state.focusPane = "inspector";
|
||||
break;
|
||||
case "main":
|
||||
this.state.focusPane = "sidebar";
|
||||
break;
|
||||
case "inspector":
|
||||
this.state.focusPane = "main";
|
||||
break;
|
||||
}
|
||||
this.updateFocus();
|
||||
}
|
||||
// Help text
|
||||
// 2-column body
|
||||
this.addChild(new TwoColumnBody(this.mainList, this.inspector));
|
||||
|
||||
private handleExtensionToggle(extensionId: string, enabled: boolean): void {
|
||||
if (!this.settingsManager) return;
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.fg("dim", " ↑/↓: navigate Space: toggle Tab: next provider Esc: close"), 0, 0));
|
||||
|
||||
if (enabled) {
|
||||
this.settingsManager.enableExtension(extensionId);
|
||||
} else {
|
||||
this.settingsManager.disableExtension(extensionId);
|
||||
}
|
||||
// Bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
this.refreshFromState();
|
||||
}
|
||||
private renderTabBar(): string {
|
||||
const parts: string[] = [" "];
|
||||
|
||||
private refreshFromState(): void {
|
||||
const disabledIds = this.settingsManager?.getDisabledExtensions() ?? [];
|
||||
this.state = refreshState(this.state, this.cwd, disabledIds);
|
||||
this.sidebar.setTree(this.state.sidebarTree);
|
||||
this.mainList.setExtensions(this.state.extensions);
|
||||
if (this.state.selected) {
|
||||
this.inspector.setExtension(this.state.selected);
|
||||
}
|
||||
}
|
||||
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";
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Ctrl+C - close dashboard
|
||||
if (isCtrlC(data)) {
|
||||
this.onClose?.();
|
||||
return;
|
||||
}
|
||||
// Build label with count
|
||||
let label = tab.label;
|
||||
if (tab.count > 0) {
|
||||
label += ` (${tab.count})`;
|
||||
}
|
||||
|
||||
// Escape: Clear search if in main pane with query, otherwise close
|
||||
if (isEscape(data)) {
|
||||
if (this.state.focusPane === "main") {
|
||||
this.mainList.clearSearch();
|
||||
}
|
||||
this.onClose?.();
|
||||
return;
|
||||
}
|
||||
// Apply strikethrough for disabled providers
|
||||
const displayLabel = isDisabled ? label.split("").join("\u0336") + "\u0336" : label;
|
||||
|
||||
// Tab: Cycle focus right
|
||||
if (isTab(data)) {
|
||||
this.cycleFocusRight();
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
// Active tab: background highlight
|
||||
parts.push(theme.bg("selectedBg", ` ${displayLabel} `));
|
||||
} else if (isDisabled) {
|
||||
// Disabled provider: strikethrough + dim
|
||||
parts.push(theme.fg("dim", ` ${displayLabel} `));
|
||||
} else if (isEmpty) {
|
||||
// Empty enabled provider: very dim, unselectable
|
||||
parts.push(`\x1b[38;5;238m ${label} \x1b[0m`);
|
||||
} else {
|
||||
// Normal enabled provider
|
||||
parts.push(theme.fg("muted", ` ${label} `));
|
||||
}
|
||||
}
|
||||
|
||||
// Shift+Tab: Cycle focus left
|
||||
if (isShiftTab(data)) {
|
||||
this.cycleFocusLeft();
|
||||
return;
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// Delegate to focused pane
|
||||
switch (this.state.focusPane) {
|
||||
case "sidebar":
|
||||
this.sidebar.handleInput(data);
|
||||
break;
|
||||
case "main":
|
||||
this.mainList.handleInput(data);
|
||||
break;
|
||||
case "inspector":
|
||||
// Inspector is read-only
|
||||
break;
|
||||
}
|
||||
}
|
||||
private handleProviderToggle(providerId: string): void {
|
||||
toggleProvider(providerId);
|
||||
this.refreshFromState();
|
||||
}
|
||||
|
||||
private handleExtensionToggle(extensionId: string, enabled: boolean): void {
|
||||
if (!this.settingsManager) return;
|
||||
|
||||
if (enabled) {
|
||||
this.settingsManager.enableExtension(extensionId);
|
||||
} else {
|
||||
this.settingsManager.disableExtension(extensionId);
|
||||
}
|
||||
|
||||
this.refreshFromState();
|
||||
}
|
||||
|
||||
private refreshFromState(): void {
|
||||
// Remember current tab ID before refresh
|
||||
const currentTabId = this.state.tabs[this.state.activeTabIndex]?.id;
|
||||
|
||||
const disabledIds = this.settingsManager?.getDisabledExtensions() ?? [];
|
||||
this.state = refreshState(this.state, this.cwd, disabledIds);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
private 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 (isCtrlC(data)) {
|
||||
this.onClose?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape - clear search first, then close
|
||||
if (isEscape(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: Cycle through tabs
|
||||
if (isTab(data)) {
|
||||
this.switchTab(1);
|
||||
return;
|
||||
}
|
||||
if (isShiftTab(data)) {
|
||||
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 {
|
||||
private leftPane: ExtensionList;
|
||||
private rightPane: InspectorPanel;
|
||||
|
||||
constructor(left: ExtensionList, right: InspectorPanel) {
|
||||
this.leftPane = left;
|
||||
this.rightPane = right;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const leftWidth = Math.floor(width * 0.5);
|
||||
const rightWidth = width - leftWidth - 3;
|
||||
|
||||
const leftLines = this.leftPane.render(leftWidth);
|
||||
const rightLines = this.rightPane.render(rightWidth);
|
||||
|
||||
const maxLines = Math.max(leftLines.length, rightLines.length);
|
||||
const combined: string[] = [];
|
||||
const separator = theme.fg("dim", " │ ");
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
|
||||
const leftPadded = left + " ".repeat(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?.();
|
||||
}
|
||||
}
|
||||
|
||||
+404
-402
@@ -1,475 +1,477 @@
|
||||
/**
|
||||
* ExtensionList - Tree view with grouping and fuzzy search.
|
||||
* ExtensionList - Inventory list with Master Switch and fuzzy search.
|
||||
*
|
||||
* Displays extensions grouped by kind with collapsible headers.
|
||||
* Supports filtering via fuzzy search (flattens tree when active).
|
||||
* When viewing a specific provider (not "ALL"), Row #0 is the Master Switch
|
||||
* that toggles the entire provider. All items below are dimmed when the
|
||||
* master switch is off.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Component,
|
||||
isArrowDown,
|
||||
isArrowLeft,
|
||||
isArrowRight,
|
||||
isArrowUp,
|
||||
isBackspace,
|
||||
isEnter,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
type Component,
|
||||
isArrowDown,
|
||||
isArrowUp,
|
||||
isBackspace,
|
||||
isEnter,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { isProviderEnabled } from "../../../../discovery";
|
||||
import { theme } from "../../theme/theme";
|
||||
import { applyFilter } from "./state-manager";
|
||||
import type { Extension, ExtensionKind, ExtensionState } from "./types";
|
||||
|
||||
export interface ExtensionListCallbacks {
|
||||
/** Called when selection changes */
|
||||
onSelectionChange?: (extension: Extension | null) => void;
|
||||
/** Called when extension is toggled (Enter pressed on item) */
|
||||
onToggle?: (extensionId: string, enabled: boolean) => void;
|
||||
/** Called when selection changes */
|
||||
onSelectionChange?: (extension: Extension | null) => void;
|
||||
/** Called when extension is toggled */
|
||||
onToggle?: (extensionId: string, enabled: boolean) => void;
|
||||
/** Called when master switch is toggled */
|
||||
onMasterToggle?: (providerId: string) => void;
|
||||
/** Provider ID for master switch (null = no master switch) */
|
||||
masterSwitchProvider?: string | null;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 30;
|
||||
const MAX_VISIBLE = 25;
|
||||
|
||||
/** Tree group for a kind of extensions */
|
||||
interface TreeGroup {
|
||||
id: string;
|
||||
kind: ExtensionKind;
|
||||
label: string;
|
||||
icon: string;
|
||||
collapsed: boolean;
|
||||
items: Extension[];
|
||||
}
|
||||
|
||||
/** Flattened tree item for rendering */
|
||||
type FlatItem =
|
||||
| { type: "group"; group: TreeGroup }
|
||||
| { type: "item"; item: Extension; group: TreeGroup };
|
||||
/** Flattened list item for rendering */
|
||||
type ListItem =
|
||||
| { type: "master"; providerId: string; providerName: string; enabled: boolean }
|
||||
| { type: "kind-header"; kind: ExtensionKind; label: string; icon: string; count: number }
|
||||
| { type: "extension"; item: Extension };
|
||||
|
||||
export class ExtensionList implements Component {
|
||||
private extensions: Extension[] = [];
|
||||
private groups: TreeGroup[] = [];
|
||||
private flatItems: FlatItem[] = [];
|
||||
private selectedIndex = 0;
|
||||
private scrollOffset = 0;
|
||||
private searchQuery = "";
|
||||
private focused = false;
|
||||
private callbacks: ExtensionListCallbacks;
|
||||
/** True when there's an active filter (query.length > 0) */
|
||||
private hasFilter = false;
|
||||
private extensions: Extension[] = [];
|
||||
private listItems: ListItem[] = [];
|
||||
private selectedIndex = 0;
|
||||
private scrollOffset = 0;
|
||||
private searchQuery = "";
|
||||
private focused = false;
|
||||
private callbacks: ExtensionListCallbacks;
|
||||
private masterSwitchProvider: string | null = null;
|
||||
|
||||
constructor(extensions: Extension[], callbacks: ExtensionListCallbacks = {}) {
|
||||
this.extensions = extensions;
|
||||
this.callbacks = callbacks;
|
||||
this.rebuildGroups();
|
||||
}
|
||||
constructor(extensions: Extension[], callbacks: ExtensionListCallbacks = {}) {
|
||||
this.extensions = extensions;
|
||||
this.callbacks = callbacks;
|
||||
this.masterSwitchProvider = callbacks.masterSwitchProvider ?? null;
|
||||
this.rebuildList();
|
||||
}
|
||||
|
||||
setExtensions(extensions: Extension[]): void {
|
||||
this.extensions = extensions;
|
||||
this.rebuildGroups();
|
||||
this.clampSelection();
|
||||
}
|
||||
setExtensions(extensions: Extension[]): void {
|
||||
this.extensions = extensions;
|
||||
this.rebuildList();
|
||||
this.clampSelection();
|
||||
}
|
||||
|
||||
setFocused(focused: boolean): void {
|
||||
this.focused = focused;
|
||||
}
|
||||
setFocused(focused: boolean): void {
|
||||
this.focused = focused;
|
||||
}
|
||||
|
||||
getSelectedExtension(): Extension | null {
|
||||
const item = this.flatItems[this.selectedIndex];
|
||||
if (item?.type === "item") {
|
||||
return item.item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
setMasterSwitchProvider(providerId: string | null): void {
|
||||
this.masterSwitchProvider = providerId;
|
||||
this.rebuildList();
|
||||
}
|
||||
|
||||
setSearchQuery(query: string): void {
|
||||
this.searchQuery = query;
|
||||
this.hasFilter = query.length > 0;
|
||||
this.rebuildGroups();
|
||||
this.selectedIndex = 0;
|
||||
this.scrollOffset = 0;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
getSearchQuery(): string {
|
||||
return this.searchQuery;
|
||||
}
|
||||
|
||||
/** Clear search filter */
|
||||
clearSearch(): void {
|
||||
this.setSearchQuery("");
|
||||
}
|
||||
resetSelection(): void {
|
||||
this.selectedIndex = 0;
|
||||
this.scrollOffset = 0;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
getSelectedExtension(): Extension | null {
|
||||
const item = this.listItems[this.selectedIndex];
|
||||
return item?.type === "extension" ? item.item : null;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
/** Get the currently selected kind header (for preview purposes) */
|
||||
getSelectedKind(): ExtensionKind | null {
|
||||
const item = this.listItems[this.selectedIndex];
|
||||
return item?.type === "kind-header" ? item.kind : null;
|
||||
}
|
||||
|
||||
// Search bar - cursor shown when focused
|
||||
const searchPrefix = theme.fg("muted", "Search: ");
|
||||
const searchText = this.searchQuery || (this.focused ? "" : theme.fg("dim", "type to filter"));
|
||||
const cursor = this.focused ? theme.fg("accent", "_") : "";
|
||||
lines.push(searchPrefix + searchText + cursor);
|
||||
lines.push("");
|
||||
setSearchQuery(query: string): void {
|
||||
this.searchQuery = query;
|
||||
this.rebuildList();
|
||||
this.selectedIndex = 0;
|
||||
this.scrollOffset = 0;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
|
||||
if (this.flatItems.length === 0) {
|
||||
lines.push(theme.fg("muted", " No extensions found"));
|
||||
return lines;
|
||||
}
|
||||
clearSearch(): void {
|
||||
this.setSearchQuery("");
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
const startIdx = this.scrollOffset;
|
||||
const endIdx = Math.min(startIdx + MAX_VISIBLE, this.flatItems.length);
|
||||
invalidate(): void {}
|
||||
|
||||
// Render visible items
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const flatItem = this.flatItems[i];
|
||||
const isSelected = this.focused && i === this.selectedIndex;
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (flatItem.type === "group") {
|
||||
lines.push(this.renderGroupHeader(flatItem.group, isSelected, width));
|
||||
} else {
|
||||
lines.push(this.renderExtensionRow(flatItem.item, isSelected, width));
|
||||
}
|
||||
}
|
||||
// Search bar
|
||||
const searchPrefix = theme.fg("muted", "Search: ");
|
||||
const searchText = this.searchQuery || (this.focused ? "" : theme.fg("dim", "type to filter"));
|
||||
const cursor = this.focused ? theme.fg("accent", "_") : "";
|
||||
lines.push(searchPrefix + searchText + cursor);
|
||||
lines.push("");
|
||||
|
||||
// Scroll indicator
|
||||
if (this.flatItems.length > MAX_VISIBLE) {
|
||||
const indicator = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.flatItems.length})`);
|
||||
lines.push(indicator);
|
||||
}
|
||||
if (this.listItems.length === 0) {
|
||||
lines.push(theme.fg("muted", " No extensions found for this provider."));
|
||||
return lines;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
// Determine if master switch is off (for dimming child items)
|
||||
const masterDisabled = this.masterSwitchProvider !== null && !isProviderEnabled(this.masterSwitchProvider);
|
||||
|
||||
private renderGroupHeader(group: TreeGroup, isSelected: boolean, width: number): string {
|
||||
const kindIcon = group.icon;
|
||||
const countStr = `(${group.items.length})`;
|
||||
// Calculate visible range
|
||||
const startIdx = this.scrollOffset;
|
||||
const endIdx = Math.min(startIdx + MAX_VISIBLE, this.listItems.length);
|
||||
|
||||
let line = `${kindIcon} ${group.label} ${theme.fg("muted", countStr)}`;
|
||||
// Render visible items
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const listItem = this.listItems[i];
|
||||
const isSelected = this.focused && i === this.selectedIndex;
|
||||
|
||||
if (isSelected) {
|
||||
line = theme.bold(theme.fg("accent", line));
|
||||
line = theme.bg("selectedBg", line);
|
||||
}
|
||||
if (listItem.type === "master") {
|
||||
lines.push(this.renderMasterSwitch(listItem, isSelected, width));
|
||||
} else if (listItem.type === "kind-header") {
|
||||
lines.push(this.renderKindHeader(listItem, isSelected, width));
|
||||
} else {
|
||||
lines.push(this.renderExtensionRow(listItem.item, isSelected, width, masterDisabled));
|
||||
}
|
||||
}
|
||||
|
||||
return truncateToWidth(line, width);
|
||||
}
|
||||
// Scroll indicator
|
||||
if (this.listItems.length > MAX_VISIBLE) {
|
||||
const indicator = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.listItems.length})`);
|
||||
lines.push(indicator);
|
||||
}
|
||||
|
||||
private renderExtensionRow(ext: Extension, isSelected: boolean, width: number): string {
|
||||
// Status icon
|
||||
const stateIcon = this.getStateIcon(ext.state);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Name
|
||||
let name = ext.displayName;
|
||||
const nameWidth = Math.min(28, width - 10);
|
||||
private renderMasterSwitch(item: ListItem & { type: "master" }, isSelected: boolean, width: number): string {
|
||||
const checkbox = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
||||
const icon = "📦";
|
||||
let label = `Enable ${item.providerName}`;
|
||||
const badge = theme.fg("warning", "(Master Switch)");
|
||||
|
||||
// Trigger (if present)
|
||||
const trigger = ext.trigger ? theme.fg("dim", ext.trigger) : "";
|
||||
let line = `${checkbox} ${icon} ${label} ${badge}`;
|
||||
|
||||
// Build the line with tree branch structure
|
||||
let line = ` ├─ ${stateIcon} `;
|
||||
if (isSelected) {
|
||||
line = theme.bold(theme.fg("accent", line));
|
||||
line = theme.bg("selectedBg", line);
|
||||
} else if (!item.enabled) {
|
||||
line = theme.fg("dim", line);
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
name = theme.bold(theme.fg("accent", name));
|
||||
} else if (ext.state === "disabled") {
|
||||
name = theme.fg("dim", name);
|
||||
} else if (ext.state === "shadowed") {
|
||||
name = theme.fg("warning", name);
|
||||
}
|
||||
return truncateToWidth(line, width);
|
||||
}
|
||||
|
||||
// Pad name
|
||||
const namePadded = this.padText(name, nameWidth);
|
||||
line += namePadded;
|
||||
private renderKindHeader(item: ListItem & { type: "kind-header" }, isSelected: boolean, width: number): string {
|
||||
const countBadge = theme.fg("muted", `(${item.count})`);
|
||||
let line = `${item.icon} ${item.label} ${countBadge}`;
|
||||
|
||||
// Add trigger with spacing
|
||||
if (trigger) {
|
||||
const remainingWidth = width - visibleWidth(line) - 2;
|
||||
if (remainingWidth > 5) {
|
||||
line += " " + truncateToWidth(trigger, remainingWidth);
|
||||
}
|
||||
}
|
||||
if (isSelected) {
|
||||
line = theme.bold(theme.fg("accent", line));
|
||||
line = theme.bg("selectedBg", line);
|
||||
} else {
|
||||
line = theme.fg("muted", line);
|
||||
}
|
||||
|
||||
// Apply selection background
|
||||
if (isSelected) {
|
||||
line = theme.bg("selectedBg", line);
|
||||
}
|
||||
return truncateToWidth(line, width);
|
||||
}
|
||||
|
||||
return truncateToWidth(line, width);
|
||||
}
|
||||
private renderExtensionRow(ext: Extension, isSelected: boolean, width: number, masterDisabled: boolean): string {
|
||||
// When master is disabled, all items appear dimmed
|
||||
const effectivelyDisabled = masterDisabled || ext.state === "disabled";
|
||||
|
||||
private getKindIcon(kind: ExtensionKind): string {
|
||||
switch (kind) {
|
||||
case "skill":
|
||||
return "⚡";
|
||||
case "tool":
|
||||
case "slash-command":
|
||||
return "🛠️";
|
||||
case "mcp":
|
||||
return "📦";
|
||||
case "rule":
|
||||
return "📋";
|
||||
case "hook":
|
||||
return "🪝";
|
||||
case "prompt":
|
||||
return "💬";
|
||||
case "context-file":
|
||||
return "📄";
|
||||
case "instruction":
|
||||
return "📌";
|
||||
default:
|
||||
return "•";
|
||||
}
|
||||
}
|
||||
// Status icon
|
||||
const stateIcon = this.getStateIcon(ext.state, masterDisabled);
|
||||
|
||||
private getStateIcon(state: ExtensionState): string {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return theme.fg("success", "●");
|
||||
case "disabled":
|
||||
return theme.fg("dim", "○");
|
||||
case "shadowed":
|
||||
return theme.fg("warning", "◐");
|
||||
}
|
||||
}
|
||||
// Name
|
||||
let name = ext.displayName;
|
||||
const nameWidth = Math.min(24, width - 16);
|
||||
|
||||
private padText(text: string, targetWidth: number): string {
|
||||
const width = visibleWidth(text);
|
||||
if (width >= targetWidth) {
|
||||
return truncateToWidth(text, targetWidth);
|
||||
}
|
||||
return text + " ".repeat(targetWidth - width);
|
||||
}
|
||||
// Build the line with indentation (visually "inside" the master switch)
|
||||
let line = ` ${stateIcon} `;
|
||||
|
||||
private rebuildGroups(): void {
|
||||
if (this.hasFilter) {
|
||||
// Flatten: show only matching items, no group headers
|
||||
const filtered = applyFilter(this.extensions, this.searchQuery);
|
||||
this.flatItems = filtered.map((item) => ({
|
||||
type: "item" as const,
|
||||
item,
|
||||
group: this.findGroupForKind(item.kind),
|
||||
}));
|
||||
} else {
|
||||
// Build groups from extensions
|
||||
this.groups = this.buildGroupsFromExtensions();
|
||||
if (isSelected && !masterDisabled) {
|
||||
name = theme.bold(theme.fg("accent", name));
|
||||
} else if (effectivelyDisabled) {
|
||||
name = theme.fg("dim", name);
|
||||
} else if (ext.state === "shadowed") {
|
||||
name = theme.fg("warning", name);
|
||||
}
|
||||
|
||||
// Flatten tree based on collapsed state
|
||||
this.flatItems = [];
|
||||
for (const group of this.groups) {
|
||||
// Add group header
|
||||
this.flatItems.push({ type: "group", group });
|
||||
// Pad name
|
||||
const namePadded = this.padText(name, nameWidth);
|
||||
line += namePadded;
|
||||
|
||||
// Add items if not collapsed
|
||||
if (!group.collapsed) {
|
||||
for (const item of group.items) {
|
||||
this.flatItems.push({ type: "item", item, group });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trigger hint
|
||||
if (ext.trigger) {
|
||||
const triggerStyle = effectivelyDisabled ? "dim" : "muted";
|
||||
const remainingWidth = width - visibleWidth(line) - 2;
|
||||
if (remainingWidth > 5) {
|
||||
line += " " + truncateToWidth(theme.fg(triggerStyle as "dim" | "muted", ext.trigger), remainingWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private buildGroupsFromExtensions(): TreeGroup[] {
|
||||
// Group extensions by kind
|
||||
const kindMap = new Map<ExtensionKind, Extension[]>();
|
||||
// Apply selection background
|
||||
if (isSelected) {
|
||||
line = theme.bg("selectedBg", line);
|
||||
}
|
||||
|
||||
for (const ext of this.extensions) {
|
||||
const items = kindMap.get(ext.kind) ?? [];
|
||||
items.push(ext);
|
||||
kindMap.set(ext.kind, items);
|
||||
}
|
||||
return truncateToWidth(line, width);
|
||||
}
|
||||
|
||||
// Create groups with labels and icons
|
||||
const groups: TreeGroup[] = [];
|
||||
const kindOrder: ExtensionKind[] = [
|
||||
"skill",
|
||||
"tool",
|
||||
"slash-command",
|
||||
"context-file",
|
||||
"rule",
|
||||
"mcp",
|
||||
"hook",
|
||||
"prompt",
|
||||
"instruction",
|
||||
];
|
||||
private getKindIcon(kind: ExtensionKind): string {
|
||||
switch (kind) {
|
||||
case "skill":
|
||||
return "⚡";
|
||||
case "tool":
|
||||
return "🔧";
|
||||
case "slash-command":
|
||||
return "🔗";
|
||||
case "mcp":
|
||||
return "🔄";
|
||||
case "rule":
|
||||
return "📋";
|
||||
case "hook":
|
||||
return "🪝";
|
||||
case "prompt":
|
||||
return "💬";
|
||||
case "context-file":
|
||||
return "📄";
|
||||
case "instruction":
|
||||
return "📌";
|
||||
default:
|
||||
return "•";
|
||||
}
|
||||
}
|
||||
|
||||
for (const kind of kindOrder) {
|
||||
const items = kindMap.get(kind);
|
||||
if (items && items.length > 0) {
|
||||
groups.push({
|
||||
id: `group:${kind}`,
|
||||
kind,
|
||||
label: this.getKindLabel(kind),
|
||||
icon: this.getKindIcon(kind),
|
||||
collapsed: false,
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
private getStateIcon(state: ExtensionState, masterDisabled: boolean): string {
|
||||
if (masterDisabled) {
|
||||
return theme.fg("dim", "○");
|
||||
}
|
||||
switch (state) {
|
||||
case "active":
|
||||
return theme.fg("success", "●");
|
||||
case "disabled":
|
||||
return theme.fg("dim", "⊘");
|
||||
case "shadowed":
|
||||
return theme.fg("warning", "◐");
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
private padText(text: string, targetWidth: number): string {
|
||||
const width = visibleWidth(text);
|
||||
if (width >= targetWidth) {
|
||||
return truncateToWidth(text, targetWidth);
|
||||
}
|
||||
return text + " ".repeat(targetWidth - width);
|
||||
}
|
||||
|
||||
private findGroupForKind(kind: ExtensionKind): TreeGroup {
|
||||
return (
|
||||
this.groups.find((g) => g.kind === kind) ?? {
|
||||
id: `group:${kind}`,
|
||||
kind,
|
||||
label: this.getKindLabel(kind),
|
||||
icon: this.getKindIcon(kind),
|
||||
collapsed: false,
|
||||
items: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
private rebuildList(): void {
|
||||
this.listItems = [];
|
||||
|
||||
private getKindLabel(kind: ExtensionKind): string {
|
||||
switch (kind) {
|
||||
case "skill":
|
||||
return "Skills";
|
||||
case "tool":
|
||||
return "Custom Tools";
|
||||
case "slash-command":
|
||||
return "Slash Commands";
|
||||
case "mcp":
|
||||
return "MCP Servers";
|
||||
case "rule":
|
||||
return "Rules";
|
||||
case "hook":
|
||||
return "Hooks";
|
||||
case "prompt":
|
||||
return "Prompts";
|
||||
case "context-file":
|
||||
return "Context Files";
|
||||
case "instruction":
|
||||
return "Instructions";
|
||||
default:
|
||||
return "Other";
|
||||
}
|
||||
}
|
||||
// Apply search filter
|
||||
const filtered = this.searchQuery.length > 0 ? applyFilter(this.extensions, this.searchQuery) : this.extensions;
|
||||
|
||||
private clampSelection(): void {
|
||||
if (this.flatItems.length === 0) {
|
||||
this.selectedIndex = 0;
|
||||
this.scrollOffset = 0;
|
||||
return;
|
||||
}
|
||||
// When searching, show flat list
|
||||
if (this.searchQuery.length > 0) {
|
||||
for (const ext of filtered) {
|
||||
this.listItems.push({ type: "extension", item: ext });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedIndex = Math.min(this.selectedIndex, this.flatItems.length - 1);
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex);
|
||||
// Provider-specific view: Master switch + flat list
|
||||
if (this.masterSwitchProvider) {
|
||||
const providerName = filtered[0]?.source.providerName ?? this.masterSwitchProvider;
|
||||
const enabled = isProviderEnabled(this.masterSwitchProvider);
|
||||
|
||||
// Adjust scroll offset
|
||||
if (this.selectedIndex < this.scrollOffset) {
|
||||
this.scrollOffset = this.selectedIndex;
|
||||
} else if (this.selectedIndex >= this.scrollOffset + MAX_VISIBLE) {
|
||||
this.scrollOffset = this.selectedIndex - MAX_VISIBLE + 1;
|
||||
}
|
||||
}
|
||||
this.listItems.push({
|
||||
type: "master",
|
||||
providerId: this.masterSwitchProvider,
|
||||
providerName,
|
||||
enabled,
|
||||
});
|
||||
|
||||
handleInput(data: string): void {
|
||||
const charCode = data.length === 1 ? data.charCodeAt(0) : -1;
|
||||
for (const ext of filtered) {
|
||||
this.listItems.push({ type: "extension", item: ext });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation - j/k or arrows
|
||||
if (isArrowUp(data) || data === "k") {
|
||||
this.moveSelectionUp();
|
||||
return;
|
||||
}
|
||||
// ALL view: Group by kind with headers
|
||||
const byKind = new Map<ExtensionKind, Extension[]>();
|
||||
for (const ext of filtered) {
|
||||
const list = byKind.get(ext.kind) ?? [];
|
||||
list.push(ext);
|
||||
byKind.set(ext.kind, list);
|
||||
}
|
||||
|
||||
if (isArrowDown(data) || data === "j") {
|
||||
this.moveSelectionDown();
|
||||
return;
|
||||
}
|
||||
const kindOrder: ExtensionKind[] = [
|
||||
"skill",
|
||||
"tool",
|
||||
"slash-command",
|
||||
"rule",
|
||||
"mcp",
|
||||
"hook",
|
||||
"prompt",
|
||||
"context-file",
|
||||
"instruction",
|
||||
];
|
||||
|
||||
// Left arrow: collapse current group or move to parent group
|
||||
if (isArrowLeft(data)) {
|
||||
const item = this.flatItems[this.selectedIndex];
|
||||
if (item?.type === "group" && !item.group.collapsed) {
|
||||
item.group.collapsed = true;
|
||||
this.rebuildGroups();
|
||||
this.clampSelection();
|
||||
} else if (item?.type === "item") {
|
||||
// Move selection to parent group header
|
||||
const groupIndex = this.flatItems.findIndex(
|
||||
(fi) => fi.type === "group" && fi.group.kind === item.group.kind
|
||||
);
|
||||
if (groupIndex >= 0) {
|
||||
this.selectedIndex = groupIndex;
|
||||
this.clampSelection();
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const kind of kindOrder) {
|
||||
const items = byKind.get(kind);
|
||||
if (!items || items.length === 0) continue;
|
||||
|
||||
// Right arrow: expand current group
|
||||
if (isArrowRight(data)) {
|
||||
const item = this.flatItems[this.selectedIndex];
|
||||
if (item?.type === "group" && item.group.collapsed) {
|
||||
item.group.collapsed = false;
|
||||
this.rebuildGroups();
|
||||
this.clampSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.listItems.push({
|
||||
type: "kind-header",
|
||||
kind,
|
||||
label: this.getKindLabel(kind),
|
||||
icon: this.getKindIcon(kind),
|
||||
count: items.length,
|
||||
});
|
||||
|
||||
// Space: TOGGLE item enabled/disabled
|
||||
if (data === " ") {
|
||||
const item = this.flatItems[this.selectedIndex];
|
||||
if (item?.type === "item") {
|
||||
const newEnabled = item.item.state === "disabled";
|
||||
this.callbacks.onToggle?.(item.item.id, newEnabled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const ext of items) {
|
||||
this.listItems.push({ type: "extension", item: ext });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enter: expand/collapse group
|
||||
if (isEnter(data)) {
|
||||
const item = this.flatItems[this.selectedIndex];
|
||||
if (item?.type === "group") {
|
||||
item.group.collapsed = !item.group.collapsed;
|
||||
this.rebuildGroups();
|
||||
this.clampSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
private getKindLabel(kind: ExtensionKind): string {
|
||||
switch (kind) {
|
||||
case "skill":
|
||||
return "Skills";
|
||||
case "tool":
|
||||
return "Tools";
|
||||
case "slash-command":
|
||||
return "Commands";
|
||||
case "rule":
|
||||
return "Rules";
|
||||
case "mcp":
|
||||
return "MCP Servers";
|
||||
case "hook":
|
||||
return "Hooks";
|
||||
case "prompt":
|
||||
return "Prompts";
|
||||
case "context-file":
|
||||
return "Context";
|
||||
case "instruction":
|
||||
return "Instructions";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace: delete from search query
|
||||
if (isBackspace(data)) {
|
||||
if (this.searchQuery.length > 0) {
|
||||
this.setSearchQuery(this.searchQuery.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
private clampSelection(): void {
|
||||
if (this.listItems.length === 0) {
|
||||
this.selectedIndex = 0;
|
||||
this.scrollOffset = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Printable characters (except special keys) -> search
|
||||
// Skip j/k (navigation), skip space, skip control chars
|
||||
if (data.length === 1 && charCode > 32 && charCode < 127) {
|
||||
// Skip j/k as they're navigation
|
||||
if (data === "j" || data === "k") {
|
||||
return;
|
||||
}
|
||||
this.setSearchQuery(this.searchQuery + data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.selectedIndex = Math.min(this.selectedIndex, this.listItems.length - 1);
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex);
|
||||
|
||||
private moveSelectionUp(): void {
|
||||
if (this.selectedIndex > 0) {
|
||||
this.selectedIndex--;
|
||||
if (this.selectedIndex < this.scrollOffset) {
|
||||
this.scrollOffset = this.selectedIndex;
|
||||
}
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
}
|
||||
// Adjust scroll offset
|
||||
if (this.selectedIndex < this.scrollOffset) {
|
||||
this.scrollOffset = this.selectedIndex;
|
||||
} else if (this.selectedIndex >= this.scrollOffset + MAX_VISIBLE) {
|
||||
this.scrollOffset = this.selectedIndex - MAX_VISIBLE + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private moveSelectionDown(): void {
|
||||
if (this.selectedIndex < this.flatItems.length - 1) {
|
||||
this.selectedIndex++;
|
||||
if (this.selectedIndex >= this.scrollOffset + MAX_VISIBLE) {
|
||||
this.scrollOffset = this.selectedIndex - MAX_VISIBLE + 1;
|
||||
}
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
}
|
||||
handleInput(data: string): void {
|
||||
const charCode = data.length === 1 ? data.charCodeAt(0) : -1;
|
||||
|
||||
private notifySelectionChange(): void {
|
||||
const ext = this.getSelectedExtension();
|
||||
this.callbacks.onSelectionChange?.(ext);
|
||||
}
|
||||
// Navigation
|
||||
if (isArrowUp(data) || data === "k") {
|
||||
this.moveSelectionUp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArrowDown(data) || data === "j") {
|
||||
this.moveSelectionDown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Space: Toggle selected item
|
||||
if (data === " ") {
|
||||
const item = this.listItems[this.selectedIndex];
|
||||
if (item?.type === "master") {
|
||||
this.callbacks.onMasterToggle?.(item.providerId);
|
||||
} else if (item?.type === "extension") {
|
||||
// Only allow toggling if master is enabled
|
||||
const masterDisabled = this.masterSwitchProvider !== null && !isProviderEnabled(this.masterSwitchProvider);
|
||||
if (!masterDisabled) {
|
||||
const newEnabled = item.item.state === "disabled";
|
||||
this.callbacks.onToggle?.(item.item.id, newEnabled);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: Same as space - toggle selected item
|
||||
if (isEnter(data)) {
|
||||
const item = this.listItems[this.selectedIndex];
|
||||
if (item?.type === "master") {
|
||||
this.callbacks.onMasterToggle?.(item.providerId);
|
||||
} else if (item?.type === "extension") {
|
||||
const masterDisabled = this.masterSwitchProvider !== null && !isProviderEnabled(this.masterSwitchProvider);
|
||||
if (!masterDisabled) {
|
||||
const newEnabled = item.item.state === "disabled";
|
||||
this.callbacks.onToggle?.(item.item.id, newEnabled);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace: Delete from search query
|
||||
if (isBackspace(data)) {
|
||||
if (this.searchQuery.length > 0) {
|
||||
this.setSearchQuery(this.searchQuery.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Printable characters -> search
|
||||
if (data.length === 1 && charCode > 32 && charCode < 127) {
|
||||
// Skip j/k as they're navigation
|
||||
if (data === "j" || data === "k") {
|
||||
return;
|
||||
}
|
||||
this.setSearchQuery(this.searchQuery + data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private moveSelectionUp(): void {
|
||||
if (this.selectedIndex > 0) {
|
||||
this.selectedIndex--;
|
||||
if (this.selectedIndex < this.scrollOffset) {
|
||||
this.scrollOffset = this.selectedIndex;
|
||||
}
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
}
|
||||
|
||||
private moveSelectionDown(): void {
|
||||
if (this.selectedIndex < this.listItems.length - 1) {
|
||||
this.selectedIndex++;
|
||||
if (this.selectedIndex >= this.scrollOffset + MAX_VISIBLE) {
|
||||
this.scrollOffset = this.selectedIndex - MAX_VISIBLE + 1;
|
||||
}
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
}
|
||||
|
||||
private notifySelectionChange(): void {
|
||||
const ext = this.getSelectedExtension();
|
||||
this.callbacks.onSelectionChange?.(ext);
|
||||
}
|
||||
}
|
||||
|
||||
-235
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* HolyGrailLayout - 3-column layout with box-drawing borders.
|
||||
*
|
||||
* ```
|
||||
* ╭──────────┬──────────────────┬──────────────────╮
|
||||
* │ Sidebar │ Main List │ Inspector │
|
||||
* │ (20%) │ (40%) │ (40%) │
|
||||
* ╰──────────┴──────────────────┴──────────────────╯
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { type Component, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../theme/theme";
|
||||
import type { FocusPane } from "./types";
|
||||
|
||||
/**
|
||||
* Pad or truncate text to exact visible width.
|
||||
*/
|
||||
function padToWidth(text: string, targetWidth: number): string {
|
||||
const width = visibleWidth(text);
|
||||
if (width >= targetWidth) {
|
||||
return truncateToWidth(text, targetWidth);
|
||||
}
|
||||
return text + " ".repeat(targetWidth - width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to fit within width, preserving ANSI codes.
|
||||
*/
|
||||
function truncateToWidth(text: string, maxWidth: number): string {
|
||||
if (maxWidth <= 0) return "";
|
||||
|
||||
let width = 0;
|
||||
let result = "";
|
||||
let inAnsi = false;
|
||||
let ansiCode = "";
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
|
||||
// Track ANSI escape sequences
|
||||
if (char === "\x1b") {
|
||||
inAnsi = true;
|
||||
ansiCode = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inAnsi) {
|
||||
ansiCode += char;
|
||||
if (char === "m" || char === "G" || char === "K" || char === "H" || char === "J") {
|
||||
result += ansiCode;
|
||||
inAnsi = false;
|
||||
ansiCode = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate character width
|
||||
const charWidth = visibleWidth(char);
|
||||
if (width + charWidth > maxWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
result += char;
|
||||
width += charWidth;
|
||||
}
|
||||
|
||||
// Reset any active ANSI codes
|
||||
if (result.includes("\x1b[")) {
|
||||
result += "\x1b[0m";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column ratios for the layout.
|
||||
*/
|
||||
const SIDEBAR_RATIO = 0.20;
|
||||
const MAIN_RATIO = 0.40;
|
||||
const INSPECTOR_RATIO = 0.40;
|
||||
|
||||
/**
|
||||
* Minimum column widths.
|
||||
*/
|
||||
const MIN_SIDEBAR = 15;
|
||||
const MIN_MAIN = 25;
|
||||
const MIN_INSPECTOR = 15;
|
||||
|
||||
export class HolyGrailLayout implements Component {
|
||||
private sidebar: Component;
|
||||
private mainList: Component;
|
||||
private inspector: Component;
|
||||
private focusedPane: FocusPane = "sidebar";
|
||||
// Cache widths to prevent layout shifts
|
||||
private cachedWidth = 0;
|
||||
private cachedSidebarWidth = 0;
|
||||
private cachedMainWidth = 0;
|
||||
private cachedInspectorWidth = 0;
|
||||
|
||||
constructor(sidebar: Component, mainList: Component, inspector: Component) {
|
||||
this.sidebar = sidebar;
|
||||
this.mainList = mainList;
|
||||
this.inspector = inspector;
|
||||
}
|
||||
|
||||
setFocusedPane(pane: FocusPane): void {
|
||||
this.focusedPane = pane;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.sidebar.invalidate?.();
|
||||
this.mainList.invalidate?.();
|
||||
this.inspector.invalidate?.();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Only recalculate widths if terminal width changed
|
||||
if (width !== this.cachedWidth) {
|
||||
this.cachedWidth = width;
|
||||
// Content row format: │ sidebar │ main │ inspector │
|
||||
// Fixed chars: 4 borders (│) + 3 spaces = 7
|
||||
const fixedChars = 7;
|
||||
const contentWidth = Math.max(0, width - fixedChars);
|
||||
|
||||
// Distribute remaining width across columns
|
||||
this.cachedSidebarWidth = Math.max(MIN_SIDEBAR, Math.floor(contentWidth * SIDEBAR_RATIO));
|
||||
this.cachedMainWidth = Math.max(MIN_MAIN, Math.floor(contentWidth * MAIN_RATIO));
|
||||
this.cachedInspectorWidth = Math.max(MIN_INSPECTOR, contentWidth - this.cachedSidebarWidth - this.cachedMainWidth);
|
||||
|
||||
// Ensure total doesn't exceed contentWidth
|
||||
const total = this.cachedSidebarWidth + this.cachedMainWidth + this.cachedInspectorWidth;
|
||||
if (total > contentWidth) {
|
||||
const excess = total - contentWidth;
|
||||
if (this.cachedMainWidth > MIN_MAIN) {
|
||||
this.cachedMainWidth = Math.max(MIN_MAIN, this.cachedMainWidth - excess);
|
||||
} else if (this.cachedSidebarWidth > MIN_SIDEBAR) {
|
||||
this.cachedSidebarWidth = Math.max(MIN_SIDEBAR, this.cachedSidebarWidth - excess);
|
||||
} else {
|
||||
this.cachedInspectorWidth = Math.max(1, this.cachedInspectorWidth - excess);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarWidth = this.cachedSidebarWidth;
|
||||
const mainWidth = this.cachedMainWidth;
|
||||
const inspectorWidth = this.cachedInspectorWidth;
|
||||
|
||||
// Render each panel
|
||||
const sidebarLines = this.sidebar.render(sidebarWidth);
|
||||
const mainLines = this.mainList.render(mainWidth);
|
||||
const inspectorLines = this.inspector.render(inspectorWidth);
|
||||
|
||||
// Find max height
|
||||
const maxHeight = Math.max(sidebarLines.length, mainLines.length, inspectorLines.length, 1);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Top border
|
||||
lines.push(this.renderTopBorder(sidebarWidth, mainWidth, inspectorWidth));
|
||||
|
||||
// Content rows
|
||||
for (let i = 0; i < maxHeight; i++) {
|
||||
const sidebarLine = sidebarLines[i] ?? "";
|
||||
const mainLine = mainLines[i] ?? "";
|
||||
const inspectorLine = inspectorLines[i] ?? "";
|
||||
lines.push(this.renderContentRow(sidebarLine, mainLine, inspectorLine, sidebarWidth, mainWidth, inspectorWidth));
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
lines.push(this.renderBottomBorder(sidebarWidth, mainWidth, inspectorWidth));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render top border: ╭──────┬──────────┬──────╮
|
||||
*/
|
||||
private renderTopBorder(sw: number, mw: number, iw: number): string {
|
||||
const sidebarBorder = this.focusedPane === "sidebar" ? "borderAccent" : "border";
|
||||
const mainBorder = this.focusedPane === "main" ? "borderAccent" : "border";
|
||||
const inspectorBorder = this.focusedPane === "inspector" ? "borderAccent" : "border";
|
||||
|
||||
return (
|
||||
theme.fg(sidebarBorder as any, "╭" + "─".repeat(sw + 1)) +
|
||||
theme.fg("border" as any, "┬") +
|
||||
theme.fg(mainBorder as any, "─".repeat(mw + 1)) +
|
||||
theme.fg("border" as any, "┬") +
|
||||
theme.fg(inspectorBorder as any, "─".repeat(iw + 1) + "╮")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content row: │ content │ content │ content │
|
||||
*/
|
||||
private renderContentRow(s: string, m: string, ins: string, sw: number, mw: number, iw: number): string {
|
||||
const sidebarBorder = this.focusedPane === "sidebar" ? "borderAccent" : "border";
|
||||
const mainBorder = this.focusedPane === "main" ? "borderAccent" : "border";
|
||||
const inspectorBorder = this.focusedPane === "inspector" ? "borderAccent" : "border";
|
||||
|
||||
const paddedS = padToWidth(s, sw);
|
||||
const paddedM = padToWidth(m, mw);
|
||||
const paddedI = padToWidth(ins, iw);
|
||||
|
||||
return (
|
||||
theme.fg(sidebarBorder as any, "│") +
|
||||
" " +
|
||||
paddedS +
|
||||
theme.fg("border" as any, "│") +
|
||||
" " +
|
||||
paddedM +
|
||||
theme.fg("border" as any, "│") +
|
||||
" " +
|
||||
paddedI +
|
||||
theme.fg(inspectorBorder as any, "│")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render bottom border: ╰──────┴──────────┴──────╯
|
||||
*/
|
||||
private renderBottomBorder(sw: number, mw: number, iw: number): string {
|
||||
const sidebarBorder = this.focusedPane === "sidebar" ? "borderAccent" : "border";
|
||||
const mainBorder = this.focusedPane === "main" ? "borderAccent" : "border";
|
||||
const inspectorBorder = this.focusedPane === "inspector" ? "borderAccent" : "border";
|
||||
|
||||
return (
|
||||
theme.fg(sidebarBorder as any, "╰" + "─".repeat(sw + 1)) +
|
||||
theme.fg("border" as any, "┴") +
|
||||
theme.fg(mainBorder as any, "─".repeat(mw + 1)) +
|
||||
theme.fg("border" as any, "┴") +
|
||||
theme.fg(inspectorBorder as any, "─".repeat(iw + 1) + "╯")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
export { ExtensionDashboard } from "./extension-dashboard";
|
||||
export { ExtensionList } from "./extension-list";
|
||||
export { HolyGrailLayout } from "./holy-grail-layout";
|
||||
export { InspectorPanel } from "./inspector-panel";
|
||||
export { SidebarTree } from "./sidebar-tree";
|
||||
export * from "./state-manager";
|
||||
export * from "./types";
|
||||
|
||||
+249
-246
@@ -4,307 +4,310 @@
|
||||
* Shows name, description, origin, status, and kind-specific preview.
|
||||
*/
|
||||
|
||||
import { type Component, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { type Component, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../theme/theme";
|
||||
import type { Extension, ExtensionState } from "./types";
|
||||
|
||||
export class InspectorPanel implements Component {
|
||||
private extension: Extension | null = null;
|
||||
private extension: Extension | null = null;
|
||||
|
||||
setExtension(extension: Extension | null): void {
|
||||
this.extension = extension;
|
||||
}
|
||||
setExtension(extension: Extension | null): void {
|
||||
this.extension = extension;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (!this.extension) {
|
||||
return [
|
||||
theme.fg("muted", "Select an extension"),
|
||||
theme.fg("dim", "to view details"),
|
||||
];
|
||||
}
|
||||
render(width: number): string[] {
|
||||
if (!this.extension) {
|
||||
return [theme.fg("muted", "Select an extension"), theme.fg("dim", "to view details")];
|
||||
}
|
||||
|
||||
const ext = this.extension;
|
||||
const lines: string[] = [];
|
||||
const ext = this.extension;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Name header
|
||||
lines.push(theme.bold(theme.fg("accent", ext.displayName)));
|
||||
lines.push("");
|
||||
// Name header
|
||||
lines.push(theme.bold(theme.fg("accent", ext.displayName)));
|
||||
lines.push("");
|
||||
|
||||
// Kind badge
|
||||
lines.push(theme.fg("muted", "Type: ") + this.getKindBadge(ext.kind));
|
||||
lines.push("");
|
||||
// Kind badge
|
||||
lines.push(theme.fg("muted", "Type: ") + this.getKindBadge(ext.kind));
|
||||
lines.push("");
|
||||
|
||||
// Description (wrapped)
|
||||
if (ext.description) {
|
||||
const wrapped = wrapTextWithAnsi(ext.description, width - 2);
|
||||
for (const line of wrapped) {
|
||||
lines.push(truncateToWidth(line, width));
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
// Description (wrapped)
|
||||
if (ext.description) {
|
||||
const wrapped = wrapTextWithAnsi(ext.description, width - 2);
|
||||
for (const line of wrapped) {
|
||||
lines.push(truncateToWidth(line, width));
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Origin
|
||||
lines.push(theme.fg("muted", "Origin:"));
|
||||
const levelLabel = ext.source.level === "user" ? "User" : ext.source.level === "project" ? "Project" : "Native";
|
||||
lines.push(" " + theme.italic(`via ${ext.source.providerName} (${levelLabel})`));
|
||||
lines.push(" " + theme.fg("dim", this.shortenPath(ext.path)));
|
||||
lines.push("");
|
||||
// Origin
|
||||
lines.push(theme.fg("muted", "Origin:"));
|
||||
const levelLabel = ext.source.level === "user" ? "User" : ext.source.level === "project" ? "Project" : "Native";
|
||||
lines.push(` ${theme.italic(`via ${ext.source.providerName} (${levelLabel})`)}`);
|
||||
lines.push(` ${theme.fg("dim", this.shortenPath(ext.path))}`);
|
||||
lines.push("");
|
||||
|
||||
// Status badge
|
||||
lines.push(theme.fg("muted", "Status:"));
|
||||
lines.push(" " + this.getStatusBadge(ext.state, ext.disabledReason, ext.shadowedBy));
|
||||
lines.push("");
|
||||
// Status badge
|
||||
lines.push(theme.fg("muted", "Status:"));
|
||||
lines.push(` ${this.getStatusBadge(ext.state, ext.disabledReason, ext.shadowedBy)}`);
|
||||
lines.push("");
|
||||
|
||||
// Preview section (routed based on kind)
|
||||
const previewLines = this.renderPreview(ext, width);
|
||||
lines.push(...previewLines);
|
||||
// Preview section (routed based on kind)
|
||||
const previewLines = this.renderPreview(ext, width);
|
||||
lines.push(...previewLines);
|
||||
|
||||
return lines;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderPreview(ext: Extension, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
let content: string[] = [];
|
||||
private renderPreview(ext: Extension, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
let content: string[] = [];
|
||||
|
||||
switch (ext.kind) {
|
||||
case "context-file":
|
||||
content = this.renderFilePreview(ext.path, width);
|
||||
break;
|
||||
case "tool":
|
||||
content = this.renderToolArgs(ext.raw, width);
|
||||
break;
|
||||
case "skill":
|
||||
content = this.renderSkillContent(ext.raw, width);
|
||||
break;
|
||||
case "mcp":
|
||||
content = this.renderMcpDetails(ext.raw, width);
|
||||
break;
|
||||
default:
|
||||
content = this.renderDefaultPreview(ext, width);
|
||||
break;
|
||||
}
|
||||
switch (ext.kind) {
|
||||
case "context-file":
|
||||
content = this.renderFilePreview(ext.path, width);
|
||||
break;
|
||||
case "tool":
|
||||
content = this.renderToolArgs(ext.raw, width);
|
||||
break;
|
||||
case "skill":
|
||||
content = this.renderSkillContent(ext.raw, width);
|
||||
break;
|
||||
case "mcp":
|
||||
content = this.renderMcpDetails(ext.raw, width);
|
||||
break;
|
||||
default:
|
||||
content = this.renderDefaultPreview(ext, width);
|
||||
break;
|
||||
}
|
||||
|
||||
if (content.length > 0) {
|
||||
lines.push(...content);
|
||||
}
|
||||
if (content.length > 0) {
|
||||
lines.push(...content);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderFilePreview(path: string, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("muted", "Preview:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
private renderFilePreview(path: string, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("muted", "Preview:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const fileLines = content.split("\n").slice(0, 20);
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const fileLines = content.split("\n").slice(0, 20);
|
||||
|
||||
for (const line of fileLines) {
|
||||
const highlighted = this.highlightMarkdown(line);
|
||||
lines.push(truncateToWidth(highlighted, width - 2));
|
||||
}
|
||||
for (const line of fileLines) {
|
||||
const highlighted = this.highlightMarkdown(line);
|
||||
lines.push(truncateToWidth(highlighted, width - 2));
|
||||
}
|
||||
|
||||
if (content.split("\n").length > 20) {
|
||||
lines.push(theme.fg("dim", "(truncated at line 20)"));
|
||||
}
|
||||
} catch (err) {
|
||||
lines.push(theme.fg("error", `Failed to read file: ${err instanceof Error ? err.message : String(err)}`));
|
||||
}
|
||||
if (content.split("\n").length > 20) {
|
||||
lines.push(theme.fg("dim", "(truncated at line 20)"));
|
||||
}
|
||||
} catch (err) {
|
||||
lines.push(theme.fg("error", `Failed to read file: ${err instanceof Error ? err.message : String(err)}`));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
private highlightMarkdown(line: string): string {
|
||||
// Basic markdown syntax highlighting
|
||||
let highlighted = line;
|
||||
private highlightMarkdown(line: string): string {
|
||||
// Basic markdown syntax highlighting
|
||||
let highlighted = line;
|
||||
|
||||
// Headers
|
||||
if (/^#{1,6}\s/.test(highlighted)) {
|
||||
highlighted = theme.bold(theme.fg("accent", highlighted));
|
||||
}
|
||||
// Code blocks
|
||||
else if (/^```/.test(highlighted)) {
|
||||
highlighted = theme.fg("dim", highlighted);
|
||||
}
|
||||
// Lists
|
||||
else if (/^[\s]*[-*+]\s/.test(highlighted)) {
|
||||
highlighted = highlighted.replace(/^([\s]*[-*+]\s)/, theme.fg("accent", "$1"));
|
||||
}
|
||||
// Numbered lists
|
||||
else if (/^[\s]*\d+\.\s/.test(highlighted)) {
|
||||
highlighted = highlighted.replace(/^([\s]*\d+\.\s)/, theme.fg("accent", "$1"));
|
||||
}
|
||||
// Headers
|
||||
if (/^#{1,6}\s/.test(highlighted)) {
|
||||
highlighted = theme.bold(theme.fg("accent", highlighted));
|
||||
}
|
||||
// Code blocks
|
||||
else if (/^```/.test(highlighted)) {
|
||||
highlighted = theme.fg("dim", highlighted);
|
||||
}
|
||||
// Lists
|
||||
else if (/^[\s]*[-*+]\s/.test(highlighted)) {
|
||||
highlighted = highlighted.replace(/^([\s]*[-*+]\s)/, theme.fg("accent", "$1"));
|
||||
}
|
||||
// Numbered lists
|
||||
else if (/^[\s]*\d+\.\s/.test(highlighted)) {
|
||||
highlighted = highlighted.replace(/^([\s]*\d+\.\s)/, theme.fg("accent", "$1"));
|
||||
}
|
||||
|
||||
return highlighted;
|
||||
}
|
||||
return highlighted;
|
||||
}
|
||||
|
||||
private renderToolArgs(raw: unknown, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("muted", "Arguments:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
private renderToolArgs(raw: unknown, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("muted", "Arguments:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
|
||||
try {
|
||||
const tool = raw as any;
|
||||
const params = tool?.parameters?.properties || tool?.inputSchema?.properties || {};
|
||||
try {
|
||||
const tool = raw as any;
|
||||
const params = tool?.parameters?.properties || tool?.inputSchema?.properties || {};
|
||||
|
||||
if (Object.keys(params).length === 0) {
|
||||
lines.push(theme.fg("dim", " (no arguments)"));
|
||||
} else {
|
||||
const required = new Set(tool?.parameters?.required || tool?.inputSchema?.required || []);
|
||||
if (Object.keys(params).length === 0) {
|
||||
lines.push(theme.fg("dim", " (no arguments)"));
|
||||
} else {
|
||||
const required = new Set(tool?.parameters?.required || tool?.inputSchema?.required || []);
|
||||
|
||||
for (const [name, spec] of Object.entries(params)) {
|
||||
const param = spec as any;
|
||||
const type = param.type || "any";
|
||||
const isRequired = required.has(name);
|
||||
const defaultVal = param.default !== undefined ? `Default: ${param.default}` : null;
|
||||
for (const [name, spec] of Object.entries(params)) {
|
||||
const param = spec as any;
|
||||
const type = param.type || "any";
|
||||
const isRequired = required.has(name);
|
||||
const defaultVal = param.default !== undefined ? `Default: ${param.default}` : null;
|
||||
|
||||
const nameCol = theme.fg("accent", name.padEnd(12));
|
||||
const typeCol = theme.fg("muted", type.padEnd(10));
|
||||
const reqCol = isRequired
|
||||
? theme.fg("warning", "Required")
|
||||
: defaultVal
|
||||
? theme.fg("dim", defaultVal)
|
||||
: theme.fg("dim", "Optional");
|
||||
const nameCol = theme.fg("accent", name.padEnd(12));
|
||||
const typeCol = theme.fg("muted", type.padEnd(10));
|
||||
const reqCol = isRequired
|
||||
? theme.fg("warning", "Required")
|
||||
: defaultVal
|
||||
? theme.fg("dim", defaultVal)
|
||||
: theme.fg("dim", "Optional");
|
||||
|
||||
lines.push(` ${nameCol} ${typeCol} ${reqCol}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lines.push(theme.fg("dim", " (unable to parse tool definition)"));
|
||||
}
|
||||
lines.push(` ${nameCol} ${typeCol} ${reqCol}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lines.push(theme.fg("dim", " (unable to parse tool definition)"));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderSkillContent(raw: unknown, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("muted", "Instruction:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
private renderSkillContent(raw: unknown, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("muted", "Instruction:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
|
||||
try {
|
||||
const skill = raw as any;
|
||||
const instruction = skill?.prompt || skill?.instruction || skill?.content || "";
|
||||
try {
|
||||
const skill = raw as any;
|
||||
const instruction = skill?.prompt || skill?.instruction || skill?.content || "";
|
||||
|
||||
if (!instruction) {
|
||||
lines.push(theme.fg("dim", " (no instruction text)"));
|
||||
} else {
|
||||
const instructionLines = instruction.split("\n").slice(0, 15);
|
||||
for (const line of instructionLines) {
|
||||
lines.push(truncateToWidth(line, width - 2));
|
||||
}
|
||||
if (!instruction) {
|
||||
lines.push(theme.fg("dim", " (no instruction text)"));
|
||||
} else {
|
||||
const instructionLines = instruction.split("\n").slice(0, 15);
|
||||
for (const line of instructionLines) {
|
||||
lines.push(truncateToWidth(line, width - 2));
|
||||
}
|
||||
|
||||
if (instruction.split("\n").length > 15) {
|
||||
lines.push(theme.fg("dim", "(truncated at line 15)"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lines.push(theme.fg("dim", " (unable to parse skill content)"));
|
||||
}
|
||||
if (instruction.split("\n").length > 15) {
|
||||
lines.push(theme.fg("dim", "(truncated at line 15)"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lines.push(theme.fg("dim", " (unable to parse skill content)"));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderMcpDetails(raw: unknown, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("muted", "Connection:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
private renderMcpDetails(raw: unknown, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("muted", "Connection:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
|
||||
try {
|
||||
const mcp = raw as any;
|
||||
const transport = mcp?.transport || mcp?.type || "unknown";
|
||||
const command = mcp?.command || mcp?.cmd || "";
|
||||
const args = mcp?.args || mcp?.arguments || [];
|
||||
try {
|
||||
const mcp = raw as any;
|
||||
const transport = mcp?.transport || mcp?.type || "unknown";
|
||||
const command = mcp?.command || mcp?.cmd || "";
|
||||
const args = mcp?.args || mcp?.arguments || [];
|
||||
|
||||
lines.push(` ${theme.fg("muted", "Transport:")} ${theme.fg("accent", transport)}`);
|
||||
lines.push(` ${theme.fg("muted", "Transport:")} ${theme.fg("accent", transport)}`);
|
||||
|
||||
if (command) {
|
||||
lines.push(` ${theme.fg("muted", "Command:")} ${theme.fg("success", command)}`);
|
||||
}
|
||||
if (command) {
|
||||
lines.push(` ${theme.fg("muted", "Command:")} ${theme.fg("success", command)}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(args) && args.length > 0) {
|
||||
lines.push(` ${theme.fg("muted", "Args:")} ${theme.fg("dim", args.join(" "))}`);
|
||||
}
|
||||
if (Array.isArray(args) && args.length > 0) {
|
||||
lines.push(` ${theme.fg("muted", "Args:")} ${theme.fg("dim", args.join(" "))}`);
|
||||
}
|
||||
|
||||
// Environment variables if present
|
||||
if (mcp?.env && typeof mcp.env === "object") {
|
||||
const envCount = Object.keys(mcp.env).length;
|
||||
if (envCount > 0) {
|
||||
lines.push(` ${theme.fg("muted", "Env vars:")} ${theme.fg("dim", `${envCount} defined`)}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lines.push(theme.fg("dim", " (unable to parse MCP configuration)"));
|
||||
}
|
||||
// Environment variables if present
|
||||
if (mcp?.env && typeof mcp.env === "object") {
|
||||
const envCount = Object.keys(mcp.env).length;
|
||||
if (envCount > 0) {
|
||||
lines.push(` ${theme.fg("muted", "Env vars:")} ${theme.fg("dim", `${envCount} defined`)}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lines.push(theme.fg("dim", " (unable to parse MCP configuration)"));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderDefaultPreview(ext: Extension, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
private renderDefaultPreview(ext: Extension, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Show trigger pattern if present
|
||||
if (ext.trigger) {
|
||||
lines.push(theme.fg("muted", "Trigger:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
lines.push(` ${theme.fg("accent", ext.trigger)}`);
|
||||
lines.push("");
|
||||
}
|
||||
// Show trigger pattern if present
|
||||
if (ext.trigger) {
|
||||
lines.push(theme.fg("muted", "Trigger:"));
|
||||
lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
|
||||
lines.push(` ${theme.fg("accent", ext.trigger)}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private getKindBadge(kind: string): string {
|
||||
const kindColors: Record<string, string> = {
|
||||
skill: "accent",
|
||||
rule: "success",
|
||||
tool: "warning",
|
||||
mcp: "accent",
|
||||
prompt: "muted",
|
||||
hook: "warning",
|
||||
"context-file": "dim",
|
||||
instruction: "muted",
|
||||
"slash-command": "accent",
|
||||
};
|
||||
private getKindBadge(kind: string): string {
|
||||
const kindColors: Record<string, string> = {
|
||||
skill: "accent",
|
||||
rule: "success",
|
||||
tool: "warning",
|
||||
mcp: "accent",
|
||||
prompt: "muted",
|
||||
hook: "warning",
|
||||
"context-file": "dim",
|
||||
instruction: "muted",
|
||||
"slash-command": "accent",
|
||||
};
|
||||
|
||||
const color = kindColors[kind] || "muted";
|
||||
return theme.fg(color as any, kind);
|
||||
}
|
||||
const color = kindColors[kind] || "muted";
|
||||
return theme.fg(color as any, kind);
|
||||
}
|
||||
|
||||
private getStatusBadge(state: ExtensionState, reason?: string, shadowedBy?: string): string {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return theme.fg("success", "● Active");
|
||||
case "disabled":
|
||||
const reasonText = reason === "provider-disabled" ? "provider disabled" : reason === "item-disabled" ? "manually disabled" : "unknown";
|
||||
return theme.fg("dim", `○ Disabled (${reasonText})`);
|
||||
case "shadowed":
|
||||
return theme.fg("warning", `◐ Shadowed` + (shadowedBy ? ` by ${shadowedBy}` : ""));
|
||||
}
|
||||
}
|
||||
private getStatusBadge(state: ExtensionState, reason?: string, shadowedBy?: string): string {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return theme.fg("success", "● Active");
|
||||
case "disabled": {
|
||||
const reasonText =
|
||||
reason === "provider-disabled"
|
||||
? "provider disabled"
|
||||
: reason === "item-disabled"
|
||||
? "manually disabled"
|
||||
: "unknown";
|
||||
return theme.fg("dim", `○ Disabled (${reasonText})`);
|
||||
}
|
||||
case "shadowed":
|
||||
return theme.fg("warning", `◐ Shadowed${shadowedBy ? ` by ${shadowedBy}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
private shortenPath(path: string): string {
|
||||
const home = homedir();
|
||||
if (path.startsWith(home)) {
|
||||
return "~" + path.slice(home.length);
|
||||
}
|
||||
private shortenPath(path: string): string {
|
||||
const home = homedir();
|
||||
if (path.startsWith(home)) {
|
||||
return `~${path.slice(home.length)}`;
|
||||
}
|
||||
|
||||
// If path is very long, show just the last parts
|
||||
if (path.length > 40) {
|
||||
const parts = path.split("/");
|
||||
if (parts.length > 3) {
|
||||
return ".../" + parts.slice(-3).join("/");
|
||||
}
|
||||
}
|
||||
// If path is very long, show just the last parts
|
||||
if (path.length > 40) {
|
||||
const parts = path.split("/");
|
||||
if (parts.length > 3) {
|
||||
return `.../${parts.slice(-3).join("/")}`;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* SidebarTree - Hierarchical tree view with provider toggles.
|
||||
*
|
||||
* Displays providers grouped by source with toggle checkboxes.
|
||||
* Supports expand/collapse for categories.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Component,
|
||||
isArrowDown,
|
||||
isArrowUp,
|
||||
isEnter,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../theme/theme";
|
||||
import { flattenTree, toggleProvider } from "./state-manager";
|
||||
import type { FlatTreeItem, TreeNode } from "./types";
|
||||
|
||||
export interface SidebarTreeCallbacks {
|
||||
/** Called when provider is toggled */
|
||||
onProviderToggle?: (providerId: string, enabled: boolean) => void;
|
||||
/** Called when selection changes */
|
||||
onSelectionChange?: (nodeId: string) => void;
|
||||
/** Called when tree structure changes (collapse/expand) */
|
||||
onTreeChange?: () => void;
|
||||
}
|
||||
|
||||
export class SidebarTree implements Component {
|
||||
private tree: TreeNode[];
|
||||
private flatItems: FlatTreeItem[] = [];
|
||||
private selectedIndex = 0;
|
||||
private focused = false;
|
||||
private callbacks: SidebarTreeCallbacks;
|
||||
|
||||
constructor(tree: TreeNode[], callbacks: SidebarTreeCallbacks = {}) {
|
||||
this.tree = tree;
|
||||
this.callbacks = callbacks;
|
||||
this.flatItems = flattenTree(tree);
|
||||
}
|
||||
|
||||
setTree(tree: TreeNode[]): void {
|
||||
this.tree = tree;
|
||||
this.flatItems = flattenTree(tree);
|
||||
// Keep selection in bounds
|
||||
if (this.selectedIndex >= this.flatItems.length) {
|
||||
this.selectedIndex = Math.max(0, this.flatItems.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
setFocused(focused: boolean): void {
|
||||
this.focused = focused;
|
||||
}
|
||||
|
||||
getSelectedNode(): TreeNode | null {
|
||||
return this.flatItems[this.selectedIndex]?.node ?? null;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
lines.push(theme.bold(theme.fg("accent", "Providers")));
|
||||
lines.push("");
|
||||
|
||||
if (this.flatItems.length === 0) {
|
||||
lines.push(theme.fg("muted", "No providers"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.flatItems.length; i++) {
|
||||
const { node, depth } = this.flatItems[i];
|
||||
const isSelected = this.focused && i === this.selectedIndex;
|
||||
|
||||
// Build the line
|
||||
const indent = " ".repeat(depth);
|
||||
let checkbox = "";
|
||||
let arrow = "";
|
||||
let label = node.label;
|
||||
|
||||
// Provider nodes get checkboxes
|
||||
if (node.type === "provider") {
|
||||
checkbox = node.enabled
|
||||
? theme.fg("success", "[x]") + " "
|
||||
: theme.fg("muted", "[ ]") + " ";
|
||||
}
|
||||
|
||||
// Nodes with children get expand/collapse arrows
|
||||
if (node.children.length > 0) {
|
||||
arrow = node.collapsed ? "▸ " : "▾ ";
|
||||
} else if (node.type === "kind") {
|
||||
arrow = " ";
|
||||
}
|
||||
|
||||
// Add count if present
|
||||
if (node.count !== undefined && node.count > 0) {
|
||||
label += theme.fg("dim", ` (${node.count})`);
|
||||
}
|
||||
|
||||
// Style based on state
|
||||
if (isSelected) {
|
||||
label = theme.bg("selectedBg", theme.bold(theme.fg("text", label)));
|
||||
} else if (!node.enabled) {
|
||||
label = theme.fg("dim", label);
|
||||
}
|
||||
|
||||
const fullLine = `${indent}${checkbox}${arrow}${label}`;
|
||||
lines.push(truncateToWidth(fullLine, width));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.flatItems.length === 0) return;
|
||||
|
||||
// Navigation
|
||||
if (isArrowUp(data) || data === "k") {
|
||||
if (this.selectedIndex > 0) {
|
||||
this.selectedIndex--;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArrowDown(data) || data === "j") {
|
||||
if (this.selectedIndex < this.flatItems.length - 1) {
|
||||
this.selectedIndex++;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle or expand/collapse
|
||||
if (data === " ") {
|
||||
const item = this.flatItems[this.selectedIndex];
|
||||
if (!item) return;
|
||||
|
||||
if (item.node.type === "provider") {
|
||||
// Toggle provider
|
||||
const newEnabled = toggleProvider(item.node.id);
|
||||
item.node.enabled = newEnabled;
|
||||
// Also update children
|
||||
for (const child of item.node.children) {
|
||||
child.enabled = newEnabled;
|
||||
}
|
||||
this.callbacks.onProviderToggle?.(item.node.id, newEnabled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnter(data)) {
|
||||
const item = this.flatItems[this.selectedIndex];
|
||||
if (!item) return;
|
||||
|
||||
if (item.node.children.length > 0) {
|
||||
// Toggle collapse
|
||||
item.node.collapsed = !item.node.collapsed;
|
||||
this.flatItems = flattenTree(this.tree);
|
||||
this.callbacks.onTreeChange?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private notifySelectionChange(): void {
|
||||
const node = this.getSelectedNode();
|
||||
if (node) {
|
||||
this.callbacks.onSelectionChange?.(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+448
-369
@@ -3,13 +3,6 @@
|
||||
* Handles data loading, tree building, filtering, and toggle persistence.
|
||||
*/
|
||||
|
||||
import {
|
||||
disableProvider,
|
||||
enableProvider,
|
||||
getAllProvidersInfo,
|
||||
isProviderEnabled,
|
||||
loadSync,
|
||||
} from "../../../../discovery";
|
||||
import type { ContextFile } from "../../../../capability/context-file";
|
||||
import type { Hook } from "../../../../capability/hook";
|
||||
import type { MCPServer } from "../../../../capability/mcp";
|
||||
@@ -18,13 +11,21 @@ import type { Rule } from "../../../../capability/rule";
|
||||
import type { Skill } from "../../../../capability/skill";
|
||||
import type { CustomTool } from "../../../../capability/tool";
|
||||
import type { SourceMeta } from "../../../../capability/types";
|
||||
import {
|
||||
disableProvider,
|
||||
enableProvider,
|
||||
getAllProvidersInfo,
|
||||
isProviderEnabled,
|
||||
loadSync,
|
||||
} from "../../../../discovery";
|
||||
import type {
|
||||
DashboardState,
|
||||
Extension,
|
||||
ExtensionKind,
|
||||
ExtensionState,
|
||||
FlatTreeItem,
|
||||
TreeNode,
|
||||
DashboardState,
|
||||
Extension,
|
||||
ExtensionKind,
|
||||
ExtensionState,
|
||||
FlatTreeItem,
|
||||
ProviderTab,
|
||||
TreeNode,
|
||||
} from "./types";
|
||||
import { makeExtensionId, sourceFromMeta } from "./types";
|
||||
|
||||
@@ -32,244 +33,244 @@ import { makeExtensionId, sourceFromMeta } from "./types";
|
||||
* Settings manager interface for granular toggle persistence.
|
||||
*/
|
||||
export interface ExtensionSettingsManager {
|
||||
getDisabledExtensions(): string[];
|
||||
setDisabledExtensions(ids: string[]): void;
|
||||
getDisabledExtensions(): string[];
|
||||
setDisabledExtensions(ids: string[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all extensions from all capabilities.
|
||||
*/
|
||||
export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extension[] {
|
||||
const extensions: Extension[] = [];
|
||||
const disabledExtensions = new Set<string>(disabledIds ?? []);
|
||||
const extensions: Extension[] = [];
|
||||
const disabledExtensions = new Set<string>(disabledIds ?? []);
|
||||
|
||||
// Helper to convert capability items to extensions
|
||||
function addItems<T extends { name: string; path: string; _source: SourceMeta }>(
|
||||
items: T[],
|
||||
kind: ExtensionKind,
|
||||
opts?: {
|
||||
getDescription?: (item: T) => string | undefined;
|
||||
getTrigger?: (item: T) => string | undefined;
|
||||
getShadowedBy?: (item: T) => string | undefined;
|
||||
},
|
||||
): void {
|
||||
for (const item of items) {
|
||||
const id = makeExtensionId(kind, item.name);
|
||||
const isDisabled = disabledExtensions.has(id);
|
||||
const isShadowed = (item as { _shadowed?: boolean })._shadowed;
|
||||
const providerEnabled = isProviderEnabled(item._source.provider);
|
||||
// Helper to convert capability items to extensions
|
||||
function addItems<T extends { name: string; path: string; _source: SourceMeta }>(
|
||||
items: T[],
|
||||
kind: ExtensionKind,
|
||||
opts?: {
|
||||
getDescription?: (item: T) => string | undefined;
|
||||
getTrigger?: (item: T) => string | undefined;
|
||||
getShadowedBy?: (item: T) => string | undefined;
|
||||
},
|
||||
): void {
|
||||
for (const item of items) {
|
||||
const id = makeExtensionId(kind, item.name);
|
||||
const isDisabled = disabledExtensions.has(id);
|
||||
const isShadowed = (item as { _shadowed?: boolean })._shadowed;
|
||||
const providerEnabled = isProviderEnabled(item._source.provider);
|
||||
|
||||
let state: ExtensionState;
|
||||
let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
|
||||
let state: ExtensionState;
|
||||
let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
|
||||
|
||||
// Item-disabled takes precedence over shadowed
|
||||
if (isDisabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "item-disabled";
|
||||
} else if (isShadowed) {
|
||||
state = "shadowed";
|
||||
disabledReason = "shadowed";
|
||||
} else if (!providerEnabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "provider-disabled";
|
||||
} else {
|
||||
state = "active";
|
||||
}
|
||||
// Item-disabled takes precedence over shadowed
|
||||
if (isDisabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "item-disabled";
|
||||
} else if (isShadowed) {
|
||||
state = "shadowed";
|
||||
disabledReason = "shadowed";
|
||||
} else if (!providerEnabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "provider-disabled";
|
||||
} else {
|
||||
state = "active";
|
||||
}
|
||||
|
||||
extensions.push({
|
||||
id,
|
||||
kind,
|
||||
name: item.name,
|
||||
displayName: item.name,
|
||||
description: opts?.getDescription?.(item),
|
||||
trigger: opts?.getTrigger?.(item),
|
||||
path: item.path,
|
||||
source: sourceFromMeta(item._source),
|
||||
state,
|
||||
disabledReason,
|
||||
shadowedBy: opts?.getShadowedBy?.(item),
|
||||
raw: item,
|
||||
});
|
||||
}
|
||||
}
|
||||
extensions.push({
|
||||
id,
|
||||
kind,
|
||||
name: item.name,
|
||||
displayName: item.name,
|
||||
description: opts?.getDescription?.(item),
|
||||
trigger: opts?.getTrigger?.(item),
|
||||
path: item.path,
|
||||
source: sourceFromMeta(item._source),
|
||||
state,
|
||||
disabledReason,
|
||||
shadowedBy: opts?.getShadowedBy?.(item),
|
||||
raw: item,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const loadOpts = cwd ? { cwd } : {};
|
||||
const loadOpts = cwd ? { cwd } : {};
|
||||
|
||||
// Load skills
|
||||
try {
|
||||
const skills = loadSync<Skill>("skills", loadOpts);
|
||||
addItems(skills.all, "skill", {
|
||||
getDescription: (s) => s.frontmatter?.description,
|
||||
getTrigger: (s) => s.frontmatter?.globs?.join(", "),
|
||||
});
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
// Load skills
|
||||
try {
|
||||
const skills = loadSync<Skill>("skills", loadOpts);
|
||||
addItems(skills.all, "skill", {
|
||||
getDescription: (s) => s.frontmatter?.description,
|
||||
getTrigger: (s) => s.frontmatter?.globs?.join(", "),
|
||||
});
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
|
||||
// Load rules
|
||||
try {
|
||||
const rules = loadSync<Rule>("rules", loadOpts);
|
||||
addItems(rules.all, "rule", {
|
||||
getDescription: (r) => r.description,
|
||||
getTrigger: (r) => r.globs?.join(", ") || (r.alwaysApply ? "always" : undefined),
|
||||
});
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
// Load rules
|
||||
try {
|
||||
const rules = loadSync<Rule>("rules", loadOpts);
|
||||
addItems(rules.all, "rule", {
|
||||
getDescription: (r) => r.description,
|
||||
getTrigger: (r) => r.globs?.join(", ") || (r.alwaysApply ? "always" : undefined),
|
||||
});
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
|
||||
// Load custom tools
|
||||
try {
|
||||
const tools = loadSync<CustomTool>("tools", loadOpts);
|
||||
addItems(tools.all, "tool", {
|
||||
getDescription: (t) => t.description,
|
||||
});
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
// Load custom tools
|
||||
try {
|
||||
const tools = loadSync<CustomTool>("tools", loadOpts);
|
||||
addItems(tools.all, "tool", {
|
||||
getDescription: (t) => t.description,
|
||||
});
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
|
||||
// Load MCP servers
|
||||
try {
|
||||
const mcps = loadSync<MCPServer>("mcps", loadOpts);
|
||||
for (const server of mcps.all) {
|
||||
const id = makeExtensionId("mcp", server.name);
|
||||
const isDisabled = disabledExtensions.has(id);
|
||||
const isShadowed = (server as { _shadowed?: boolean })._shadowed;
|
||||
const providerEnabled = isProviderEnabled(server._source.provider);
|
||||
// Load MCP servers
|
||||
try {
|
||||
const mcps = loadSync<MCPServer>("mcps", loadOpts);
|
||||
for (const server of mcps.all) {
|
||||
const id = makeExtensionId("mcp", server.name);
|
||||
const isDisabled = disabledExtensions.has(id);
|
||||
const isShadowed = (server as { _shadowed?: boolean })._shadowed;
|
||||
const providerEnabled = isProviderEnabled(server._source.provider);
|
||||
|
||||
let state: ExtensionState;
|
||||
let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
|
||||
let state: ExtensionState;
|
||||
let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
|
||||
|
||||
if (isDisabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "item-disabled";
|
||||
} else if (isShadowed) {
|
||||
state = "shadowed";
|
||||
disabledReason = "shadowed";
|
||||
} else if (!providerEnabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "provider-disabled";
|
||||
} else {
|
||||
state = "active";
|
||||
}
|
||||
if (isDisabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "item-disabled";
|
||||
} else if (isShadowed) {
|
||||
state = "shadowed";
|
||||
disabledReason = "shadowed";
|
||||
} else if (!providerEnabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "provider-disabled";
|
||||
} else {
|
||||
state = "active";
|
||||
}
|
||||
|
||||
extensions.push({
|
||||
id,
|
||||
kind: "mcp",
|
||||
name: server.name,
|
||||
displayName: server.name,
|
||||
description: server.command || server.url,
|
||||
trigger: server.transport || "stdio",
|
||||
path: server._source.path,
|
||||
source: sourceFromMeta(server._source),
|
||||
state,
|
||||
disabledReason,
|
||||
raw: server,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
extensions.push({
|
||||
id,
|
||||
kind: "mcp",
|
||||
name: server.name,
|
||||
displayName: server.name,
|
||||
description: server.command || server.url,
|
||||
trigger: server.transport || "stdio",
|
||||
path: server._source.path,
|
||||
source: sourceFromMeta(server._source),
|
||||
state,
|
||||
disabledReason,
|
||||
raw: server,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
|
||||
// Load prompts
|
||||
try {
|
||||
const prompts = loadSync<Prompt>("prompts", loadOpts);
|
||||
addItems(prompts.all, "prompt", {
|
||||
getDescription: () => undefined,
|
||||
getTrigger: (p) => `/prompts:${p.name}`,
|
||||
});
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
// Load prompts
|
||||
try {
|
||||
const prompts = loadSync<Prompt>("prompts", loadOpts);
|
||||
addItems(prompts.all, "prompt", {
|
||||
getDescription: () => undefined,
|
||||
getTrigger: (p) => `/prompts:${p.name}`,
|
||||
});
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
|
||||
// Load hooks
|
||||
try {
|
||||
const hooks = loadSync<Hook>("hooks", loadOpts);
|
||||
for (const hook of hooks.all) {
|
||||
const id = makeExtensionId("hook", `${hook.type}:${hook.tool}:${hook.name}`);
|
||||
const isDisabled = disabledExtensions.has(id);
|
||||
const isShadowed = (hook as { _shadowed?: boolean })._shadowed;
|
||||
const providerEnabled = isProviderEnabled(hook._source.provider);
|
||||
// Load hooks
|
||||
try {
|
||||
const hooks = loadSync<Hook>("hooks", loadOpts);
|
||||
for (const hook of hooks.all) {
|
||||
const id = makeExtensionId("hook", `${hook.type}:${hook.tool}:${hook.name}`);
|
||||
const isDisabled = disabledExtensions.has(id);
|
||||
const isShadowed = (hook as { _shadowed?: boolean })._shadowed;
|
||||
const providerEnabled = isProviderEnabled(hook._source.provider);
|
||||
|
||||
let state: ExtensionState;
|
||||
let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
|
||||
let state: ExtensionState;
|
||||
let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
|
||||
|
||||
if (isDisabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "item-disabled";
|
||||
} else if (isShadowed) {
|
||||
state = "shadowed";
|
||||
disabledReason = "shadowed";
|
||||
} else if (!providerEnabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "provider-disabled";
|
||||
} else {
|
||||
state = "active";
|
||||
}
|
||||
if (isDisabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "item-disabled";
|
||||
} else if (isShadowed) {
|
||||
state = "shadowed";
|
||||
disabledReason = "shadowed";
|
||||
} else if (!providerEnabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "provider-disabled";
|
||||
} else {
|
||||
state = "active";
|
||||
}
|
||||
|
||||
extensions.push({
|
||||
id,
|
||||
kind: "hook",
|
||||
name: hook.name,
|
||||
displayName: hook.name,
|
||||
description: `${hook.type}-${hook.tool}`,
|
||||
trigger: `${hook.type}:${hook.tool}`,
|
||||
path: hook.path,
|
||||
source: sourceFromMeta(hook._source),
|
||||
state,
|
||||
disabledReason,
|
||||
raw: hook,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
extensions.push({
|
||||
id,
|
||||
kind: "hook",
|
||||
name: hook.name,
|
||||
displayName: hook.name,
|
||||
description: `${hook.type}-${hook.tool}`,
|
||||
trigger: `${hook.type}:${hook.tool}`,
|
||||
path: hook.path,
|
||||
source: sourceFromMeta(hook._source),
|
||||
state,
|
||||
disabledReason,
|
||||
raw: hook,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
|
||||
// Load context files
|
||||
try {
|
||||
const contextFiles = loadSync<ContextFile>("context-files", loadOpts);
|
||||
for (const file of contextFiles.all) {
|
||||
// Extract filename from path for display
|
||||
const name = file.path.split("/").pop() || file.path;
|
||||
const id = makeExtensionId("context-file", `${file.level}:${name}`);
|
||||
const isDisabled = disabledExtensions.has(id);
|
||||
const isShadowed = (file as { _shadowed?: boolean })._shadowed;
|
||||
const providerEnabled = isProviderEnabled(file._source.provider);
|
||||
// Load context files
|
||||
try {
|
||||
const contextFiles = loadSync<ContextFile>("context-files", loadOpts);
|
||||
for (const file of contextFiles.all) {
|
||||
// Extract filename from path for display
|
||||
const name = file.path.split("/").pop() || file.path;
|
||||
const id = makeExtensionId("context-file", `${file.level}:${name}`);
|
||||
const isDisabled = disabledExtensions.has(id);
|
||||
const isShadowed = (file as { _shadowed?: boolean })._shadowed;
|
||||
const providerEnabled = isProviderEnabled(file._source.provider);
|
||||
|
||||
let state: ExtensionState;
|
||||
let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
|
||||
let state: ExtensionState;
|
||||
let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
|
||||
|
||||
if (isDisabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "item-disabled";
|
||||
} else if (isShadowed) {
|
||||
state = "shadowed";
|
||||
disabledReason = "shadowed";
|
||||
} else if (!providerEnabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "provider-disabled";
|
||||
} else {
|
||||
state = "active";
|
||||
}
|
||||
if (isDisabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "item-disabled";
|
||||
} else if (isShadowed) {
|
||||
state = "shadowed";
|
||||
disabledReason = "shadowed";
|
||||
} else if (!providerEnabled) {
|
||||
state = "disabled";
|
||||
disabledReason = "provider-disabled";
|
||||
} else {
|
||||
state = "active";
|
||||
}
|
||||
|
||||
extensions.push({
|
||||
id,
|
||||
kind: "context-file",
|
||||
name,
|
||||
displayName: name,
|
||||
description: file.level === "user" ? "User-level context" : "Project-level context",
|
||||
trigger: file.level,
|
||||
path: file.path,
|
||||
source: sourceFromMeta(file._source),
|
||||
state,
|
||||
disabledReason,
|
||||
raw: file,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
extensions.push({
|
||||
id,
|
||||
kind: "context-file",
|
||||
name,
|
||||
displayName: name,
|
||||
description: file.level === "user" ? "User-level context" : "Project-level context",
|
||||
trigger: file.level,
|
||||
path: file.path,
|
||||
source: sourceFromMeta(file._source),
|
||||
state,
|
||||
disabledReason,
|
||||
raw: file,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Capability may not be registered
|
||||
}
|
||||
|
||||
return extensions;
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,203 +278,281 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
|
||||
* Groups by provider → kind.
|
||||
*/
|
||||
export function buildSidebarTree(extensions: Extension[]): TreeNode[] {
|
||||
const providers = getAllProvidersInfo();
|
||||
const tree: TreeNode[] = [];
|
||||
const providers = getAllProvidersInfo();
|
||||
const tree: TreeNode[] = [];
|
||||
|
||||
// Group extensions by provider and kind
|
||||
const byProvider = new Map<string, Map<ExtensionKind, Extension[]>>();
|
||||
// Group extensions by provider and kind
|
||||
const byProvider = new Map<string, Map<ExtensionKind, Extension[]>>();
|
||||
|
||||
for (const ext of extensions) {
|
||||
const providerId = ext.source.provider;
|
||||
if (!byProvider.has(providerId)) {
|
||||
byProvider.set(providerId, new Map());
|
||||
}
|
||||
const byKind = byProvider.get(providerId)!;
|
||||
if (!byKind.has(ext.kind)) {
|
||||
byKind.set(ext.kind, []);
|
||||
}
|
||||
byKind.get(ext.kind)!.push(ext);
|
||||
}
|
||||
for (const ext of extensions) {
|
||||
const providerId = ext.source.provider;
|
||||
if (!byProvider.has(providerId)) {
|
||||
byProvider.set(providerId, new Map());
|
||||
}
|
||||
const byKind = byProvider.get(providerId)!;
|
||||
if (!byKind.has(ext.kind)) {
|
||||
byKind.set(ext.kind, []);
|
||||
}
|
||||
byKind.get(ext.kind)!.push(ext);
|
||||
}
|
||||
|
||||
// Build tree nodes for each provider (show ALL providers, even if disabled/empty)
|
||||
for (const provider of providers) {
|
||||
// Skip the 'native' provider as it cannot be toggled
|
||||
if (provider.id === "native") continue;
|
||||
// Build tree nodes for each provider (show ALL providers, even if disabled/empty)
|
||||
for (const provider of providers) {
|
||||
// Skip the 'native' provider as it cannot be toggled
|
||||
if (provider.id === "native") continue;
|
||||
|
||||
const byKind = byProvider.get(provider.id);
|
||||
const kindNodes: TreeNode[] = [];
|
||||
let totalCount = 0;
|
||||
const byKind = byProvider.get(provider.id);
|
||||
const kindNodes: TreeNode[] = [];
|
||||
let totalCount = 0;
|
||||
|
||||
if (byKind && byKind.size > 0) {
|
||||
for (const [kind, exts] of byKind) {
|
||||
totalCount += exts.length;
|
||||
kindNodes.push({
|
||||
id: `${provider.id}:${kind}`,
|
||||
label: getKindDisplayName(kind),
|
||||
type: "kind",
|
||||
enabled: provider.enabled,
|
||||
collapsed: true,
|
||||
children: [],
|
||||
count: exts.length,
|
||||
});
|
||||
}
|
||||
if (byKind && byKind.size > 0) {
|
||||
for (const [kind, exts] of byKind) {
|
||||
totalCount += exts.length;
|
||||
kindNodes.push({
|
||||
id: `${provider.id}:${kind}`,
|
||||
label: getKindDisplayName(kind),
|
||||
type: "kind",
|
||||
enabled: provider.enabled,
|
||||
collapsed: true,
|
||||
children: [],
|
||||
count: exts.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort kind nodes by count (most items first)
|
||||
kindNodes.sort((a, b) => (b.count || 0) - (a.count || 0));
|
||||
}
|
||||
// Sort kind nodes by count (most items first)
|
||||
kindNodes.sort((a, b) => (b.count || 0) - (a.count || 0));
|
||||
}
|
||||
|
||||
tree.push({
|
||||
id: provider.id,
|
||||
label: provider.displayName,
|
||||
type: "provider",
|
||||
enabled: provider.enabled,
|
||||
collapsed: false,
|
||||
children: kindNodes,
|
||||
count: totalCount,
|
||||
});
|
||||
}
|
||||
tree.push({
|
||||
id: provider.id,
|
||||
label: provider.displayName,
|
||||
type: "provider",
|
||||
enabled: provider.enabled,
|
||||
collapsed: false,
|
||||
children: kindNodes,
|
||||
count: totalCount,
|
||||
});
|
||||
}
|
||||
|
||||
return tree;
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten tree for keyboard navigation.
|
||||
*/
|
||||
export function flattenTree(tree: TreeNode[]): FlatTreeItem[] {
|
||||
const flat: FlatTreeItem[] = [];
|
||||
let index = 0;
|
||||
const flat: FlatTreeItem[] = [];
|
||||
let index = 0;
|
||||
|
||||
function walk(node: TreeNode, depth: number): void {
|
||||
flat.push({ node, depth, index: index++ });
|
||||
if (!node.collapsed) {
|
||||
for (const child of node.children) {
|
||||
walk(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
function walk(node: TreeNode, depth: number): void {
|
||||
flat.push({ node, depth, index: index++ });
|
||||
if (!node.collapsed) {
|
||||
for (const child of node.children) {
|
||||
walk(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of tree) {
|
||||
walk(node, 0);
|
||||
}
|
||||
for (const node of tree) {
|
||||
walk(node, 0);
|
||||
}
|
||||
|
||||
return flat;
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply fuzzy filter to extensions.
|
||||
*/
|
||||
export function applyFilter(extensions: Extension[], query: string): Extension[] {
|
||||
if (!query.trim()) {
|
||||
return extensions;
|
||||
}
|
||||
if (!query.trim()) {
|
||||
return extensions;
|
||||
}
|
||||
|
||||
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) {
|
||||
return extensions;
|
||||
}
|
||||
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) {
|
||||
return extensions;
|
||||
}
|
||||
|
||||
return extensions.filter((ext) => {
|
||||
const searchable = [
|
||||
ext.name,
|
||||
ext.displayName,
|
||||
ext.description || "",
|
||||
ext.trigger || "",
|
||||
ext.source.providerName,
|
||||
ext.kind,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return extensions.filter((ext) => {
|
||||
const searchable = [
|
||||
ext.name,
|
||||
ext.displayName,
|
||||
ext.description || "",
|
||||
ext.trigger || "",
|
||||
ext.source.providerName,
|
||||
ext.kind,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return tokens.every((token) => searchable.includes(token));
|
||||
});
|
||||
return tokens.every((token) => searchable.includes(token));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for extension kind.
|
||||
*/
|
||||
function getKindDisplayName(kind: ExtensionKind): string {
|
||||
switch (kind) {
|
||||
case "skill":
|
||||
return "Skills";
|
||||
case "rule":
|
||||
return "Rules";
|
||||
case "tool":
|
||||
return "Tools";
|
||||
case "mcp":
|
||||
return "MCP Servers";
|
||||
case "prompt":
|
||||
return "Prompts";
|
||||
case "instruction":
|
||||
return "Instructions";
|
||||
case "context-file":
|
||||
return "Context Files";
|
||||
case "hook":
|
||||
return "Hooks";
|
||||
case "slash-command":
|
||||
return "Slash Commands";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
switch (kind) {
|
||||
case "skill":
|
||||
return "Skills";
|
||||
case "rule":
|
||||
return "Rules";
|
||||
case "tool":
|
||||
return "Tools";
|
||||
case "mcp":
|
||||
return "MCP Servers";
|
||||
case "prompt":
|
||||
return "Prompts";
|
||||
case "instruction":
|
||||
return "Instructions";
|
||||
case "context-file":
|
||||
return "Context Files";
|
||||
case "hook":
|
||||
return "Hooks";
|
||||
case "slash-command":
|
||||
return "Slash Commands";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build provider tabs from extensions.
|
||||
*/
|
||||
export function buildProviderTabs(extensions: Extension[]): ProviderTab[] {
|
||||
const providers = getAllProvidersInfo();
|
||||
const tabs: ProviderTab[] = [];
|
||||
|
||||
// Count extensions per provider
|
||||
const countByProvider = new Map<string, number>();
|
||||
for (const ext of extensions) {
|
||||
const count = countByProvider.get(ext.source.provider) ?? 0;
|
||||
countByProvider.set(ext.source.provider, count + 1);
|
||||
}
|
||||
|
||||
// ALL tab first
|
||||
tabs.push({
|
||||
id: "all",
|
||||
label: "ALL",
|
||||
enabled: true,
|
||||
count: extensions.length,
|
||||
});
|
||||
|
||||
// Provider tabs (skip native)
|
||||
for (const provider of providers) {
|
||||
if (provider.id === "native") continue;
|
||||
const count = countByProvider.get(provider.id) ?? 0;
|
||||
tabs.push({
|
||||
id: provider.id,
|
||||
label: provider.displayName,
|
||||
enabled: provider.enabled,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: ALL first, then enabled by count, then disabled by count, then empty
|
||||
tabs.sort((a, b) => {
|
||||
if (a.id === "all") return -1;
|
||||
if (b.id === "all") return 1;
|
||||
|
||||
// Categorize: 0 = enabled with content, 1 = disabled, 2 = empty+enabled
|
||||
const category = (t: ProviderTab) => {
|
||||
if (t.count === 0 && t.enabled) return 2; // empty
|
||||
if (!t.enabled) return 1; // disabled
|
||||
return 0; // enabled with content
|
||||
};
|
||||
|
||||
const aCat = category(a);
|
||||
const bCat = category(b);
|
||||
if (aCat !== bCat) return aCat - bCat;
|
||||
|
||||
// Within same category, sort by count descending
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter extensions by provider tab.
|
||||
*/
|
||||
export function filterByProvider(extensions: Extension[], providerId: string): Extension[] {
|
||||
if (providerId === "all") {
|
||||
return extensions;
|
||||
}
|
||||
return extensions.filter((ext) => ext.source.provider === providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create initial dashboard state.
|
||||
*/
|
||||
export function createInitialState(cwd?: string, disabledIds?: string[]): DashboardState {
|
||||
const extensions = loadAllExtensions(cwd, disabledIds);
|
||||
const sidebarTree = buildSidebarTree(extensions);
|
||||
const flatTree = flattenTree(sidebarTree);
|
||||
const extensions = loadAllExtensions(cwd, disabledIds);
|
||||
const tabs = buildProviderTabs(extensions);
|
||||
const tabFiltered = extensions; // "all" tab by default
|
||||
const searchFiltered = tabFiltered;
|
||||
|
||||
return {
|
||||
focusPane: "sidebar",
|
||||
sidebarTree,
|
||||
flatTree,
|
||||
sidebarIndex: 0,
|
||||
extensions,
|
||||
filtered: extensions,
|
||||
searchQuery: "",
|
||||
mainIndex: 0,
|
||||
scrollOffset: 0,
|
||||
selected: extensions[0] ?? null,
|
||||
};
|
||||
return {
|
||||
tabs,
|
||||
activeTabIndex: 0,
|
||||
extensions,
|
||||
tabFiltered,
|
||||
searchFiltered,
|
||||
searchQuery: "",
|
||||
listIndex: 0,
|
||||
scrollOffset: 0,
|
||||
selected: searchFiltered[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle provider enabled state.
|
||||
*/
|
||||
export function toggleProvider(providerId: string): boolean {
|
||||
if (isProviderEnabled(providerId)) {
|
||||
disableProvider(providerId);
|
||||
return false;
|
||||
} else {
|
||||
enableProvider(providerId);
|
||||
return true;
|
||||
}
|
||||
if (isProviderEnabled(providerId)) {
|
||||
disableProvider(providerId);
|
||||
return false;
|
||||
} else {
|
||||
enableProvider(providerId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state after toggle.
|
||||
*/
|
||||
export function refreshState(state: DashboardState, cwd?: string, disabledIds?: string[]): DashboardState {
|
||||
const extensions = loadAllExtensions(cwd, disabledIds);
|
||||
const sidebarTree = buildSidebarTree(extensions);
|
||||
const flatTree = flattenTree(sidebarTree);
|
||||
const filtered = applyFilter(extensions, state.searchQuery);
|
||||
const extensions = loadAllExtensions(cwd, disabledIds);
|
||||
const tabs = buildProviderTabs(extensions);
|
||||
|
||||
// Try to preserve selection
|
||||
const selectedId = state.selected?.id;
|
||||
let selected = selectedId ? filtered.find((e) => e.id === selectedId) : null;
|
||||
if (!selected && filtered.length > 0) {
|
||||
selected = filtered[Math.min(state.mainIndex, filtered.length - 1)];
|
||||
}
|
||||
// Get current provider from tabs
|
||||
const activeTab = state.tabs[state.activeTabIndex];
|
||||
const providerId = activeTab?.id ?? "all";
|
||||
|
||||
return {
|
||||
...state,
|
||||
sidebarTree,
|
||||
flatTree,
|
||||
extensions,
|
||||
filtered,
|
||||
selected: selected ?? null,
|
||||
mainIndex: selected ? filtered.indexOf(selected) : 0,
|
||||
};
|
||||
// Re-apply filters
|
||||
const tabFiltered = filterByProvider(extensions, providerId);
|
||||
const searchFiltered = applyFilter(tabFiltered, state.searchQuery);
|
||||
|
||||
// Find new index for current provider (tabs may have reordered)
|
||||
const newActiveTabIndex = tabs.findIndex((t) => t.id === providerId);
|
||||
const activeTabIndex = newActiveTabIndex >= 0 ? newActiveTabIndex : 0;
|
||||
|
||||
// Try to preserve selection
|
||||
const selectedId = state.selected?.id;
|
||||
let selected = selectedId ? searchFiltered.find((e) => e.id === selectedId) : null;
|
||||
if (!selected && searchFiltered.length > 0) {
|
||||
selected = searchFiltered[Math.min(state.listIndex, searchFiltered.length - 1)];
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
tabs,
|
||||
activeTabIndex,
|
||||
extensions,
|
||||
tabFiltered,
|
||||
searchFiltered,
|
||||
selected: selected ?? null,
|
||||
listIndex: selected ? searchFiltered.indexOf(selected) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,32 +98,44 @@ export interface FlatTreeItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus pane in the dashboard.
|
||||
* Focus region in the tabbed dashboard.
|
||||
*/
|
||||
export type FocusPane = "sidebar" | "main" | "inspector";
|
||||
export type FocusRegion = "tabs" | "list";
|
||||
|
||||
/**
|
||||
* Dashboard state.
|
||||
* Provider tab representation.
|
||||
*/
|
||||
export interface ProviderTab {
|
||||
/** Provider ID (or "all" for the ALL tab) */
|
||||
id: string;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Whether provider is enabled (always true for "all") */
|
||||
enabled: boolean;
|
||||
/** Extension count for this provider */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabbed dashboard state.
|
||||
*/
|
||||
export interface DashboardState {
|
||||
/** Currently focused pane */
|
||||
focusPane: FocusPane;
|
||||
|
||||
/** Sidebar tree nodes */
|
||||
sidebarTree: TreeNode[];
|
||||
/** Flattened tree for navigation */
|
||||
flatTree: FlatTreeItem[];
|
||||
/** Selected index in flattened tree */
|
||||
sidebarIndex: number;
|
||||
/** Provider tabs */
|
||||
tabs: ProviderTab[];
|
||||
/** Active tab index */
|
||||
activeTabIndex: number;
|
||||
|
||||
/** All extensions (unfiltered) */
|
||||
extensions: Extension[];
|
||||
/** Filtered extensions (after search) */
|
||||
filtered: Extension[];
|
||||
/** Extensions filtered by active tab */
|
||||
tabFiltered: Extension[];
|
||||
/** Extensions filtered by search (applied after tab filter) */
|
||||
searchFiltered: Extension[];
|
||||
/** Current search query */
|
||||
searchQuery: string;
|
||||
|
||||
/** Selected index in main list */
|
||||
mainIndex: number;
|
||||
listIndex: number;
|
||||
/** Scroll offset for main list */
|
||||
scrollOffset: number;
|
||||
|
||||
@@ -131,6 +143,11 @@ export interface DashboardState {
|
||||
selected: Extension | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use FocusRegion instead
|
||||
*/
|
||||
export type FocusPane = "sidebar" | "main" | "inspector";
|
||||
|
||||
/**
|
||||
* Callbacks from dashboard to parent.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { basename } from "node:path";
|
||||
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@oh-my-pi/pi-ai";
|
||||
import type { SlashCommand } from "@oh-my-pi/pi-tui";
|
||||
@@ -24,28 +23,15 @@ import {
|
||||
TUI,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { contextFileCapability } from "../../capability/context-file";
|
||||
import { instructionCapability } from "../../capability/instruction";
|
||||
import { promptCapability } from "../../capability/prompt";
|
||||
import { ruleCapability } from "../../capability/rule";
|
||||
import { getAuthPath, getDebugLogPath } from "../../config";
|
||||
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
|
||||
import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index";
|
||||
import type { HookUIContext } from "../../core/hooks/index";
|
||||
import { createCompactionSummaryMessage } from "../../core/messages";
|
||||
import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager";
|
||||
import { loadSkills } from "../../core/skills";
|
||||
import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator";
|
||||
import type { TruncationResult } from "../../core/tools/truncate";
|
||||
import {
|
||||
type ContextFile,
|
||||
disableProvider,
|
||||
enableProvider,
|
||||
type Instruction,
|
||||
loadSync,
|
||||
type Prompt,
|
||||
type Rule,
|
||||
} from "../../discovery";
|
||||
import { disableProvider, enableProvider } from "../../discovery";
|
||||
import { getChangelogPath, parseChangelog } from "../../utils/changelog";
|
||||
import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
|
||||
import { ArminComponent } from "./components/armin";
|
||||
@@ -56,6 +42,7 @@ import { BranchSummaryMessageComponent } from "./components/branch-summary-messa
|
||||
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message";
|
||||
import { CustomEditor } from "./components/custom-editor";
|
||||
import { DynamicBorder } from "./components/dynamic-border";
|
||||
import { ExtensionDashboard } from "./components/extensions";
|
||||
import { FooterComponent } from "./components/footer";
|
||||
import { HookEditorComponent } from "./components/hook-editor";
|
||||
import { HookInputComponent } from "./components/hook-input";
|
||||
@@ -65,7 +52,6 @@ import { ModelSelectorComponent } from "./components/model-selector";
|
||||
import { OAuthSelectorComponent } from "./components/oauth-selector";
|
||||
import { SessionSelectorComponent } from "./components/session-selector";
|
||||
import { SettingsSelectorComponent } from "./components/settings-selector";
|
||||
import { ExtensionDashboard } from "./components/extensions";
|
||||
import { ToolExecutionComponent } from "./components/tool-execution";
|
||||
import { TreeSelectorComponent } from "./components/tree-selector";
|
||||
import { TtsrNotificationComponent } from "./components/ttsr-notification";
|
||||
|
||||
@@ -7,9 +7,35 @@
|
||||
*/
|
||||
|
||||
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { APP_NAME, VERSION } from "../config";
|
||||
import type { AgentSession } from "../core/agent-session";
|
||||
import { logger } from "../core/logger";
|
||||
|
||||
/**
|
||||
* Print session header to stderr (text mode only).
|
||||
*/
|
||||
function printHeader(session: AgentSession): void {
|
||||
const model = session.model;
|
||||
const lines = [
|
||||
`${APP_NAME} v${VERSION}`,
|
||||
"--------",
|
||||
`workdir: ${process.cwd()}`,
|
||||
`model: ${model?.id ?? "unknown"}`,
|
||||
`provider: ${model?.provider ?? "unknown"}`,
|
||||
`thinking: ${session.thinkingLevel}`,
|
||||
`session: ${session.sessionId}`,
|
||||
"--------",
|
||||
];
|
||||
console.error(lines.join("\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Print session footer to stderr (text mode only).
|
||||
*/
|
||||
function printFooter(): void {
|
||||
console.error("--------");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run in print (single-shot) mode.
|
||||
* Sends prompts to the agent and outputs the result.
|
||||
@@ -27,6 +53,11 @@ export async function runPrintMode(
|
||||
initialMessage?: string,
|
||||
initialImages?: ImageContent[],
|
||||
): Promise<void> {
|
||||
// Print header to stderr (text mode only)
|
||||
if (mode === "text") {
|
||||
printHeader(session);
|
||||
}
|
||||
|
||||
// Hook runner already has no-op UI context by default (set in main.ts)
|
||||
// Set up hooks for print mode (no UI)
|
||||
const hookRunner = session.hookRunner;
|
||||
@@ -116,6 +147,9 @@ export async function runPrintMode(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print footer to stderr
|
||||
printFooter();
|
||||
}
|
||||
|
||||
// Ensure stdout is fully flushed before returning
|
||||
|
||||
Reference in New Issue
Block a user