feat(coding-agent): replaced 3-column layout with tabbed interface

- Redesigned Extension Control Center from 3-column layout to tabbed interface with provider tabs.
- Replaced sidebar tree navigation with horizontal tab bar supporting TAB/Shift+TAB cycling.
- Added session header/footer output in text mode showing version, model, provider, and thinking level.
- Removed HolyGrailLayout and SidebarTree components in favor of TwoColumnBody with tabs.
This commit is contained in:
can1357
2026-01-03 21:53:01 +01:00
parent b7cf5a0296
commit 44ccf7c1cb
14 changed files with 1446 additions and 1629 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 62 KiB

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