diff --git a/assets/discovery.webp b/assets/discovery.webp index 9052684c2..89f1675c1 100644 Binary files a/assets/discovery.webp and b/assets/discovery.webp differ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1bd0a156d..09ec94df6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index c594434b7..a2c40f4be 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -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. diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index b1f00fd59..0a9522f50 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/extension-dashboard.ts b/packages/coding-agent/src/modes/interactive/components/extensions/extension-dashboard.ts index 83cad1e42..a486952ce 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/extension-dashboard.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/extension-dashboard.ts @@ -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?.(); + } } diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/extension-list.ts b/packages/coding-agent/src/modes/interactive/components/extensions/extension-list.ts index d01a88a0a..d5be4e5d0 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/extension-list.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/extension-list.ts @@ -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(); + // 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(); + 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); + } } diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/holy-grail-layout.ts b/packages/coding-agent/src/modes/interactive/components/extensions/holy-grail-layout.ts deleted file mode 100644 index e0550870b..000000000 --- a/packages/coding-agent/src/modes/interactive/components/extensions/holy-grail-layout.ts +++ /dev/null @@ -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) + "╯") - ); - } -} diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/index.ts b/packages/coding-agent/src/modes/interactive/components/extensions/index.ts index ec8844edb..efd441146 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/index.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/inspector-panel.ts b/packages/coding-agent/src/modes/interactive/components/extensions/inspector-panel.ts index 9772ef7cf..e12c5ea69 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/inspector-panel.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/inspector-panel.ts @@ -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 = { - 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 = { + 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; + } } diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/sidebar-tree.ts b/packages/coding-agent/src/modes/interactive/components/extensions/sidebar-tree.ts deleted file mode 100644 index 64a9c1019..000000000 --- a/packages/coding-agent/src/modes/interactive/components/extensions/sidebar-tree.ts +++ /dev/null @@ -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); - } - } -} diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts b/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts index acb8c2f85..341130c9a 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts @@ -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(disabledIds ?? []); + const extensions: Extension[] = []; + const disabledExtensions = new Set(disabledIds ?? []); - // Helper to convert capability items to extensions - function addItems( - 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( + 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("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("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("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("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("tools", loadOpts); - addItems(tools.all, "tool", { - getDescription: (t) => t.description, - }); - } catch { - // Capability may not be registered - } + // Load custom tools + try { + const tools = loadSync("tools", loadOpts); + addItems(tools.all, "tool", { + getDescription: (t) => t.description, + }); + } catch { + // Capability may not be registered + } - // Load MCP servers - try { - const mcps = loadSync("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("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("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("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("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("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("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("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>(); + // Group extensions by provider and kind + const byProvider = new Map>(); - 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(); + 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, + }; } diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/types.ts b/packages/coding-agent/src/modes/interactive/components/extensions/types.ts index 7fe2bcd30..92b102b5b 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/types.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/types.ts @@ -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. */ diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9f96aa334..0650e60bd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 0d5c6e652..a26cc474c 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -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 { + // 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