diff --git a/src/cli.ts b/src/cli.ts index 321ce2d..4e1ec20 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { FileConfigProvider } from './config/file-provider.js'; import { FileStorageAdapter } from './storage/file.js'; import type { SystemConfig, ToolDiscoveryConfig } from './types/config.js'; +import { DEFAULT_CONNECTION_POOL } from './types/service.js'; import type { TagFilter } from './types/tool.js'; import { getPackageVersion } from './utils/package-version.js'; import { silenceStderrForShutdown } from './utils/silence-stderr-shutdown.js'; @@ -292,11 +293,7 @@ function initializeConfigDir(configDir: string): void { logLevel: 'INFO', configDir, mcpServers: {}, - connectionPool: { - maxConnections: 5, - idleTimeout: 60000, - connectionTimeout: 30000, - }, + connectionPool: { ...DEFAULT_CONNECTION_POOL }, healthCheck: { enabled: true, interval: 30000, diff --git a/src/config/file-provider.ts b/src/config/file-provider.ts index bb455f5..3254e00 100644 --- a/src/config/file-provider.ts +++ b/src/config/file-provider.ts @@ -14,6 +14,7 @@ import type { ValidationError, } from '../types/config.js'; import type { ServiceDefinition } from '../types/service.js'; +import { DEFAULT_CONNECTION_POOL } from '../types/service.js'; import type { StorageAdapter } from '../types/storage.js'; import * as log from '../utils/logger.js'; @@ -683,11 +684,7 @@ export class FileConfigProvider implements ConfigProvider { logLevel: 'INFO', configDir: this.configDir, mcpServers: {}, - connectionPool: { - maxConnections: 5, - idleTimeout: 60000, - connectionTimeout: 30000, - }, + connectionPool: { ...DEFAULT_CONNECTION_POOL }, healthCheck: { enabled: true, interval: 30000, diff --git a/src/tui/app-optimized.tsx b/src/tui/app-optimized.tsx index 56e91a0..f434524 100644 --- a/src/tui/app-optimized.tsx +++ b/src/tui/app-optimized.tsx @@ -73,6 +73,13 @@ export const TuiAppOptimized: React.FC = ({ const terminalHeight = stdout?.rows || 24; + // Vertical space consumed by chrome above the content area: + // Header (double border title 3 rows + stats 1 row + margin 1 = 5). + // StatusBar adds a round-bordered box (border 2 + 1 line + margin 1 = 4) while a message is visible. + const OUTER_CHROME_LINES = 5; + const STATUS_BAR_LINES = statusMessage ? 4 : 0; + const contentHeight = Math.max(8, terminalHeight - OUTER_CHROME_LINES - STATUS_BAR_LINES); + // Calculate global tool statistics from in-memory cache const globalToolStats = React.useMemo(() => { let total = 0; @@ -469,12 +476,11 @@ export const TuiAppOptimized: React.FC = ({ process.exit(0); } - // Tools view + // Tools view: ServiceTools handles its own input (search Esc layering, + // tool navigation/toggle). Do not intercept Esc here — that would bypass + // ServiceTools' layered Esc (exit search → clear filter → back) and jump + // straight to the service list. if (view === 'tools') { - if (key.escape) { - setView('list'); - setRefreshKey(k => k + 1); - } return; } @@ -632,11 +638,15 @@ export const TuiAppOptimized: React.FC = ({ {view === 'tools' && editingService && ( setView('list')} + onBack={() => { + setView('list'); + setRefreshKey(k => k + 1); + }} onToggleTool={handleToggleTool} onBatchToggleTools={handleBatchToggleTools} toolStates={editingService.toolStates || {}} onToolsDiscovered={handleToolsDiscovered} + terminalHeight={contentHeight} /> )} diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 15ed299..25d1578 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -65,6 +65,13 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c const terminalHeight = stdout?.rows || 24; + // Vertical space consumed by chrome above the content area: + // app header box (border 2 + padding 2 + 1 line + margin 1 = 6) + info bar (3 lines + margin 1 = 4). + // A transient status message adds a single-bordered box (6 lines). + const OUTER_CHROME_LINES = 10; + const STATUS_MESSAGE_LINES = statusMessage ? 6 : 0; + const contentHeight = Math.max(8, terminalHeight - OUTER_CHROME_LINES - STATUS_MESSAGE_LINES); + // Calculate global tool statistics const globalToolStats = React.useMemo(() => { let totalTools = 0; @@ -366,12 +373,8 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c return; } - // Tools view - handle back + // Tools view: ServiceTools handles its own input including Esc layering. if (view === 'tools') { - if (key.escape) { - setView('list'); - setRefreshKey(k => k + 1); - } return; } @@ -544,11 +547,15 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c {view === 'tools' && editingService && ( setView('list')} + onBack={() => { + setView('list'); + setRefreshKey(k => k + 1); + }} onToggleTool={handleToggleTool} onBatchToggleTools={handleBatchToggleTools} toolStates={editingService.toolStates || {}} onToolsDiscovered={handleToolsDiscovered} + terminalHeight={contentHeight} /> )} diff --git a/src/tui/components/ServiceForm.tsx b/src/tui/components/ServiceForm.tsx index 18aecc5..b5427c3 100644 --- a/src/tui/components/ServiceForm.tsx +++ b/src/tui/components/ServiceForm.tsx @@ -12,6 +12,7 @@ import { Box, Text, useInput, useStdout } from 'ink'; import TextInput from 'ink-text-input'; import SelectInput from 'ink-select-input'; import type { ServiceDefinition, TransportType } from '../../types/service.js'; +import { fieldHelp, fieldPlaceholder } from './service-field-config.js'; export interface ServiceFormProps { /** Existing service to edit (undefined for new service) */ @@ -138,26 +139,7 @@ function getFieldLabel(field: FormField): string { * Get field help text */ function getFieldHelp(field: FormField): string { - const help: Record = { - name: 'Unique identifier for this service', - transport: 'Protocol used to communicate with the service', - command: 'Command to start the MCP server (e.g., npx, node, python)', - url: 'HTTP(S) URL of the MCP server', - args: 'Command-line arguments (e.g., -y, @modelcontextprotocol/server-filesystem, /tmp)', - env: 'Environment variables to pass to the process (e.g., NODE_ENV=production, DEBUG=true).', - headers: 'Custom HTTP headers (e.g., Authorization: Bearer token, Content-Type: application/json).', - tags: 'Labels for categorization and filtering (e.g., local, storage, api)', - enabled: 'Whether this service should be active', - maxConnections: 'Maximum number of concurrent connections (default: 5)', - idleTimeout: 'Time before idle connections are closed (default: 60000)', - connectionTimeout: 'Maximum time to wait for connection (default: 30000)', - triggerHintsStart: 'Reason the LLM should call this service at conversation start (e.g., "recall role memory").', - triggerHintsEnd: 'Reason the LLM should call this service before conversation ends (e.g., "persist new memory").', - triggerHintsPhrases: 'Extra trigger phrases the LLM should treat as a search signal (e.g., "我是X, switch role").', - confirm: 'Review and save the configuration', - quickMode: 'Use quick mode with defaults for advanced options', - }; - return help[field]; + return fieldHelp[field]; } /** @@ -473,6 +455,7 @@ export const ServiceForm: React.FC = ({ // Render text input field const renderTextInput = (field: FormField) => { + const placeholder = fieldPlaceholder[field]; return ( = ({ setFormData({ ...formData, [field]: value }); }} onSubmit={() => goToNextField()} + {...(placeholder ? { placeholder } : {})} /> ); }; diff --git a/src/tui/components/ServiceFormUnified.tsx b/src/tui/components/ServiceFormUnified.tsx index dfcb651..027e4c2 100644 --- a/src/tui/components/ServiceFormUnified.tsx +++ b/src/tui/components/ServiceFormUnified.tsx @@ -12,6 +12,7 @@ import { Box, Text, useInput, useStdout } from 'ink'; import TextInput from 'ink-text-input'; import SelectInput from 'ink-select-input'; import type { ServiceDefinition, TransportType } from '../../types/service.js'; +import { fieldHelp, fieldPlaceholder } from './service-field-config.js'; export interface ServiceFormUnifiedProps { /** Existing service to edit (undefined for new service) */ @@ -91,14 +92,14 @@ function getFieldConfigs(transport: TransportType): FieldConfig[] { { field: 'name', label: 'Service Name', - help: 'Unique identifier (letters, numbers, hyphens, underscores)', + help: fieldHelp.name, required: true, type: 'text', }, { field: 'transport', label: 'Transport Type', - help: 'Protocol for communication', + help: fieldHelp.transport, required: true, type: 'select', }, @@ -108,7 +109,7 @@ function getFieldConfigs(transport: TransportType): FieldConfig[] { configs.push({ field: 'command', label: 'Command', - help: 'Command to start the MCP server (e.g., npx, node, python)', + help: fieldHelp.command, required: true, type: 'text', dependsOn: { field: 'transport', value: 'stdio' }, @@ -116,7 +117,7 @@ function getFieldConfigs(transport: TransportType): FieldConfig[] { configs.push({ field: 'args', label: 'Arguments', - help: 'Command-line arguments (comma-separated, optional)', + help: fieldHelp.args, required: false, type: 'text', dependsOn: { field: 'transport', value: 'stdio' }, @@ -124,7 +125,7 @@ function getFieldConfigs(transport: TransportType): FieldConfig[] { configs.push({ field: 'env', label: 'Environment Variables', - help: 'Environment variables to pass to the process (KEY=VALUE pairs, comma-separated, optional).', + help: fieldHelp.env, required: false, type: 'text', dependsOn: { field: 'transport', value: 'stdio' }, @@ -133,7 +134,7 @@ function getFieldConfigs(transport: TransportType): FieldConfig[] { configs.push({ field: 'url', label: 'URL', - help: 'HTTP(S) URL of the MCP server', + help: fieldHelp.url, required: true, type: 'text', dependsOn: { field: 'transport', value: transport }, @@ -141,7 +142,7 @@ function getFieldConfigs(transport: TransportType): FieldConfig[] { configs.push({ field: 'headers', label: 'Headers', - help: 'Custom HTTP headers (Key: Value pairs, comma-separated, optional).', + help: fieldHelp.headers, required: false, type: 'text', dependsOn: { field: 'transport', value: transport }, @@ -152,56 +153,56 @@ function getFieldConfigs(transport: TransportType): FieldConfig[] { { field: 'tags', label: 'Tags', - help: 'Labels for categorization (comma-separated, optional)', + help: fieldHelp.tags, required: false, type: 'text', }, { field: 'enabled', label: 'Enabled', - help: 'Whether this service should be active', + help: fieldHelp.enabled, required: false, type: 'select', }, { field: 'maxConnections', label: 'Max Connections', - help: 'Maximum concurrent connections (1-100, default: 5)', + help: fieldHelp.maxConnections, required: false, type: 'text', }, { field: 'idleTimeout', label: 'Idle Timeout', - help: 'Time before idle connections close in ms (min: 1000, default: 60000)', + help: fieldHelp.idleTimeout, required: false, type: 'text', }, { field: 'connectionTimeout', label: 'Connection Timeout', - help: 'Maximum time to wait for connection in ms (min: 1000, default: 30000)', + help: fieldHelp.connectionTimeout, required: false, type: 'text', }, { field: 'triggerHintsStart', label: 'Trigger: On Session Start', - help: 'Reason for the LLM to call this service at conversation start (optional, e.g. "recall role memory").', + help: fieldHelp.triggerHintsStart, required: false, type: 'text', }, { field: 'triggerHintsEnd', label: 'Trigger: On Session End', - help: 'Reason to call before the conversation ends (optional, e.g. "persist new memory").', + help: fieldHelp.triggerHintsEnd, required: false, type: 'text', }, { field: 'triggerHintsPhrases', label: 'Trigger Phrases', - help: 'Extra phrases that should make the LLM search this service (comma-separated, optional).', + help: fieldHelp.triggerHintsPhrases, required: false, type: 'text', } @@ -615,6 +616,7 @@ export const ServiceFormUnified: React.FC = ({ // Render text input const renderTextInput = (field: FormField) => { + const placeholder = fieldPlaceholder[field]; return ( = ({ setTouched(prev => new Set(prev).add(field)); goToNextField(); }} + {...(placeholder ? { placeholder } : {})} /> ); }; diff --git a/src/tui/components/ServiceJsonEditor.tsx b/src/tui/components/ServiceJsonEditor.tsx index 02a7fed..f1c18a2 100644 --- a/src/tui/components/ServiceJsonEditor.tsx +++ b/src/tui/components/ServiceJsonEditor.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; import type { ServiceDefinition } from '../../types/service.js'; +import { DEFAULT_CONNECTION_POOL } from '../../types/service.js'; export interface ServiceJsonEditorProps { /** Initial JSON content (for editing existing service) */ @@ -191,9 +192,9 @@ function getExampleJson(): string { "tags": ["local", "storage"], "enabled": true, "connectionPool": { - "maxConnections": 5, - "idleTimeout": 60000, - "connectionTimeout": 30000 + "maxConnections": DEFAULT_CONNECTION_POOL.maxConnections, + "idleTimeout": DEFAULT_CONNECTION_POOL.idleTimeout, + "connectionTimeout": DEFAULT_CONNECTION_POOL.connectionTimeout } }, "github": { @@ -205,6 +206,21 @@ function getExampleJson(): string { }, "tags": ["remote", "api"], "enabled": true + }, + "remote-api": { + "transport": "http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer token", + "Content-Type": "application/json" + }, + "tags": ["remote", "api"], + "enabled": true, + "connectionPool": { + "maxConnections": DEFAULT_CONNECTION_POOL.maxConnections, + "idleTimeout": DEFAULT_CONNECTION_POOL.idleTimeout, + "connectionTimeout": DEFAULT_CONNECTION_POOL.connectionTimeout + } } }, null, 2); } diff --git a/src/tui/components/ServiceTools.tsx b/src/tui/components/ServiceTools.tsx index 4a6b2dd..116ea32 100644 --- a/src/tui/components/ServiceTools.tsx +++ b/src/tui/components/ServiceTools.tsx @@ -4,7 +4,7 @@ * Displays tools for a selected service and allows enabling/disabling them. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Box, Text, useInput, useStdout } from 'ink'; import { fetchServiceTools } from '../discovery-worker.js'; import type { ServiceDefinition } from '../../types/service.js'; @@ -16,6 +16,11 @@ export interface ServiceToolsProps { onBatchToggleTools?: (toolStates: Record) => void; toolStates?: Record; onToolsDiscovered?: (toolCount: number) => void; + /** + * Actual vertical space available to this component, as computed by the host + * (e.g. app.tsx minus its own header/footer). Falls back to terminal height. + */ + terminalHeight?: number; } interface BasicTool { @@ -41,6 +46,7 @@ export const ServiceTools: React.FC = ({ onBatchToggleTools, toolStates = {}, onToolsDiscovered, + terminalHeight: terminalHeightProp, }) => { const { stdout } = useStdout(); const [tools, setTools] = useState([]); @@ -48,13 +54,22 @@ export const ServiceTools: React.FC = ({ const [error, setError] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); + const [toolScrollOffset, setToolScrollOffset] = useState(0); + const [searchQuery, setSearchQuery] = useState(''); + const [searchMode, setSearchMode] = useState(false); - const terminalHeight = stdout?.rows || 24; + const filteredTools = useMemo(() => { + if (!searchQuery) return tools; + const q = searchQuery.toLowerCase(); + return tools.filter(t => t.name.toLowerCase().includes(q)); + }, [tools, searchQuery]); + + const terminalHeight = terminalHeightProp ?? (stdout?.rows || 24); const terminalWidth = stdout?.columns || 80; const HEADER_LINES = 4; const FOOTER_LINES = 4; // Increased from 3 to 4 to account for quick actions section const AVAILABLE_LINES = Math.max(1, terminalHeight - HEADER_LINES - FOOTER_LINES); - const VISIBLE_TOOLS = Math.min(tools.length, Math.max(3, AVAILABLE_LINES)); + const VISIBLE_TOOLS = Math.min(filteredTools.length, Math.max(3, AVAILABLE_LINES - 2)); // Calculate available lines for description content (accounting for description header and scroll indicators) const DESCRIPTION_CONTENT_LINES = Math.max(1, AVAILABLE_LINES - 2); @@ -65,7 +80,13 @@ export const ServiceTools: React.FC = ({ const TOOLS_LIST_WIDTH = Math.floor(effectiveWidth * TOOL_WIDTH_RATIO); const DESC_WIDTH = effectiveWidth - TOOLS_LIST_WIDTH; - const currentTool = tools[selectedIndex]; + // Prefix "▶ ✓ " / " ✓ " occupies 4 cells; keep name strictly within the panel + const TOOL_NAME_PREFIX_WIDTH = 4; + const maxToolNameWidth = Math.max(8, TOOLS_LIST_WIDTH - TOOL_NAME_PREFIX_WIDTH - 1); + const truncateToolName = (name: string): string => + name.length > maxToolNameWidth ? name.slice(0, maxToolNameWidth - 1) + '…' : name; + + const currentTool = filteredTools[selectedIndex]; const descriptionLines = currentTool?.description?.split('\n') || []; const maxDescScroll = Math.max(0, descriptionLines.length - DESCRIPTION_CONTENT_LINES); @@ -77,6 +98,22 @@ export const ServiceTools: React.FC = ({ setScrollOffset(0); }, [selectedIndex]); + // Reset selection when the filter changes so the index stays valid + useEffect(() => { + setSelectedIndex(0); + setToolScrollOffset(0); + }, [searchQuery]); + + useEffect(() => { + setToolScrollOffset(prev => { + if (selectedIndex < prev) return selectedIndex; + if (selectedIndex >= prev + VISIBLE_TOOLS) { + return Math.max(0, selectedIndex - VISIBLE_TOOLS + 1); + } + return prev; + }); + }, [selectedIndex, VISIBLE_TOOLS]); + useEffect(() => { const loadTools = async () => { setLoading(true); @@ -112,55 +149,106 @@ export const ServiceTools: React.FC = ({ }, [service.name, service.url]); useInput((input, key) => { + // --- Search input mode: keystrokes edit the query (↑↓ still navigate) --- + if (searchMode) { + if (key.escape) { + // First Esc: leave search mode but keep the filter; a second Esc + // (handled in navigation mode below) clears the query, a third + // returns to the service list. + setSearchMode(false); + return; + } + if (key.return) { + setSearchMode(false); + return; + } + if (key.upArrow) { + setSelectedIndex(prev => Math.max(0, prev - 1)); + return; + } + if (key.downArrow) { + setSelectedIndex(prev => Math.max(0, Math.min(filteredTools.length - 1, prev + 1))); + return; + } + if (key.backspace || key.delete) { + setSearchQuery(prev => prev.slice(0, -1)); + return; + } + // Printable character (including space) → append to query + if (input && input.length === 1 && input >= ' ' && input !== '/' ) { + setSearchQuery(prev => prev + input); + return; + } + return; + } + + // --- Navigation mode --- + if (input === '/') { + setSearchMode(true); + return; + } + if (key.escape) { + // Layered Esc: a lingering filter clears first, then we go back. + if (searchQuery) { + setSearchQuery(''); + } else { + onBack(); + } + return; + } if (key.upArrow) { setSelectedIndex(prev => Math.max(0, prev - 1)); } else if (key.downArrow) { - setSelectedIndex(prev => Math.min(tools.length - 1, prev + 1)); + setSelectedIndex(prev => Math.max(0, Math.min(filteredTools.length - 1, prev + 1))); } else if (key.leftArrow) { setScrollOffset(prev => Math.max(0, prev - 1)); } else if (key.rightArrow) { setScrollOffset(prev => Math.min(maxDescScroll, prev + 1)); } else if (input === ' ' || input === 't') { - if (tools[selectedIndex]) { - const tool = tools[selectedIndex]; + const tool = filteredTools[selectedIndex]; + if (tool) { const newEnabled = !tool.enabled; onToggleTool(tool.name, newEnabled); - setTools(prev => prev.map((t, i) => - i === selectedIndex ? { ...t, enabled: newEnabled } : t + setTools(prev => prev.map(t => + t.name === tool.name ? { ...t, enabled: newEnabled } : t )); } } else if (input === 'a') { - const toolsToEnable = tools.filter(t => !t.enabled).map(t => t.name); + const toolsToEnable = filteredTools.filter(t => !t.enabled).map(t => t.name); if (toolsToEnable.length > 0) { + const filteredNames = new Set(filteredTools.map(t => t.name)); + const applyEnable = (t: ToolWithState): ToolWithState => + filteredNames.has(t.name) ? { ...t, enabled: true } : t; if (onBatchToggleTools) { const batchToolStates: Record = {}; toolsToEnable.forEach(toolName => { batchToolStates[toolName] = true; }); onBatchToggleTools(batchToolStates); - setTools(prev => prev.map(t => ({ ...t, enabled: true }))); + setTools(prev => prev.map(applyEnable)); } else { - setTools(prev => prev.map(t => ({ ...t, enabled: true }))); + setTools(prev => prev.map(applyEnable)); toolsToEnable.forEach(toolName => onToggleTool(toolName, true)); } } } else if (input === 'A') { - const toolsToDisable = tools.filter(t => t.enabled).map(t => t.name); + const toolsToDisable = filteredTools.filter(t => t.enabled).map(t => t.name); if (toolsToDisable.length > 0) { + const filteredNames = new Set(filteredTools.map(t => t.name)); + const applyDisable = (t: ToolWithState): ToolWithState => + filteredNames.has(t.name) ? { ...t, enabled: false } : t; if (onBatchToggleTools) { const batchToolStates: Record = {}; toolsToDisable.forEach(toolName => { batchToolStates[toolName] = false; }); onBatchToggleTools(batchToolStates); - setTools(prev => prev.map(t => ({ ...t, enabled: false }))); + setTools(prev => prev.map(applyDisable)); } else { - setTools(prev => prev.map(t => ({ ...t, enabled: false }))); + setTools(prev => prev.map(applyDisable)); toolsToDisable.forEach(toolName => onToggleTool(toolName, false)); } } - } else if (key.escape) { - onBack(); } }); @@ -215,7 +303,7 @@ export const ServiceTools: React.FC = ({ {service.transport === 'stdio' ? 'Could not connect to stdio service - check command and ensure service is running' - : (service.url + : (service.url ? 'Could not connect to service or service has no tools' : 'Service URL not configured - tools can only be discovered when service is reachable')} @@ -236,49 +324,90 @@ export const ServiceTools: React.FC = ({ )} ) : ( - - - {tools.slice(0, VISIBLE_TOOLS).map((tool, index) => ( - - - {index === selectedIndex ? '▶ ' : ' '} - - {tool.enabled ? '✓' : '✗'} - - {' '}{tool.name} - - - ))} - {tools.length > VISIBLE_TOOLS && ( - ... +{tools.length - VISIBLE_TOOLS} more - )} + + {/* Search bar */} + + + 🔍 + {searchMode || searchQuery ? ( + <> + Search: + {searchQuery} + {searchMode && _} + + {' '}[{filteredTools.length}/{totalToolsCount} matched] + + + ) : ( + Press / to search ({totalToolsCount} tools) + )} + - - - Description: - {descriptionLines.length > 0 ? ( - <> - {descriptionLines.slice(scrollOffset, scrollOffset + DESCRIPTION_CONTENT_LINES).map((line, i) => ( - {line} - ))} - - {scrollOffset > 0 ? '↑' : ' '} - {scrollOffset > 0 && scrollOffset < maxDescScroll ? '|' : ''} - {scrollOffset < maxDescScroll ? '↓' : ''} + + + + {filteredTools.length === 0 ? ( + + No tools match "{searchQuery}" - - ) : ( - No description - )} + ) : ( + <> + {filteredTools.slice(toolScrollOffset, toolScrollOffset + VISIBLE_TOOLS).map((tool, index) => ( + + + {index === selectedIndex - toolScrollOffset ? '▶ ' : ' '} + + {tool.enabled ? '✓' : '✗'} + + {' '} + + {truncateToolName(tool.name)} + + ))} + {(toolScrollOffset > 0 || toolScrollOffset + VISIBLE_TOOLS < filteredTools.length) && ( + + {toolScrollOffset > 0 && '↑ more'} + {toolScrollOffset > 0 && toolScrollOffset + VISIBLE_TOOLS < filteredTools.length && ' • '} + {toolScrollOffset + VISIBLE_TOOLS < filteredTools.length && + `↓ ${filteredTools.length - toolScrollOffset - VISIBLE_TOOLS} more`} + + )} + + )} + + + + Description: + {descriptionLines.length > 0 ? ( + <> + {descriptionLines.slice(scrollOffset, scrollOffset + DESCRIPTION_CONTENT_LINES).map((line, i) => ( + {line} + ))} + + {scrollOffset > 0 ? '↑' : ' '} + {scrollOffset > 0 && scrollOffset < maxDescScroll ? '|' : ''} + {scrollOffset < maxDescScroll ? '↓' : ''} + + + ) : ( + No description + )} + )} Quick Actions: - ↑/↓: Navigate tools • Space/T: Toggle tool - A: Disable all tools • a: Enable all tools - ←/→: Scroll description • Esc: Return to service list + + {' '}↑/↓: Navigate • Space/T: Toggle tool • /: Search{searchMode ? ' (Enter to confirm)' : ''} + + + {' '}a: Enable {searchQuery ? 'filtered' : 'all'} • A: Disable {searchQuery ? 'filtered' : 'all'} + + + {' '}←/→: Scroll description • Esc: {searchQuery ? 'Clear search' : 'Return to service list'} + ); diff --git a/src/tui/components/service-field-config.ts b/src/tui/components/service-field-config.ts new file mode 100644 index 0000000..2cf876d --- /dev/null +++ b/src/tui/components/service-field-config.ts @@ -0,0 +1,58 @@ +/** + * Shared per-field help text and placeholders for the TUI service forms. + * + * Only structurally complex fields (args, env, headers) carry a format example + * and a placeholder; simple fields (name, tags, command, url, ...) get a one-line + * description only, keeping the forms quiet where the expected format is obvious. + */ + +import { DEFAULT_CONNECTION_POOL } from '../../types/service.js'; + +export type HelpFieldKey = + | 'name' + | 'transport' + | 'command' + | 'url' + | 'args' + | 'env' + | 'headers' + | 'tags' + | 'enabled' + | 'maxConnections' + | 'idleTimeout' + | 'connectionTimeout' + | 'triggerHintsStart' + | 'triggerHintsEnd' + | 'triggerHintsPhrases' + | 'confirm' + | 'quickMode'; + +const ARGS_EXAMPLE = '-y, @modelcontextprotocol/server-filesystem, /tmp'; +const ENV_EXAMPLE = 'NODE_ENV=production, DEBUG=true'; +const HEADERS_EXAMPLE = 'Authorization: Bearer token, Content-Type: application/json'; + +export const fieldHelp: Record = { + name: 'Unique service identifier.', + transport: 'stdio = local subprocess; sse = Server-Sent Events; http = Streamable HTTP.', + command: 'Executable to launch the MCP server (stdio only).', + url: 'HTTP(S) URL of the MCP server (sse/http).', + args: `Command arguments, comma-separated. e.g. ${ARGS_EXAMPLE}`, + env: `Environment variables as KEY=VALUE, comma-separated. e.g. ${ENV_EXAMPLE}`, + headers: `HTTP headers as Key: Value, comma-separated. Names use hyphens. e.g. ${HEADERS_EXAMPLE}`, + tags: 'Labels for filtering, comma-separated.', + enabled: 'Whether this service should be active.', + maxConnections: `Maximum number of concurrent connections (default: ${DEFAULT_CONNECTION_POOL.maxConnections}).`, + idleTimeout: `Time before idle connections are closed, in ms (default: ${DEFAULT_CONNECTION_POOL.idleTimeout}).`, + connectionTimeout: `Maximum time to wait for a connection, in ms (default: ${DEFAULT_CONNECTION_POOL.connectionTimeout}).`, + triggerHintsStart: 'Reason the LLM should call this service at conversation start.', + triggerHintsEnd: 'Reason the LLM should call this service before conversation ends.', + triggerHintsPhrases: 'Extra trigger phrases the LLM should treat as a search signal.', + confirm: 'Review and save the configuration.', + quickMode: 'Use quick mode with defaults for advanced options.', +}; + +export const fieldPlaceholder: Partial> = { + args: ARGS_EXAMPLE, + env: ENV_EXAMPLE, + headers: HEADERS_EXAMPLE, +}; diff --git a/src/types/service.ts b/src/types/service.ts index a8b9acc..6739d99 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -19,6 +19,16 @@ export interface ConnectionPoolConfig { connectionTimeout: number; } +/** + * Default connection pool settings shared by config defaults and UI help text, + * so the user-facing hints never drift from what actually gets applied. + */ +export const DEFAULT_CONNECTION_POOL: ConnectionPoolConfig = { + maxConnections: 5, + idleTimeout: 60000, + connectionTimeout: 30000, +}; + /** * Service definition for an MCP server */ diff --git a/tests/integration/tui-service-tools-scroll.test.ts b/tests/integration/tui-service-tools-scroll.test.ts new file mode 100644 index 0000000..aadbaf9 --- /dev/null +++ b/tests/integration/tui-service-tools-scroll.test.ts @@ -0,0 +1,387 @@ +/** + * Reproduces the ServiceTools scroll-indicator overlap bug against the REAL + * components, using the optimized app's outer chrome (Header) and contentHeight + * calculation. Mocks tool discovery to return 50 tools and drives ↓ keystrokes + * to scroll to the bottom, then asserts the "↑ more" indicator occupies its own + * line rather than overlapping the last tool row. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { Readable } from 'stream'; +import { Box, useStdout, render } from 'ink'; +import { ServiceTools } from '../../src/tui/components/ServiceTools.js'; +import { Header } from '../../src/tui/components/Header.js'; +import type { ServiceDefinition } from '../../src/types/service.js'; + +// Hoisted so the mock factory can reference it and tests can assert that the +// mock (not a real connection attempt) drove the render. +const { fetchServiceToolsMock } = vi.hoisted(() => { + const tools = Array.from({ length: 50 }, (_, i) => ({ + // Long names well beyond the tool-list panel width, to exercise truncation + name: `namespace___tool_${String(i).padStart(3, '0')}_with_a_very_long_extra_suffix_that_goes_well_beyond_the_tool_list_width_0123456789`, + description: 'mock tool', + inputSchema: { type: 'object', properties: {} }, + })); + return { fetchServiceToolsMock: vi.fn(() => Promise.resolve(tools)) }; +}); + +vi.mock('../../src/tui/discovery-worker.js', () => ({ + __esModule: true, + fetchServiceTools: fetchServiceToolsMock, + default: fetchServiceToolsMock, +})); + +// Minimal ANSI terminal emulator (same as repro script) +class Terminal { + grid: string[][]; + rows: number; + cols: number; + private r = 0; + private c = 0; + constructor(rows: number, cols: number) { + this.rows = rows; + this.cols = cols; + this.grid = Array.from({ length: rows }, () => Array(cols).fill(' ')); + } + feed(data: string) { + let i = 0; + while (i < data.length) { + const ch = data[i]!; + if (ch === '\x1b') { + if (data[i + 1] === '[') { + let j = i + 2; + let paramStr = ''; + while (j < data.length && !/[A-Za-z]/.test(data[j]!)) { + paramStr += data[j]!; + j++; + } + const final = data[j]!; + j++; + const isPrivate = paramStr.includes('?'); + const clean = paramStr.replace(/[^0-9;]/g, ''); + const parts = clean.split(';'); + const num = (s: string) => (s === '' ? 1 : parseInt(s, 10) || 1); + if (!isPrivate) { + if (final === 'H' || final === 'f') { + this.r = Math.min(this.rows - 1, Math.max(0, num(parts[0] ?? '1') - 1)); + this.c = Math.min(this.cols - 1, Math.max(0, num(parts[1] ?? '1') - 1)); + } else if (final === 'A') this.r = Math.max(0, this.r - num(parts[0] ?? '1')); + else if (final === 'B') this.r = Math.min(this.rows - 1, this.r + num(parts[0] ?? '1')); + else if (final === 'C') this.c = Math.min(this.cols - 1, this.c + num(parts[0] ?? '1')); + else if (final === 'D') this.c = Math.max(0, this.c - num(parts[0] ?? '1')); + else if (final === 'G') + this.c = Math.min(this.cols - 1, Math.max(0, num(parts[0] ?? '1') - 1)); + else if (final === 'K') { + if (this.r >= 0 && this.r < this.rows) { + for (let k = this.c; k < this.cols; k++) this.grid[this.r]![k] = ' '; + } + } else if (final === 'J' && parts[0] === '2') { + for (let rr = 0; rr < this.rows; rr++) + for (let cc = 0; cc < this.cols; cc++) this.grid[rr]![cc] = ' '; + } + } + i = j; + } else { + i += 2; + while (i < data.length && !/[A-Za-z]/.test(data[i]!)) i++; + i++; + } + } else if (ch === '\n') { + this.r++; + this.c = 0; + i++; + } else if (ch === '\r') { + this.c = 0; + i++; + } else if (ch >= ' ') { + if (this.r >= 0 && this.r < this.rows && this.c >= 0 && this.c < this.cols) { + this.grid[this.r]![this.c] = ch; + } + this.c++; + i++; + } else { + i++; + } + } + } + text(): string { + return this.grid.map((row) => row.join('').replace(/\s+$/, '')).join('\n'); + } +} + +const createStdin = () => { + const stdin: any = new Readable({ read() {} }); + stdin.isTTY = true; + stdin.setRawMode = () => {}; + stdin.ref = () => {}; + stdin.unref = () => {}; + return stdin; +}; + +// Mirrors the optimized app's outer chrome + contentHeight wiring +const MiniApp: React.FC<{ rows: number }> = ({ rows }) => { + const { stdout } = useStdout(); + const terminalHeight = stdout?.rows || rows; + const OUTER_CHROME_LINES = 5; + const STATUS_BAR_LINES = 0; + const contentHeight = Math.max(8, terminalHeight - OUTER_CHROME_LINES - STATUS_BAR_LINES); + + const service: ServiceDefinition = { + name: 'big-service', + transport: 'stdio', + command: 'node', + enabled: true, + tags: [], + connectionPool: { + maxConnections: 5, + idleTimeout: 60000, + connectionTimeout: 30000, + }, + }; + + return React.createElement( + Box, + { flexDirection: 'column', height: terminalHeight }, + React.createElement(Header, { + title: 'MCP Router System', + subtitle: 'Configuration Manager', + stats: [ + { label: 'Services', value: 1, color: 'yellow' }, + { label: 'Enabled', value: 1, color: 'green' }, + { label: 'Mode', value: 'tui', color: 'blue' }, + ], + }), + React.createElement( + Box, + { flexDirection: 'column', flexGrow: 1 }, + React.createElement(ServiceTools, { + service, + onBack: () => {}, + onToggleTool: () => {}, + toolStates: {}, + terminalHeight: contentHeight, + }) + ) + ); +}; + +function renderApp(rows: number, cols: number) { + const term = new Terminal(rows, cols); + const stdin = createStdin(); + const stdout: any = { + columns: cols, + rows, + isTTY: true, + write: (s: string) => { + term.feed(s); + return true; + }, + on: () => {}, + off: () => {}, + emit: () => {}, + once: () => {}, + removeListener: () => {}, + setEncoding: () => {}, + getWindowSize: () => [cols, rows], + }; + const instance = render(React.createElement(MiniApp, { rows }), { + stdout, + stdin, + exitOnCtrlC: false, + }); + return { instance, term, stdin, stdout }; +} + +const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); +// Poll the rendered terminal until `pred` holds or the timeout elapses, so slow +// CI runners don't flake on Ink's 32ms-throttled render loop. +const waitFor = async (pred: () => boolean, timeoutMs = 5000): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + await new Promise((r) => setImmediate(r)); + await sleep(20); + if (pred()) return true; + } + return pred(); +}; +// Push a sequence of keystrokes with enough delay between each for Ink's +// throttled render loop (32ms) to flush, then flush the scheduler. +const typeKeys = async (stdin: any, chars: string, perKey = 60) => { + for (const ch of chars) { + stdin.push(Buffer.from(ch, 'utf8')); + await new Promise((r) => setImmediate(r)); + await sleep(perKey); + } +}; + +describe('ServiceTools scroll indicator (real components, optimized chrome)', () => { + it('keeps ↑ more on its own line at the bottom of a long tool list (24-row terminal)', async () => { + const { instance, term, stdin } = renderApp(24, 80); + + // Wait for tools to load (mocked discovery must drive the render) + await waitFor(() => term.text().includes('namespace___tool_000')); + expect(fetchServiceToolsMock).toHaveBeenCalled(); + expect(term.text()).toContain('namespace___tool_000'); + + // Scroll all the way to the bottom (stop early once tool_049 is selected) + for (let i = 0; i < 120; i++) { + stdin.push(Buffer.from('\x1b[B', 'utf8')); // down arrow + await new Promise((r) => setImmediate(r)); + await sleep(25); + if ( + term + .text() + .split('\n') + .some((l) => l.includes('▶') && l.includes('tool_049')) + ) { + break; + } + } + + const text = term.text(); + + const lines = text.split('\n'); + const moreLine = lines.findIndex((l) => l.includes('↑ more')); + expect(moreLine).toBeGreaterThan(-1); + + // The line containing "↑ more" must be ONLY the indicator, not a tool row + expect(lines[moreLine]!.includes('namespace___tool_')).toBe(false); + expect(lines[moreLine]!.trim()).toBe('↑ more'); + + // The last tool row (selected, at bottom) is on its own line above it + const lastToolLine = lines.findIndex((l) => l.includes('tool_049')); + expect(lastToolLine).toBeGreaterThan(-1); + expect(lines[lastToolLine]!.includes('▶')).toBe(true); + expect(lastToolLine).toBeLessThan(moreLine); + + // Long tool names must be truncated within the left panel, not overflow + // into the description column. Left panel width is TOOLS_LIST_WIDTH = 38 + // at 80 columns (76 * 0.5). A tool row and the description column legitimately + // share the same row (side-by-side panels), so assert the truncation ellipsis + // sits strictly before the description column. + const LEFT_PANEL_WIDTH = 38; + const toolRows = lines.filter((l) => l.includes('namespace___tool_')); + expect(toolRows.length).toBeGreaterThan(0); + for (const row of toolRows) { + const ellipsisCol = row.indexOf('…'); + expect(ellipsisCol).toBeGreaterThan(0); + expect(ellipsisCol).toBeLessThan(LEFT_PANEL_WIDTH); + const descCol = row.indexOf('Description'); + if (descCol > -1) { + expect(ellipsisCol).toBeLessThan(descCol); + } + } + + instance.unmount(); + }); + + it('filters the tool list by name when entering search mode', async () => { + const { instance, term, stdin } = renderApp(24, 80); + + // Wait for tools to load (mocked discovery must drive the render) + await waitFor(() => term.text().includes('namespace___tool_000')); + + // Enter search mode and type "tool_04" → matches tool_040..tool_049 (10 tools) + await typeKeys(stdin, '/'); + await typeKeys(stdin, 'tool_04'); + + const text = term.text(); + // Search bar shows the query and match count + expect(text).toContain('Search: tool_04'); + expect(text).toContain('[10/50 matched]'); + + // Only tool_04x rows are visible; tool_000 (non-matching) is gone + expect(text).not.toContain('namespace___tool_000'); + expect(text).toContain('namespace___tool_040'); + // The first match is selected (▶ marker) + expect(text).toContain('▶'); + + instance.unmount(); + }); + + it('shows an empty state when no tools match the query', async () => { + const { instance, term, stdin } = renderApp(24, 80); + + for (let i = 0; i < 60; i++) { + await new Promise((r) => setImmediate(r)); + await sleep(10); + if (term.text().includes('namespace___tool_000')) break; + } + + await typeKeys(stdin, '/'); + await typeKeys(stdin, 'zzzzzz'); + + const text = term.text(); + expect(text).toContain('[0/50 matched]'); + expect(text).toContain('No tools match'); + + instance.unmount(); + }); + + it('exits search input mode but keeps the filter on first Esc, clears on second', async () => { + const { instance, term, stdin } = renderApp(24, 80); + + for (let i = 0; i < 60; i++) { + await new Promise((r) => setImmediate(r)); + await sleep(10); + if (term.text().includes('namespace___tool_000')) break; + } + + // Enter search input mode and narrow the list + await typeKeys(stdin, '/'); + await typeKeys(stdin, 'tool_04'); + expect(term.text()).toContain('[10/50 matched]'); + // Still in search input mode (yellow cursor visible) + expect(term.text()).toContain('_'); + + // First Esc: leave input mode but keep the filter active + stdin.push(Buffer.from('\x1b', 'utf8')); // Esc + await new Promise((r) => setImmediate(r)); + await sleep(80); + const afterFirstEsc = term.text(); + expect(afterFirstEsc).toContain('[10/50 matched]'); + // No longer in input mode (no cursor) + expect(afterFirstEsc).not.toContain('Search: tool_04_'); + // Filter still applied: non-matching tool hidden + expect(afterFirstEsc).not.toContain('namespace___tool_000'); + + // Second Esc: clear the query, restore the full list + stdin.push(Buffer.from('\x1b', 'utf8')); // Esc + await new Promise((r) => setImmediate(r)); + await sleep(80); + const afterSecondEsc = term.text(); + expect(afterSecondEsc).toContain('namespace___tool_000'); + expect(afterSecondEsc).toContain('Press / to search'); + + instance.unmount(); + }); + + it('toggles only the filtered tool after confirming the search with Enter', async () => { + const { instance, term, stdin } = renderApp(24, 80); + + for (let i = 0; i < 60; i++) { + await new Promise((r) => setImmediate(r)); + await sleep(10); + if (term.text().includes('namespace___tool_000')) break; + } + + // Search "tool_040", confirm with Enter, then toggle with Space + await typeKeys(stdin, '/'); + await typeKeys(stdin, 'tool_040'); + stdin.push(Buffer.from('\r', 'utf8')); // Enter + await new Promise((r) => setImmediate(r)); + await sleep(80); + stdin.push(Buffer.from(' ', 'utf8')); // Space → toggle + await new Promise((r) => setImmediate(r)); + await sleep(80); + + const text = term.text(); + // tool_040 was enabled (✓ green) and is now disabled (✗ red), shown selected + // Find the selected row containing tool_040 + const selectedRow = text.split('\n').find((l) => l.includes('▶') && l.includes('tool_040')); + expect(selectedRow).toBeDefined(); + expect(selectedRow!.includes('✗')).toBe(true); + + instance.unmount(); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 12b9a50..5cbbe77 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -3,6 +3,12 @@ * Suppress unhandled errors from child processes in tests */ +// Ink short-circuits its render loop in CI mode (CI=true): it stores each +// frame in `lastOutput` and only flushes to stdout on unmount(), so any test +// that drives a live Ink render and reads the captured stdout sees a blank +// terminal. Force CI off so renders flush on every frame, matching local dev. +process.env['CI'] = 'false'; + // Suppress unhandled rejections from transport processes process.on('unhandledRejection', (reason) => { // Only suppress TransportError with PROCESS_EXITED code diff --git a/tests/unit/tui/service-field-config.test.ts b/tests/unit/tui/service-field-config.test.ts new file mode 100644 index 0000000..69e02c1 --- /dev/null +++ b/tests/unit/tui/service-field-config.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { + fieldHelp, + fieldPlaceholder, + type HelpFieldKey, +} from '../../../src/tui/components/service-field-config.js'; + +describe('service-field-config', () => { + it('provides non-empty help for every configured field', () => { + for (const [field, help] of Object.entries(fieldHelp)) { + expect(help, `help for ${field} should not be empty`).toBeTruthy(); + } + }); + + it('only provides placeholders for complex structured fields', () => { + expect(Object.keys(fieldPlaceholder).sort()).toEqual(['args', 'env', 'headers']); + }); + + it('gives headers help with the Key: Value format and hyphen hint', () => { + expect(fieldHelp.headers).toContain('Key: Value'); + expect(fieldHelp.headers).toContain('hyphens'); + }); + + it('gives env help with the KEY=VALUE format', () => { + expect(fieldHelp.env).toContain('KEY=VALUE'); + }); + + it('does not provide placeholders for simple fields', () => { + const simpleFields: HelpFieldKey[] = [ + 'name', + 'command', + 'url', + 'tags', + 'enabled', + 'maxConnections', + 'idleTimeout', + 'connectionTimeout', + 'triggerHintsStart', + 'triggerHintsEnd', + 'triggerHintsPhrases', + 'confirm', + 'quickMode', + ]; + for (const field of simpleFields) { + expect(fieldPlaceholder[field], `no placeholder for ${field}`).toBeUndefined(); + } + }); +});