From e1d46a59155d261361660adca605c9a6936e3e4f Mon Sep 17 00:00:00 2001 From: kugouming Date: Fri, 11 Sep 2026 15:01:48 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(tui):=20=E6=96=B0=E5=A2=9E=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=8F=82=E6=95=B0=E8=A1=A8=E5=8D=95=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=9D=A2=E6=9D=BF,=E6=94=AF=E6=8C=81=20CJK=20?= =?UTF-8?q?=E6=84=9F=E7=9F=A5=E5=B8=83=E5=B1=80=E4=B8=8E=E5=89=AA=E8=B4=B4?= =?UTF-8?q?=E6=9D=BF=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServiceTools 从只读工具列表扩展为「参数填写 → 运行 → 结果查看」四区交互面板(Tab 切区、Ctrl+R 运行、Ctrl+J 表单/JSON 切换、Ctrl+Y 复制、Ctrl+O 存文件) - discovery-worker 将 stdio/HTTP/SSE 会话抽为泛型复用,新增 callServiceTool 及 tools/call,会话过期(-32001/404)自动重试且不重试工具级错误 - 新增 tool-param-schema(按 JSON Schema 构建参数行与校验)、text-layout(CJK 双宽截断/换行/边框)、clipboard(跨平台复制)及其单测 - 新增 JsonTextArea 与 SingleLineInput 受控输入组件,HelpDialog 补充新快捷键说明 - 补充 TUI 运行面板端到端测试与 callServiceTool 会话过期集成测试 - 新增 string-width、wrap-ansi 依赖以支持宽字符换行计算 --- package-lock.json | 4 +- package.json | 4 +- src/tui/app-optimized.tsx | 100 +- src/tui/clipboard.ts | 62 + src/tui/components/HelpDialog.tsx | 180 ++- src/tui/components/JsonTextArea.tsx | 142 ++ src/tui/components/ServiceTools.tsx | 1316 ++++++++++++++--- src/tui/components/SingleLineInput.tsx | 112 ++ src/tui/discovery-worker.ts | 846 +++++++---- src/tui/text-layout.ts | 143 ++ src/tui/tool-param-schema.ts | 531 +++++++ .../discovery-worker-session-expiry.test.ts | 196 ++- .../integration/tui-service-tools-run.test.ts | 757 ++++++++++ .../tui-service-tools-scroll.test.ts | 8 +- tests/unit/tui/clipboard.test.ts | 16 + tests/unit/tui/text-layout.test.ts | 157 ++ tests/unit/tui/tool-call-result.test.ts | 118 ++ tests/unit/tui/tool-param-schema.test.ts | 685 +++++++++ 18 files changed, 4856 insertions(+), 521 deletions(-) create mode 100644 src/tui/clipboard.ts create mode 100644 src/tui/components/JsonTextArea.tsx create mode 100644 src/tui/components/SingleLineInput.tsx create mode 100644 src/tui/text-layout.ts create mode 100644 src/tui/tool-param-schema.ts create mode 100644 tests/integration/tui-service-tools-run.test.ts create mode 100644 tests/unit/tui/clipboard.test.ts create mode 100644 tests/unit/tui/text-layout.test.ts create mode 100644 tests/unit/tui/tool-call-result.test.ts create mode 100644 tests/unit/tui/tool-param-schema.test.ts diff --git a/package-lock.json b/package-lock.json index fae3919..527ea8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "ink-text-input": "^6.0.0", "pino": "^8.17.2", "pino-pretty": "^10.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "string-width": "^7.2.0", + "wrap-ansi": "^9.0.2" }, "bin": { "onemcp": "dist/cli.js" diff --git a/package.json b/package.json index 29914f4..1ec0a5d 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,9 @@ "ink-text-input": "^6.0.0", "pino": "^8.17.2", "pino-pretty": "^10.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "string-width": "^7.2.0", + "wrap-ansi": "^9.0.2" }, "devDependencies": { "@types/eventsource": "^1.1.15", diff --git a/src/tui/app-optimized.tsx b/src/tui/app-optimized.tsx index f434524..dfafb53 100644 --- a/src/tui/app-optimized.tsx +++ b/src/tui/app-optimized.tsx @@ -16,7 +16,11 @@ import { ServiceTools } from './components/ServiceTools.js'; import { Header } from './components/Header.js'; import { StatusBar, type StatusMessage } from './components/StatusBar.js'; import { HelpDialog } from './components/HelpDialog.js'; -import { ToolDiscoveryManager, type DiscoveryStatus, type DiscoveryResult } from './tool-discovery-manager.js'; +import { + ToolDiscoveryManager, + type DiscoveryStatus, + type DiscoveryResult, +} from './tool-discovery-manager.js'; import type { SystemConfig, ConfigProvider } from '../types/config.js'; import type { ServiceDefinition } from '../types/service.js'; @@ -45,13 +49,15 @@ type ViewState = 'list' | 'add' | 'edit' | 'tools' | 'help'; export const TuiAppOptimized: React.FC = ({ configDir, config: propConfig, - configProvider: propConfigProvider + configProvider: propConfigProvider, }) => { const { stdout } = useStdout(); const [state, setState] = useState('loading'); const [view, setView] = useState('list'); const [config, setConfig] = useState(propConfig || null); - const [configProvider, setConfigProvider] = useState(propConfigProvider || null); + const [configProvider, setConfigProvider] = useState( + propConfigProvider || null + ); const [services, setServices] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [error, setError] = useState(null); @@ -63,7 +69,9 @@ export const TuiAppOptimized: React.FC = ({ const [lastRefresh, setLastRefresh] = useState(new Date()); // In-memory discovery state (never persisted to disk) - const [discoveryStatuses, setDiscoveryStatuses] = useState>(new Map()); + const [discoveryStatuses, setDiscoveryStatuses] = useState>( + new Map() + ); const [toolCountCache, setToolCountCache] = useState>(new Map()); // Singleton ToolDiscoveryManager (stable across renders) @@ -84,10 +92,10 @@ export const TuiAppOptimized: React.FC = ({ const globalToolStats = React.useMemo(() => { let total = 0; let enabled = 0; - services.forEach(s => { + services.forEach((s) => { const count = toolCountCache.get(s.name) ?? 0; total += count; - const disabled = Object.values(s.toolStates ?? {}).filter(v => v === false).length; + const disabled = Object.values(s.toolStates ?? {}).filter((v) => v === false).length; enabled += Math.max(0, count - disabled); }); return { enabled, total }; @@ -98,12 +106,12 @@ export const TuiAppOptimized: React.FC = ({ const manager = discoveryManagerRef.current; const onDiscovered = (result: DiscoveryResult) => { - setDiscoveryStatuses(prev => new Map(prev).set(result.serviceName, 'completed')); - setToolCountCache(prev => new Map(prev).set(result.serviceName, result.toolCount ?? 0)); + setDiscoveryStatuses((prev) => new Map(prev).set(result.serviceName, 'completed')); + setToolCountCache((prev) => new Map(prev).set(result.serviceName, result.toolCount ?? 0)); }; const onError = (result: DiscoveryResult) => { - setDiscoveryStatuses(prev => new Map(prev).set(result.serviceName, 'failed')); + setDiscoveryStatuses((prev) => new Map(prev).set(result.serviceName, 'failed')); }; manager.on('discovered', onDiscovered); @@ -135,7 +143,9 @@ export const TuiAppOptimized: React.FC = ({ const validation = provider.validate(loadedConfig); if (!validation.valid) { - const errorMessages = validation.errors.map(e => `${e.field}: ${e.message}`).join(', '); + const errorMessages = validation.errors + .map((e) => `${e.field}: ${e.message}`) + .join(', '); throw new Error(`Configuration validation failed: ${errorMessages}`); } @@ -149,7 +159,10 @@ export const TuiAppOptimized: React.FC = ({ // Set up configuration watch to handle external changes unwatch = provider.watch((newConfig) => { - const updatedServices = Object.entries(newConfig.mcpServers).map(([name, def]) => ({ ...def, name })); + const updatedServices = Object.entries(newConfig.mcpServers).map(([name, def]) => ({ + ...def, + name, + })); setServices(updatedServices); if (serviceRegistry) { @@ -170,10 +183,10 @@ export const TuiAppOptimized: React.FC = ({ setLastRefresh(new Date()); // Trigger auto-discovery for all enabled services (fast-fail, no retries) - const enabledServices = serviceList.filter(s => s.enabled); + const enabledServices = serviceList.filter((s) => s.enabled); if (enabledServices.length > 0) { const initialStatuses = new Map(); - enabledServices.forEach(s => initialStatuses.set(s.name, 'pending')); + enabledServices.forEach((s) => initialStatuses.set(s.name, 'pending')); setDiscoveryStatuses(initialStatuses); const visibleIndices = Array.from( @@ -195,7 +208,7 @@ export const TuiAppOptimized: React.FC = ({ unwatch(); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [configDir, propConfig, propConfigProvider]); // Reload services @@ -251,7 +264,7 @@ export const TuiAppOptimized: React.FC = ({ if (isRename && editingService) { // Rename: remove old entry, start fresh discovery for new name - setDiscoveryStatuses(prev => { + setDiscoveryStatuses((prev) => { const next = new Map(prev); next.delete(editingService.name); if (service.enabled) next.set(service.name, 'pending'); @@ -263,7 +276,7 @@ export const TuiAppOptimized: React.FC = ({ } } else if ((isNew || !hasCachedTools) && service.enabled) { // New service or no cached tool count yet: trigger discovery - setDiscoveryStatuses(prev => new Map(prev).set(service.name, 'pending')); + setDiscoveryStatuses((prev) => new Map(prev).set(service.name, 'pending')); const list = updatedList ?? services; void discoveryManagerRef.current.refreshZeroToolServices(list); } @@ -320,10 +333,8 @@ export const TuiAppOptimized: React.FC = ({ setEditingService(updatedService); // Update services list to reflect the change immediately - setServices(prevServices => - prevServices.map(s => - s.name === editingService.name ? updatedService : s - ) + setServices((prevServices) => + prevServices.map((s) => (s.name === editingService.name ? updatedService : s)) ); setStatusMessage({ @@ -360,10 +371,8 @@ export const TuiAppOptimized: React.FC = ({ } setEditingService(updatedService); - setServices(prevServices => - prevServices.map(s => - s.name === editingService.name ? updatedService : s - ) + setServices((prevServices) => + prevServices.map((s) => (s.name === editingService.name ? updatedService : s)) ); setStatusMessage({ @@ -383,8 +392,8 @@ export const TuiAppOptimized: React.FC = ({ // Handle tools discovered (from ServiceTools view) — update in-memory cache only, no persistence const handleToolsDiscovered = (toolCount: number) => { if (!editingService) return; - setToolCountCache(prev => new Map(prev).set(editingService.name, toolCount)); - setDiscoveryStatuses(prev => new Map(prev).set(editingService.name, 'completed')); + setToolCountCache((prev) => new Map(prev).set(editingService.name, toolCount)); + setDiscoveryStatuses((prev) => new Map(prev).set(editingService.name, 'completed')); }; // Handle service toggle @@ -392,13 +401,11 @@ export const TuiAppOptimized: React.FC = ({ if (!config || !configProvider) return; try { - const updatedServices = services.map(s => - s.name === serviceName ? { ...s, enabled } : s - ); + const updatedServices = services.map((s) => (s.name === serviceName ? { ...s, enabled } : s)); const newConfig = { ...config, services: updatedServices }; await configProvider.save(newConfig); - const target = updatedServices.find(s => s.name === serviceName); + const target = updatedServices.find((s) => s.name === serviceName); if (serviceRegistry && target !== undefined) { await serviceRegistry.register(target); } @@ -428,13 +435,13 @@ export const TuiAppOptimized: React.FC = ({ await serviceRegistry.unregister(serviceName); } else { // Fallback: update config directly - const updatedServices = services.filter(s => s.name !== serviceName); + const updatedServices = services.filter((s) => s.name !== serviceName); const newConfig = { ...config, services: updatedServices }; await configProvider.save(newConfig); } // Update local state - const updatedServices = services.filter(s => s.name !== serviceName); + const updatedServices = services.filter((s) => s.name !== serviceName); setServices(updatedServices); if (selectedIndex >= updatedServices.length && updatedServices.length > 0) { setSelectedIndex(updatedServices.length - 1); @@ -493,9 +500,9 @@ export const TuiAppOptimized: React.FC = ({ // List view navigation if (view === 'list') { if (key.upArrow) { - setSelectedIndex(prev => Math.max(0, prev - 1)); + setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setSelectedIndex(prev => Math.min(services.length - 1, prev + 1)); + setSelectedIndex((prev) => Math.min(services.length - 1, prev + 1)); } else if (key.return) { if (services[selectedIndex]) { setEditingService(services[selectedIndex]); @@ -524,7 +531,7 @@ export const TuiAppOptimized: React.FC = ({ const service = services[selectedIndex]; void handleDeleteService(service.name); } - } else if (input === 'r') { + } else if (input === 'r' && !key.ctrl) { // Reload config then re-discover zero-tool services void (async () => { const refreshed = await reloadServices(); @@ -563,7 +570,9 @@ export const TuiAppOptimized: React.FC = ({
- ❌ Error loading configuration + + ❌ Error loading configuration + {error} @@ -581,7 +590,7 @@ export const TuiAppOptimized: React.FC = ({ return setView('list')} />; } - const enabledServices = services.filter(s => s.enabled).length; + const enabledServices = services.filter((s) => s.enabled).length; const formMode = useUnifiedForm ? 'Unified' : 'Traditional'; // Render main application @@ -599,10 +608,7 @@ export const TuiAppOptimized: React.FC = ({ showHelp={view === 'list'} /> - setStatusMessage(null)} - /> + setStatusMessage(null)} /> {view === 'list' && ( @@ -619,8 +625,8 @@ export const TuiAppOptimized: React.FC = ({ /> )} - {(view === 'add' || view === 'edit') && ( - useUnifiedForm ? ( + {(view === 'add' || view === 'edit') && + (useUnifiedForm ? ( = ({ onSubmit={handleServiceSubmit} onCancel={handleServiceCancel} /> - ) - )} + ))} {view === 'tools' && editingService && ( { setView('list'); - setRefreshKey(k => k + 1); + setRefreshKey((k) => k + 1); }} onToggleTool={handleToggleTool} onBatchToggleTools={handleBatchToggleTools} @@ -654,8 +659,7 @@ export const TuiAppOptimized: React.FC = ({ {view === 'list' && ( - Last refresh: {lastRefresh.toLocaleTimeString()} • - Config: {configDir} + Last refresh: {lastRefresh.toLocaleTimeString()} • Config: {configDir} )} diff --git a/src/tui/clipboard.ts b/src/tui/clipboard.ts new file mode 100644 index 0000000..45e2808 --- /dev/null +++ b/src/tui/clipboard.ts @@ -0,0 +1,62 @@ +/** + * System clipboard integration. + * + * Terminals expose no clipboard API, so this shells out to the platform's + * clipboard utility. It is only ever called from an explicit user keypress and + * the text never leaves the machine — no network, no files. + */ + +import { spawnSync } from 'node:child_process'; + +/** One clipboard utility invocation, in preference order. */ +export interface ClipboardCommand { + command: string; + args: string[]; +} + +/** + * Clipboard utilities to try, most-preferred first. Wayland's wl-copy is tried + * before X11's xclip because Linux desktops are increasingly Wayland-only. + */ +export function clipboardCommands( + platform: NodeJS.Platform = process.platform +): readonly ClipboardCommand[] { + if (platform === 'darwin') { + return [{ command: 'pbcopy', args: [] }]; + } + if (platform === 'win32') { + return [{ command: 'clip', args: [] }]; + } + return [ + { command: 'wl-copy', args: [] }, + { command: 'xclip', args: ['-selection', 'clipboard'] }, + ]; +} + +/** Whether a spelling of text-to-copy is worth attempting at all. */ +function isCopiable(text: string): boolean { + return text.length > 0; +} + +/** + * Copy `text` to the system clipboard. + * + * Returns false when no clipboard utility is available or all of them fail, so + * the caller can surface a notice instead of throwing inside the render loop. + */ +export function copyToClipboard(text: string): boolean { + if (!isCopiable(text)) { + return false; + } + for (const { command, args } of clipboardCommands()) { + try { + const result = spawnSync(command, args, { input: text, encoding: 'utf8' }); + if (result.status === 0) { + return true; + } + } catch { + // Utility missing or not executable — try the next candidate. + } + } + return false; +} diff --git a/src/tui/components/HelpDialog.tsx b/src/tui/components/HelpDialog.tsx index d277b21..ac58fdc 100644 --- a/src/tui/components/HelpDialog.tsx +++ b/src/tui/components/HelpDialog.tsx @@ -1,6 +1,6 @@ /** * TUI Help Dialog Component - * + * * Displays comprehensive help information */ @@ -21,66 +21,172 @@ export const HelpDialog: React.FC = ({ onClose }) => { return ( - Help - Keyboard Shortcuts + + Help - Keyboard Shortcuts + - Service List View + + Service List View + - ↑/↓ - Navigate services - Enter - Edit selected service - a - Add new service - e - Edit selected service - d - Delete selected service - Space/t - Toggle service enabled/disabled - v - View service tools - r - Refresh service list - y - Toggle form mode (unified/traditional) - ? - Show this help - q - Quit application + + ↑/↓ - Navigate services + + + Enter - Edit selected service + + + a - Add new service + + + e - Edit selected service + + + d - Delete selected service + + + Space/t - Toggle service enabled/disabled + + + v - View service tools + + + r - Refresh service list + + + y - Toggle form mode (unified/traditional) + + + ? - Show this help + + + q - Quit application + - Service Form (Unified) + + Service Form (Unified) + - Tab - Next field - Shift+Tab - Previous field - Enter - Confirm field and move to next - Ctrl+A - Toggle advanced options - Ctrl+S - Save service - Esc - Cancel and return + + Tab - Next field + + + Shift+Tab - Previous field + + + Enter - Confirm field and move to next + + + Ctrl+A - Toggle advanced options + + + Ctrl+S - Save service + + + Esc - Cancel and return + - Service Form (Traditional) + + Service Form (Traditional) + - Enter - Next step - ↑/↓ - Select option (for dropdowns) - p - Preview configuration (at confirm step) - Esc - Cancel and return + + Enter - Next step + + + ↑/↓ - Select option (for dropdowns) + + + p - Preview configuration (at confirm step) + + + Esc - Cancel and return + - Tools View + + Tools View + - ↑/↓ - Navigate tools - ←/→ - Scroll description - Space/t - Toggle tool enabled/disabled - a - Enable all tools - Shift+A - Disable all tools - Esc - Back to service list + + ↑/↓ - Navigate tools + + + Space/t - Toggle tool enabled/disabled + + + a - Enable all tools + + + Shift+A - Disable all tools + + + / - Search tools + + + Tab - Switch region (list → params → result) + + + ↑/↓ - In a region: move the param / result cursor + + + ←/→ - Page the panel (also PgUp/PgDn, Ctrl+U/D) + + + Ctrl+R - Run the selected tool + + + Ctrl+E - Expand / collapse the full tool description + + + Ctrl+J - Toggle form / raw JSON arguments + + + Ctrl+P - Toggle formatted / raw output + + + Ctrl+Y - Copy the result (or the selected lines) + + + v - Select result lines from the cursor (↑/↓ extend) + + + f - Full-width view (hides the tool list, so mouse-drag picks + up only the result) + + + Ctrl+O - Save full output to a temp file + + + Esc - Leave region / back to service list + - Client Tag Filtering + + Client Tag Filtering + - tagFilter - Clients specify tags in initialize request params - tags - Array of tags to match (e.g., ["production", "api"]) - logic - "AND" (all tags required) or "OR" (any tag matches) + + tagFilter - Clients specify tags in initialize request params + + + tags - Array of tags to match (e.g., ["production", "api"]) + + + logic - "AND" (all tags required) or "OR" (any tag matches) + Services without tags are always available to all clients Works for both stdio and HTTP modes via JSON-RPC initialize diff --git a/src/tui/components/JsonTextArea.tsx b/src/tui/components/JsonTextArea.tsx new file mode 100644 index 0000000..309fc2e --- /dev/null +++ b/src/tui/components/JsonTextArea.tsx @@ -0,0 +1,142 @@ +/** + * TUI multi-line JSON text area + * + * A controlled editor for the raw-arguments JSON mode of ToolRunner. + * Handles only editing keys (printable, backspace, Enter, arrows); control + * chords (Ctrl+S / Ctrl+J / ...) are ignored here so the parent component + * owns them exclusively. + */ + +import React, { useMemo, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; + +export interface JsonTextAreaProps { + value: string; + onChange: (next: string) => void; + /** Viewport height in lines. */ + height: number; +} + +export const JsonTextArea: React.FC = ({ value, onChange, height }) => { + const [cursor, setCursor] = useState(value.length); + + const lines = useMemo(() => value.split('\n'), [value]); + + /** Resolve a 0-based cursor offset to [lineIndex, columnIndex]. */ + const locate = (offset: number): { line: number; column: number } => { + let remaining = Math.max(0, Math.min(offset, value.length)); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) { + break; + } + if (remaining <= line.length) { + return { line: i, column: remaining }; + } + remaining -= line.length + 1; + } + return { line: Math.max(0, lines.length - 1), column: 0 }; + }; + + const offsetOfLineStart = (lineIndex: number): number => { + let offset = 0; + for (let i = 0; i < lineIndex; i++) { + offset += (lines[i]?.length ?? 0) + 1; + } + return offset; + }; + + const clampCursor = (next: number): number => Math.max(0, Math.min(next, value.length)); + + useInput((input, key) => { + if (key.ctrl || key.escape || key.tab) { + return; + } + + if (key.backspace || key.delete) { + if (cursor === 0) { + return; + } + onChange(value.slice(0, cursor - 1) + value.slice(cursor)); + setCursor(clampCursor(cursor - 1)); + return; + } + + if (key.leftArrow) { + setCursor((prev) => clampCursor(prev - 1)); + return; + } + if (key.rightArrow) { + setCursor((prev) => clampCursor(prev + 1)); + return; + } + if (key.upArrow || key.downArrow) { + const pos = locate(cursor); + const targetLine = key.upArrow + ? Math.max(0, pos.line - 1) + : Math.min(lines.length - 1, pos.line + 1); + setCursor( + clampCursor( + offsetOfLineStart(targetLine) + Math.min(pos.column, lines[targetLine]?.length ?? 0) + ) + ); + return; + } + + if (key.return) { + // Enter inserts a newline (Ctrl+J — a bare \n — is reserved for the + // parent's mode toggle and never reaches this handler as an edit). + onChange(value.slice(0, cursor) + '\n' + value.slice(cursor)); + setCursor(cursor + 1); + return; + } + + // Printable character + if (input && input.length === 1 && input >= ' ' && input !== '\n') { + onChange(value.slice(0, cursor) + input + value.slice(cursor)); + setCursor(cursor + input.length); + } + }); + + // Cursor-following viewport: the window scrolls only once the cursor would + // fall below the last visible line, so it never jumps while editing. + const cursorPos = locate(cursor); + const visibleHeight = Math.max(1, height); + const maxStart = Math.max(0, lines.length - visibleHeight); + const adjustedStart = Math.max(0, Math.min(cursorPos.line - (visibleHeight - 1), maxStart)); + + const visibleLines = lines.slice(adjustedStart, adjustedStart + visibleHeight); + + return ( + + {visibleLines.map((line, i) => { + const lineIndex = adjustedStart + i; + if (lineIndex !== cursorPos.line) { + return ( + + {line} + + ); + } + const before = line.slice(0, cursorPos.column); + const at = line.slice(cursorPos.column, cursorPos.column + 1); + const after = line.slice(cursorPos.column + 1); + return ( + + {before} + {at === '' ? ' ' : at} + {after} + + ); + })} + {lines.length > visibleHeight && ( + + {adjustedStart > 0 ? '↑' : ' '} + {adjustedStart + visibleHeight < lines.length + ? `↓ ${lines.length - adjustedStart - visibleHeight} more` + : ''} + + )} + + ); +}; diff --git a/src/tui/components/ServiceTools.tsx b/src/tui/components/ServiceTools.tsx index 116ea32..d74e264 100644 --- a/src/tui/components/ServiceTools.tsx +++ b/src/tui/components/ServiceTools.tsx @@ -1,13 +1,51 @@ /** * TUI Service Tools Component * - * Displays tools for a selected service and allows enabling/disabling them. + * Displays tools for a selected service with a flattened detail panel + * (description → editable parameters → run result in one scroll flow) and + * allows enabling/disabling or running them inline (Ctrl+R). */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Box, Text, useInput, useStdout } from 'ink'; -import { fetchServiceTools } from '../discovery-worker.js'; +import { writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + callServiceTool, + DiscoveryError, + DiscoveryErrorType, + fetchServiceTools, + ToolCallError, + type ToolCallOutcome, +} from '../discovery-worker.js'; +import { + boxBottom, + boxRow, + boxTop, + displayWidth, + SECTION_BAR, + sectionTitle, + truncateDisplay, +} from '../text-layout.js'; +import { copyToClipboard } from '../clipboard.js'; +import { + bestEffortArgs, + buildParamRows, + buildToolArguments, + buildToolParams, + formatParamValue, + isRecord, + seedFormValues, + wrapText, + type SegmentTone, + type StyledSegment, +} from '../tool-param-schema.js'; import type { ServiceDefinition } from '../../types/service.js'; +import type { Tool } from '../../types/tool.js'; +import type { ToolParam } from '../tool-param-schema.js'; +import { SingleLineInput } from './SingleLineInput.js'; +import { JsonTextArea } from './JsonTextArea.js'; export interface ServiceToolsProps { service: ServiceDefinition; @@ -23,21 +61,133 @@ export interface ServiceToolsProps { terminalHeight?: number; } -interface BasicTool { - name: string; - description: string; - inputSchema: { - type: 'object'; - properties: Record; - required?: string[]; - }; +/** Where keyboard focus lives inside the tools view. */ +type PanelFocus = 'list' | 'params' | 'result' | 'json'; +type RunStatus = 'editing' | 'running' | 'done'; +type ResultView = 'formatted' | 'raw'; + +/** One row of the flattened detail panel; every row renders as one line. */ +type DetailRow = + | { + type: 'text'; + text: string; + segments?: StyledSegment[]; + anchor?: string; + /** Index into the result's rendered lines (for range selection). */ + resultIndex?: number; + } + | { type: 'input'; param: ToolParam } + | { type: 'select'; param: ToolParam }; + +const FETCH_TIMEOUT_MS = 15000; +const CALL_TIMEOUT_MS = 60000; +const RESULT_MAX_CHARS = 200_000; +/** Indent + marker for the expanded parameter's value / editor row. */ +const DETAIL_VALUE_INDENT = ' ▸ '; +/** Indent for the description body, setting it apart from the section title. */ +const DESCRIPTION_INDENT = ' '; + +/** + * Panel scroll indicator. Rendered on one row always (so the panel height is + * stable) but stays blank when there is nothing to scroll, and is indented to + * align with content — a lone dim glyph at the panel's left edge reads as a + * rendering artifact rather than a control. + */ +const scrollHint = (offset: number, max: number): string => { + const arrows: string[] = []; + if (offset > 0) { + arrows.push('↑'); + } + if (offset < max) { + arrows.push('↓'); + } + return arrows.length > 0 ? ` ${arrows.join('|')} more` : ' '; +}; + +/** Map a segment tone to ink Text props (colors are a pure enhancement; the + * test harness strips SGR, so nothing observable depends on them). + * + * `muted` deliberately de-emphasizes with a grey FOREGROUND rather than + * `dimColor`: some terminals render SGR 2 (faint) by dimming the whole cell — + * background included — which turns every dim span into a visibly darker + * rectangle. */ +type ToneStyle = { bold?: boolean; dimColor?: boolean; color?: 'red' | 'cyan' | 'green' | 'gray' }; +const toneStyle = (tone: SegmentTone): ToneStyle => { + switch (tone) { + case 'muted': + return { color: 'gray' }; + case 'critical': + return { color: 'red', bold: true }; + case 'accent': + return { color: 'cyan', bold: true }; + case 'primary': + return { bold: true }; + case 'value': + return {}; + } +}; + +const isCtrlJ = (input: string, key: { ctrl: boolean }): boolean => + // Terminals send Ctrl+J as a bare LF: ink parses it to name 'enter' with + // ctrl=false, so the raw \n must be matched alongside ctrl+'j'. + input === '\n' || (key.ctrl && input === 'j'); + +const sanitizeFileName = (name: string): string => name.replace(/[^A-Za-z0-9._-]/g, '_'); + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } } -interface ToolWithState extends BasicTool { - enabled: boolean; +/** Classify a call failure into the run-view error copy. */ +function describeCallError(err: unknown): string { + if (err instanceof ToolCallError) { + const code = typeof err.code === 'number' ? ` (${err.code})` : ''; + const data = err.data !== undefined ? `\n${safeStringify(err.data)}` : ''; + return `Backend error${code}: ${err.message}${data}`; + } + if (err instanceof DiscoveryError) { + if (err.type === DiscoveryErrorType.TIMEOUT) { + return 'Timed out — the tool may still be running on the backend.'; + } + return `Could not call tool: ${err.message}`; + } + return err instanceof Error ? err.message : String(err); } -const FETCH_TIMEOUT_MS = 15000; +/** Cycle a boolean/enum field's value with Space (parent-handled). */ +function cycleSelectValue(param: ToolParam, current: string): string { + const options = + param.kind === 'boolean' + ? [...(param.required ? [] : ['']), 'true', 'false'] + : [...(param.required ? [] : ['']), ...(param.enumValues ?? []).map((v) => String(v))]; + const idx = options.indexOf(current); + return options[(idx + 1) % options.length] ?? ''; +} + +/** + * Section header row. The bar glyph is the same whether or not the section has + * focus — only its color differs: the focused bar reads as plain text, the idle + * one is grey. Grey rather than `dimColor` on purpose: terminals that render + * faint cells by dimming them would paint a darker block behind the bar. + */ +const sectionRow = (label: string, width: number, focused: boolean): DetailRow => { + const text = sectionTitle(label, width); + if (!text.startsWith(SECTION_BAR)) { + return { type: 'text', text }; + } + return { + type: 'text', + text, + segments: [ + { text: SECTION_BAR, tone: focused ? 'value' : 'muted' }, + { text: text.slice(SECTION_BAR.length), tone: 'value' }, + ], + }; +}; export const ServiceTools: React.FC = ({ service, @@ -49,63 +199,110 @@ export const ServiceTools: React.FC = ({ terminalHeight: terminalHeightProp, }) => { const { stdout } = useStdout(); - const [tools, setTools] = useState([]); + const [tools, setTools] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); + const [focus, setFocus] = useState('list'); + const [fieldIndex, setFieldIndex] = useState(0); + const [panelScroll, setPanelScroll] = useState(0); + const [formValues, setFormValuesState] = useState>({}); + const [jsonText, setJsonTextState] = useState(() => JSON.stringify({}, null, 2)); + const [jsonError, setJsonError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + const [extraCount, setExtraCount] = useState(0); + const [runStatus, setRunStatus] = useState('editing'); + const [outcome, setOutcome] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [runDurationMs, setRunDurationMs] = useState(null); + const [resultView, setResultView] = useState('formatted'); + const [dumpPath, setDumpPath] = useState(null); + /** Hides the tool list so the result spans the full width and mouse-drag + * selection cannot pick up the neighbouring column. */ + const [fullWidth, setFullWidth] = useState(false); + const [copyNotice, setCopyNotice] = useState(null); + /** Result-line range selection (anchor + moving end), like a visual mode. */ + const [selectAnchor, setSelectAnchor] = useState(null); + const [selectCursor, setSelectCursor] = useState(null); + /** The result line ↑/↓ moves — shown with a `▸` marker while the region is + * focused, so the position stays visible without an active selection. */ + const [resultCursor, setResultCursor] = useState(null); + /** Show the whole tool description instead of the capped preview. Temporary + * by design: selecting another tool restores the capped default. */ + const [descExpanded, setDescExpanded] = useState(false); const [toolScrollOffset, setToolScrollOffset] = useState(0); const [searchQuery, setSearchQuery] = useState(''); const [searchMode, setSearchMode] = useState(false); + // Refs mirror the state so Ctrl+R reads the latest keystrokes even though + // the focused input's useInput fires first within the same keypress. + const formValuesRef = useRef(formValues); + const jsonTextRef = useRef(jsonText); + const extraArgsRef = useRef>({}); + /** Typed arguments per tool name, so reselecting a tool restores them. */ + const formCacheRef = useRef< + Map< + string, + { values: Record; jsonText: string; extra: Record } + > + >(new Map()); + const prevToolNameRef = useRef(undefined); + + const setFormValues = (next: Record): void => { + formValuesRef.current = next; + setFormValuesState(next); + }; + const setJsonText = (next: string): void => { + jsonTextRef.current = next; + setJsonTextState(next); + }; + const filteredTools = useMemo(() => { if (!searchQuery) return tools; const q = searchQuery.toLowerCase(); - return tools.filter(t => t.name.toLowerCase().includes(q)); + 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(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); - + // Contextual hint footer; the "Quick Actions:" title is dropped on tiny terminals. + const FOOTER_LINES = terminalHeight < 10 ? 2 : 3; + const ERROR_LINES = error !== null ? 1 : 0; + const BODY_LINES = Math.max(1, terminalHeight - HEADER_LINES - FOOTER_LINES - ERROR_LINES); + // Body = search bar + divider + the two side-by-side panels. + const PANEL_LINES = Math.max(1, BODY_LINES - 2); + const VISIBLE_TOOLS = Math.min(filteredTools.length, Math.max(1, PANEL_LINES - 1)); + const BORDER_PADDING = 4; const effectiveWidth = Math.max(terminalWidth - BORDER_PADDING, 50); const TOOL_WIDTH_RATIO = Math.min(0.5, Math.max(0.3, 40 / effectiveWidth)); const TOOLS_LIST_WIDTH = Math.floor(effectiveWidth * TOOL_WIDTH_RATIO); - const DESC_WIDTH = effectiveWidth - TOOLS_LIST_WIDTH; + // Full-width mode drops the tool list so the panel owns the entire row — + // mouse-drag selection then has no neighbouring column to pick up. + const PANEL_BOX_WIDTH = fullWidth + ? Math.max(20, terminalWidth - 2) + : effectiveWidth - TOOLS_LIST_WIDTH; + const PANEL_WIDTH = Math.max(10, PANEL_BOX_WIDTH - 2); + const RESULT_INNER = Math.max(8, PANEL_WIDTH - 2); - // 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; + // "▶ ✓ " occupies 5 cells (▶ is double-width); keep names inside the panel. + const TOOL_NAME_PREFIX_CELLS = 5; + const maxToolNameWidth = Math.max(8, TOOLS_LIST_WIDTH - TOOL_NAME_PREFIX_CELLS - 1); + const truncateToolName = (name: string): string => truncateDisplay(name, maxToolNameWidth); const currentTool = filteredTools[selectedIndex]; - const descriptionLines = currentTool?.description?.split('\n') || []; - const maxDescScroll = Math.max(0, descriptionLines.length - DESCRIPTION_CONTENT_LINES); + const params = useMemo( + () => buildToolParams(currentTool?.inputSchema), + [currentTool?.inputSchema] + ); // Calculate tool statistics - const enabledToolsCount = tools.filter(t => t.enabled).length; + const enabledToolsCount = tools.filter((t) => t.enabled).length; const totalToolsCount = tools.length; useEffect(() => { - setScrollOffset(0); - }, [selectedIndex]); - - // Reset selection when the filter changes so the index stays valid - useEffect(() => { - setSelectedIndex(0); - setToolScrollOffset(0); - }, [searchQuery]); - - useEffect(() => { - setToolScrollOffset(prev => { + setToolScrollOffset((prev) => { if (selectedIndex < prev) return selectedIndex; if (selectedIndex >= prev + VISIBLE_TOOLS) { return Math.max(0, selectedIndex - VISIBLE_TOOLS + 1); @@ -114,6 +311,43 @@ export const ServiceTools: React.FC = ({ }); }, [selectedIndex, VISIBLE_TOOLS]); + // Reset per-tool editor/run state when the selected tool changes, but carry + // the typed arguments over per tool so switching back restores them. + useEffect(() => { + const previous = prevToolNameRef.current; + if (previous !== undefined && previous !== currentTool?.name) { + formCacheRef.current.set(previous, { + values: formValuesRef.current, + jsonText: jsonTextRef.current, + extra: extraArgsRef.current, + }); + } + prevToolNameRef.current = currentTool?.name; + const restored = + currentTool === undefined ? undefined : formCacheRef.current.get(currentTool.name); + + setFocus('list'); + setFieldIndex(0); + setPanelScroll(0); + setDescExpanded(false); + setFormValues(restored?.values ?? seedFormValues(params)); + setJsonText(restored?.jsonText ?? JSON.stringify({}, null, 2)); + setJsonError(null); + setFieldErrors({}); + setExtraCount(restored === undefined ? 0 : Object.keys(restored.extra).length); + setRunStatus('editing'); + setOutcome(null); + setErrorMessage(null); + setRunDurationMs(null); + setResultView('formatted'); + setDumpPath(null); + setCopyNotice(null); + setSelectAnchor(null); + setSelectCursor(null); + setResultCursor(null); + extraArgsRef.current = restored?.extra ?? {}; + }, [currentTool?.name]); + useEffect(() => { const loadTools = async () => { setLoading(true); @@ -121,22 +355,28 @@ export const ServiceTools: React.FC = ({ try { const fetchedTools = await fetchServiceTools(service, FETCH_TIMEOUT_MS); - + if (fetchedTools.length > 0) { - setTools(fetchedTools.map(tool => ({ - ...tool, - enabled: toolStates[tool.name] ?? true, - }))); - + setTools( + fetchedTools.map((tool) => ({ + ...tool, + enabled: toolStates[tool.name] ?? true, + })) + ); + // Notify parent component of discovered tool count onToolsDiscovered?.(fetchedTools.length); } else if (Object.keys(toolStates).length > 0) { - setTools(Object.entries(toolStates).map(([name, enabled]) => ({ - name, - description: '', - inputSchema: { type: 'object', properties: {} }, - enabled, - }))); + setTools( + Object.entries(toolStates).map(([name, enabled]) => ({ + name, + namespacedName: `${service.name}__${name}`, + serviceName: service.name, + description: '', + inputSchema: { type: 'object' as const, properties: {} }, + enabled, + })) + ); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch tools'); @@ -145,16 +385,289 @@ export const ServiceTools: React.FC = ({ } }; - loadTools(); + void loadTools(); }, [service.name, service.url]); + const goToField = (index: number): void => { + const next = Math.max(0, Math.min(params.length - 1, index)); + setFieldIndex(next); + }; + + /** Page the panel by one viewport, clamped to the scrollable range. */ + const pagePanel = (direction: 1 | -1): void => { + setPanelScroll((prev) => { + const clamped = Math.min(prev, maxPanelScroll); + return Math.max(0, Math.min(maxPanelScroll, clamped + direction * FLOW_VISIBLE)); + }); + }; + + /** + * Tab order across the panel regions. Unavailable regions are skipped, so + * Tab only ever lands somewhere meaningful (no result → no result region). + */ + const cycleRegion = (direction: 1 | -1): void => { + const available: PanelFocus[] = ['list']; + if (params.length > 0) { + available.push('params'); + } + if (runStatus !== 'editing') { + available.push('result'); + } + const current = Math.max(0, available.indexOf(focus)); + const next = available[(current + direction + available.length) % available.length]; + if (next === undefined) { + return; + } + if (next === 'params') { + setFieldIndex((prev) => Math.max(0, Math.min(params.length - 1, prev))); + } + setFocus(next); + }; + + const runTool = (): void => { + if (runStatus === 'running' || currentTool === undefined) { + return; + } + let args: Record; + + if (focus !== 'json') { + const result = buildToolArguments(params, formValuesRef.current); + if (!result.ok) { + setFieldErrors(result.errors); + setFocus('params'); + const firstIdx = params.findIndex((p) => result.errors[p.name] !== undefined); + if (firstIdx >= 0) { + setFieldIndex(firstIdx); + } + return; + } + setFieldErrors({}); + args = { ...result.args, ...extraArgsRef.current }; + } else { + let parsed: unknown; + try { + parsed = JSON.parse(jsonTextRef.current); + } catch (err) { + setJsonError(err instanceof Error ? err.message : 'Invalid JSON'); + return; + } + if (!isRecord(parsed)) { + setJsonError('Arguments must be a JSON object'); + return; + } + setJsonError(null); + args = parsed; + } + + setRunStatus('running'); + setOutcome(null); + setErrorMessage(null); + setDumpPath(null); + setCopyNotice(null); + setSelectAnchor(null); + setSelectCursor(null); + setResultCursor(null); + const startedAt = Date.now(); + + void callServiceTool(service, currentTool.name, args, CALL_TIMEOUT_MS) + .then((res) => { + setRunDurationMs(Date.now() - startedAt); + setOutcome(res); + setRunStatus('done'); + // Reveal the output: focusing the result region also scrolls to it. + setFocus('result'); + }) + .catch((err: unknown) => { + setRunDurationMs(Date.now() - startedAt); + setErrorMessage(describeCallError(err)); + setRunStatus('done'); + setFocus('result'); + }); + }; + + /** Ctrl+J: project form → JSON, or parse JSON back into the form fields. */ + const toggleJsonMode = (): void => { + if (runStatus === 'running' || searchMode || params.length === 0) { + return; + } + if (focus !== 'json') { + setJsonText( + JSON.stringify( + { ...bestEffortArgs(params, formValuesRef.current), ...extraArgsRef.current }, + null, + 2 + ) + ); + setJsonError(null); + setFocus('json'); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(jsonTextRef.current); + } catch (err) { + setJsonError(err instanceof Error ? err.message : 'Invalid JSON — fix before switching'); + return; + } + if (!isRecord(parsed)) { + setJsonError('Arguments must be a JSON object — fix before switching'); + return; + } + const values = { ...formValuesRef.current }; + for (const key of Object.keys(values)) { + if (!(key in parsed)) { + values[key] = ''; + } + } + const extra: Record = {}; + let extraKeys = 0; + for (const [key, value] of Object.entries(parsed)) { + const param = params.find((p) => p.name === key); + if (param) { + values[key] = typeof value === 'string' ? value : JSON.stringify(value); + } else { + extra[key] = value; + extraKeys++; + } + } + extraArgsRef.current = extra; + setExtraCount(extraKeys); + setJsonError(null); + setFormValues(values); + setFocus('params'); + }; + + /** Toggle the tool list off so the result owns the full terminal width. */ + const toggleFullWidth = (): void => { + setFullWidth((prev) => !prev); + setFocus((prev) => + prev === 'list' || prev === 'result' ? (fullWidth ? 'list' : 'result') : prev + ); + }; + + /** Index of the first result line at or below the viewport top. */ + const firstVisibleResultIndex = (): number => { + for (let i = clampedPanelScroll; i < flowRows.length; i++) { + const row = flowRows[i]; + if (row?.type === 'text' && row.resultIndex !== undefined) { + return row.resultIndex; + } + } + return 0; + }; + + /** The highlighted result-line range, or null when nothing is selected. */ + const selectedRange = (): { from: number; to: number } | null => { + if (selectAnchor === null || selectCursor === null) { + return null; + } + return { + from: Math.min(selectAnchor, selectCursor), + to: Math.max(selectAnchor, selectCursor), + }; + }; + + /** Start or cancel a result-line range selection. */ + const toggleSelection = (): void => { + if (runStatus !== 'done' || resultLines.length === 0) { + return; + } + if (selectAnchor !== null) { + setSelectAnchor(null); + setSelectCursor(null); + return; + } + // Anchor where the cursor sits (it defaults to the first visible line). + const start = resultCursor ?? firstVisibleResultIndex(); + setSelectAnchor(start); + setSelectCursor(start); + setCopyNotice(null); + }; + + /** + * Copy the highlighted lines — or the whole result when nothing is selected. + * "Copy all" uses the UNWRAPPED text so pasted data has no display line + * breaks; a range copies exactly the lines that were highlighted. + */ + const copyResult = (): void => { + if (runStatus !== 'done' || resultLines.length === 0) { + return; + } + const range = selectedRange(); + const text = + range === null ? resultText : resultLines.slice(range.from, range.to + 1).join('\n'); + if (text === '') { + setCopyNotice('Nothing to copy'); + return; + } + const lineCount = range === null ? resultText.split('\n').length : range.to - range.from + 1; + setCopyNotice( + copyToClipboard(text) + ? `✓ Copied ${lineCount} line(s) to the clipboard` + : '✗ No clipboard utility found (pbcopy/xclip)' + ); + }; + + /** Write the full raw result to a temp file (unaffected by display width). */ + const dumpOutcome = (): void => { + if (runStatus !== 'done' || outcome === null) { + return; + } + try { + const dir = mkdtempSync(join(tmpdir(), 'onemcp-call-')); + const file = join(dir, `${sanitizeFileName(currentTool?.name ?? 'tool')}.json`); + writeFileSync(file, outcome.raw, 'utf8'); + setDumpPath(file); + } catch { + setDumpPath(null); + } + }; + useInput((input, key) => { + // --- Control chords: active in every focus, including search mode --- + if (input === 'r' && key.ctrl) { + runTool(); + return; + } + if (isCtrlJ(input, key)) { + toggleJsonMode(); + return; + } + if (input === 'e' && key.ctrl) { + setDescExpanded((prev) => !prev); + return; + } + if (input === 'p' && key.ctrl && runStatus === 'done' && outcome !== null) { + setResultView((prev) => (prev === 'formatted' ? 'raw' : 'formatted')); + return; + } + if (input === 'o' && key.ctrl && runStatus === 'done' && outcome !== null) { + dumpOutcome(); + return; + } + if (input === 'y' && key.ctrl) { + copyResult(); + return; + } + // Panel paging — PageUp/PageDown, with Ctrl+U/Ctrl+D as a fallback for + // terminals that do not send the page-key sequences. Deliberately ungated: + // paging must work before a tool has ever been run. + if (key.pageUp || (key.ctrl && input === 'u')) { + setPanelScroll((prev) => Math.max(0, Math.min(prev, maxPanelScroll) - FLOW_VISIBLE)); + return; + } + if (key.pageDown || (key.ctrl && input === 'd')) { + setPanelScroll((prev) => + Math.min(maxPanelScroll, Math.min(prev, maxPanelScroll) + FLOW_VISIBLE) + ); + return; + } + // --- 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. + // (handled below) clears the query, a third returns to the services. setSearchMode(false); return; } @@ -163,117 +676,533 @@ export const ServiceTools: React.FC = ({ return; } if (key.upArrow) { - setSelectedIndex(prev => Math.max(0, prev - 1)); + setSelectedIndex((prev) => Math.max(0, prev - 1)); return; } if (key.downArrow) { - setSelectedIndex(prev => Math.max(0, Math.min(filteredTools.length - 1, prev + 1))); + setSelectedIndex((prev) => Math.max(0, Math.min(filteredTools.length - 1, prev + 1))); return; } if (key.backspace || key.delete) { - setSearchQuery(prev => prev.slice(0, -1)); + 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); + if (input && input.length === 1 && input >= ' ' && input !== '/' && !key.ctrl) { + setSearchQuery((prev) => prev + input); return; } return; } - // --- Navigation mode --- - if (input === '/') { - setSearchMode(true); - return; - } + // --- Layered Esc: cancel selection → leave region → clear filter → back --- if (key.escape) { - // Layered Esc: a lingering filter clears first, then we go back. - if (searchQuery) { + if (runStatus === 'running') { + return; + } + if (selectAnchor !== null) { + setSelectAnchor(null); + setSelectCursor(null); + } else if (focus !== 'list') { + setFocus('list'); + } else if (searchQuery) { setSearchQuery(''); } else { onBack(); } return; } + + // Full width: hide the tool list so the result spans the whole row. + if (input === 'f' && !key.ctrl && (focus === 'list' || focus === 'result')) { + toggleFullWidth(); + return; + } + + // --- JSON focus: editing keys belong to JsonTextArea --- + if (focus === 'json') { + return; + } + + // --- Parameter focus: ↑/↓ change the expanded parameter; the mounted + // input owns letters, ←/→ (cursor) and Enter. --- + if (focus === 'params') { + if (key.tab) { + cycleRegion(key.shift ? -1 : 1); + return; + } + if (key.upArrow) { + goToField(fieldIndex - 1); + return; + } + if (key.downArrow || key.return) { + goToField(fieldIndex + 1); + return; + } + const currentParam = params[fieldIndex]; + if ( + input === ' ' && + currentParam !== undefined && + (currentParam.kind === 'boolean' || currentParam.enumValues !== undefined) + ) { + const next = cycleSelectValue(currentParam, formValuesRef.current[currentParam.name] ?? ''); + setFormValues({ ...formValuesRef.current, [currentParam.name]: next }); + } + return; + } + + // --- Result focus: ↑/↓ move the line cursor (extending the selection while + // one is active), ←/→ page, v selects a range from the cursor --- + if (focus === 'result') { + if (key.tab) { + cycleRegion(key.shift ? -1 : 1); + return; + } + const selecting = selectAnchor !== null; + if (input === 'v' && !key.ctrl) { + toggleSelection(); + return; + } + const hasLines = resultLines.length > 0; + // While a range is active the moving end is the selection cursor; + // otherwise it is the plain line cursor. + const cursor = (selecting ? selectCursor : resultCursor) ?? firstVisibleResultIndex(); + const move = (delta: number): void => { + if (!hasLines) { + return; + } + const next = Math.max(0, Math.min(resultLines.length - 1, cursor + delta)); + if (selecting) { + setSelectCursor(next); + } else { + setResultCursor(next); + } + }; + if (key.upArrow) { + move(-1); + } else if (key.downArrow) { + move(1); + } else if (key.leftArrow) { + // Paging drags the cursor along, otherwise the reveal effect would + // yank the view straight back to the cursor. + move(-FLOW_VISIBLE); + pagePanel(-1); + } else if (key.rightArrow) { + move(FLOW_VISIBLE); + pagePanel(1); + } + return; + } + + // --- List focus --- + if (input === '/') { + setSearchMode(true); + return; + } + if (key.tab) { + cycleRegion(key.shift ? -1 : 1); + return; + } if (key.upArrow) { - setSelectedIndex(prev => Math.max(0, prev - 1)); + setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setSelectedIndex(prev => Math.max(0, Math.min(filteredTools.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)); + pagePanel(-1); } else if (key.rightArrow) { - setScrollOffset(prev => Math.min(maxDescScroll, prev + 1)); - } else if (input === ' ' || input === 't') { + pagePanel(1); + } else if ((input === ' ' || input === 't') && !key.ctrl) { const tool = filteredTools[selectedIndex]; if (tool) { const newEnabled = !tool.enabled; onToggleTool(tool.name, newEnabled); - setTools(prev => prev.map(t => - t.name === tool.name ? { ...t, enabled: newEnabled } : t - )); + setTools((prev) => + prev.map((t) => (t.name === tool.name ? { ...t, enabled: newEnabled } : t)) + ); } - } else if (input === 'a') { - const toolsToEnable = filteredTools.filter(t => !t.enabled).map(t => t.name); + } else if (input === 'a' && !key.ctrl) { + 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 => + const filteredNames = new Set(filteredTools.map((t) => t.name)); + const applyEnable = (t: Tool): Tool => filteredNames.has(t.name) ? { ...t, enabled: true } : t; if (onBatchToggleTools) { const batchToolStates: Record = {}; - toolsToEnable.forEach(toolName => { + toolsToEnable.forEach((toolName) => { batchToolStates[toolName] = true; }); onBatchToggleTools(batchToolStates); - setTools(prev => prev.map(applyEnable)); + setTools((prev) => prev.map(applyEnable)); } else { - setTools(prev => prev.map(applyEnable)); - toolsToEnable.forEach(toolName => onToggleTool(toolName, true)); + setTools((prev) => prev.map(applyEnable)); + toolsToEnable.forEach((toolName) => onToggleTool(toolName, true)); } } } else if (input === 'A') { - const toolsToDisable = filteredTools.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 => + const filteredNames = new Set(filteredTools.map((t) => t.name)); + const applyDisable = (t: Tool): Tool => filteredNames.has(t.name) ? { ...t, enabled: false } : t; if (onBatchToggleTools) { const batchToolStates: Record = {}; - toolsToDisable.forEach(toolName => { + toolsToDisable.forEach((toolName) => { batchToolStates[toolName] = false; }); onBatchToggleTools(batchToolStates); - setTools(prev => prev.map(applyDisable)); + setTools((prev) => prev.map(applyDisable)); } else { - setTools(prev => prev.map(applyDisable)); - toolsToDisable.forEach(toolName => onToggleTool(toolName, false)); + setTools((prev) => prev.map(applyDisable)); + toolsToDisable.forEach((toolName) => onToggleTool(toolName, false)); } } } }); - const endpointInfo = service.transport === 'stdio' - ? ((service.command || '') + (service.args?.length ? ' ' + service.args.join(' ') : '')) - : (service.url || 'N/A'); + // --- Flattened detail rows (description → parameters; result is pinned) --- + + const descCap = Math.min(12, Math.max(3, Math.floor(PANEL_LINES * 0.35))); + const descRows: DetailRow[] = useMemo(() => { + const rows: DetailRow[] = []; + const wrapped = wrapText( + currentTool?.description ?? '', + PANEL_WIDTH - DESCRIPTION_INDENT.length + ); + if (wrapped.length === 0 || (wrapped.length === 1 && wrapped[0] === '')) { + rows.push({ type: 'text', text: `${DESCRIPTION_INDENT}(no description)` }); + } else if (!descExpanded && wrapped.length > descCap) { + for (const line of wrapped.slice(0, descCap)) { + rows.push({ type: 'text', text: `${DESCRIPTION_INDENT}${line}` }); + } + rows.push({ + type: 'text', + text: `${DESCRIPTION_INDENT}… ${wrapped.length - descCap} more line(s) — Ctrl+E expands`, + }); + } else { + for (const line of wrapped) { + rows.push({ type: 'text', text: `${DESCRIPTION_INDENT}${line}` }); + } + } + return rows; + }, [currentTool?.description, PANEL_WIDTH, descCap, descExpanded]); + + // Only the parameter region expands a field; browsing (list focus) keeps the + // compact one-line-per-parameter overview. + const expandedParamName = focus === 'params' ? (params[fieldIndex]?.name ?? null) : null; + const paramRows: DetailRow[] = useMemo( + () => + buildParamRows(params, formValues, PANEL_WIDTH, expandedParamName).map((row): DetailRow => { + if (row.kind !== 'text') { + return { type: row.kind, param: row.param }; + } + return { + type: 'text', + text: row.text, + ...(row.segments !== undefined ? { segments: row.segments } : {}), + ...(row.anchor !== undefined ? { anchor: row.anchor } : {}), + }; + }), + [params, formValues, PANEL_WIDTH, expandedParamName] + ); + + /** One-line status headline for the result box. */ + const resultLabel = (() => { + const duration = runDurationMs === null ? '' : ` ${runDurationMs}ms`; + if (runStatus === 'running') { + return `Result: running…`; + } + if (errorMessage !== null) { + return `Result: ✗ failed${duration}`; + } + if (outcome?.isError) { + return `Result: ✗ tool reported an error${duration}`; + } + return `Result: ✓${duration}`; + })(); + + /** Description + parameters + validation rows (the scrollable flow). */ + const allRowsBase: DetailRow[] = useMemo( + () => [ + sectionRow('Description', PANEL_WIDTH, focus === 'list'), + { type: 'text' as const, text: ' ' }, + ...descRows, + { type: 'text' as const, text: ' ' }, + sectionRow(`Parameters (${params.length})`, PANEL_WIDTH, focus === 'params'), + { type: 'text' as const, text: ' ' }, + ...paramRows, + ...(Object.keys(fieldErrors).length > 0 + ? Object.entries(fieldErrors).map( + ([name, message]): DetailRow => ({ + type: 'text' as const, + text: ` ✗ ${name}: ${message}`, + }) + ) + : []), + ...(extraCount > 0 + ? [ + { + type: 'text' as const, + text: ` ${extraCount} extra key(s) from JSON merged on run`, + } satisfies DetailRow, + ] + : []), + ], + [descRows, paramRows, params.length, fieldErrors, extraCount, PANEL_WIDTH, focus] + ); + + /** The result text exactly as produced (unwrapped) — what "copy all" copies. */ + const resultText: string = useMemo(() => { + if (runStatus !== 'done' || outcome === null) { + return ''; + } + const base = resultView === 'raw' ? outcome.raw : outcome.formatted; + return base.length > RESULT_MAX_CHARS ? base.slice(0, RESULT_MAX_CHARS) : base; + }, [runStatus, outcome, resultView]); + + /** Result content wrapped for the panel — also the unit of range selection. */ + const resultLines: string[] = useMemo(() => { + if (runStatus === 'editing') { + return []; + } + let body = ''; + if (runStatus === 'running') { + body = `Running ${currentTool?.namespacedName ?? ''}…`; + } else if (errorMessage !== null) { + body = errorMessage; + } else { + body = resultText; + } + // Every line is kept — rendering is already bounded by the viewport slice, + // so capping here would only make output unreachable. + const lines = wrapText(body, RESULT_INNER); + if (dumpPath !== null) { + lines.push(`Saved full output: ${truncateDisplay(dumpPath, RESULT_INNER - 20)}`); + } + return lines; + }, [runStatus, errorMessage, resultText, dumpPath, RESULT_INNER, currentTool?.namespacedName]); + + /** The result section: section header, then a framed block of output rows. */ + const resultRows: DetailRow[] = useMemo(() => { + if (resultLines.length === 0) { + return []; + } + return [ + { type: 'text' as const, text: ' ' }, + sectionRow('Output', PANEL_WIDTH, focus === 'result'), + { type: 'text' as const, text: ' ' }, + { type: 'text' as const, text: boxTop(resultLabel, RESULT_INNER) }, + ...resultLines.map( + (line, index): DetailRow => ({ + type: 'text' as const, + text: boxRow(line, RESULT_INNER), + resultIndex: index, + }) + ), + { type: 'text' as const, text: boxBottom('', RESULT_INNER) }, + ]; + }, [resultLines, RESULT_INNER, PANEL_WIDTH, focus, resultLabel]); + + const flowRows: DetailRow[] = useMemo( + () => [...allRowsBase, ...resultRows], + [allRowsBase, resultRows] + ); + /** Where the result section starts — used to reveal it. */ + const resultStartIndex = allRowsBase.length; + + const FLOW_VISIBLE = Math.max(1, PANEL_LINES - 1); // reserve the indicator row + const maxPanelScroll = Math.max(0, flowRows.length - FLOW_VISIBLE); + // Clamp at render time — PANEL_LINES shrinks while a status message is + // visible, so an effect-based clamp would leave a blank panel for ~2s. + const clampedPanelScroll = Math.min(panelScroll, maxPanelScroll); + + /** + * Keep the expanded parameter visible while navigating it. + * + * Collapsing makes `flowRows.length` focus-dependent, so the previous + * absolute-index math would scroll BACKWARDS when the block height changed. + * Instead: recompute against this render's rows and only move when the + * parameter's whole block is not currently on screen. + */ + useEffect(() => { + if (focus !== 'params' || runStatus !== 'editing') { + return; + } + const name = params[fieldIndex]?.name; + if (name === undefined) { + return; + } + const anchor = flowRows.findIndex((r) => r.type === 'text' && r.anchor === name); + if (anchor < 0) { + return; + } + const nextName = params[fieldIndex + 1]?.name; + const nextAnchor = + nextName === undefined + ? -1 + : flowRows.findIndex((r) => r.type === 'text' && r.anchor === nextName); + const blockEnd = nextAnchor > anchor ? nextAnchor : anchor + 1; + setPanelScroll((prev) => { + const clamped = Math.min(prev, maxPanelScroll); + const fullyVisible = anchor >= clamped && blockEnd <= clamped + FLOW_VISIBLE; + return fullyVisible ? clamped : Math.min(maxPanelScroll, anchor); + }); + }, [focus, fieldIndex, params, flowRows, maxPanelScroll, FLOW_VISIBLE, runStatus]); + + /** Focusing the result region reveals it and seeds the line cursor. */ + useEffect(() => { + if (focus !== 'result') { + return; + } + setPanelScroll(Math.min(Math.max(0, resultStartIndex), maxPanelScroll)); + setResultCursor((prev) => { + if (resultLines.length === 0) { + return null; + } + return prev !== null && prev < resultLines.length ? prev : 0; + }); + }, [focus, resultStartIndex, maxPanelScroll, resultLines.length]); + + /** Keep the line cursor (or the selection end) on screen. */ + useEffect(() => { + if (focus !== 'result') { + return; + } + const target = selectCursor ?? resultCursor; + if (target === null) { + return; + } + const rowIndex = flowRows.findIndex((r) => r.type === 'text' && r.resultIndex === target); + if (rowIndex < 0) { + return; + } + setPanelScroll((prev) => { + const clamped = Math.min(prev, maxPanelScroll); + if (rowIndex >= clamped && rowIndex < clamped + FLOW_VISIBLE) { + return clamped; + } + return Math.max(0, Math.min(maxPanelScroll, rowIndex - Math.floor(FLOW_VISIBLE / 2))); + }); + }, [focus, selectCursor, resultCursor, flowRows, maxPanelScroll, FLOW_VISIBLE]); + + /** The copy notice is transient. */ + useEffect(() => { + if (copyNotice === null) { + return; + } + const timer = setTimeout(() => setCopyNotice(null), 4000); + return () => clearTimeout(timer); + }, [copyNotice]); + + const endpointInfo = + service.transport === 'stdio' + ? (service.command || '') + (service.args?.length ? ' ' + service.args.join(' ') : '') + : service.url || 'N/A'; if (loading) { return ( - Tools for: {service.name} - Transport: {service.transport} | {endpointInfo} + + Tools for: {service.name} + + + Transport: {service.transport} | {endpointInfo} + Fetching tools from service... ); } + const renderDetailRow = (row: DetailRow, key: string) => { + const range = selectedRange(); + if (row.type === 'text' && row.resultIndex !== undefined) { + // Result content rows are split so the selection background covers only + // the actual text — never the `│` frame or the row's trailing padding, + // which would otherwise turn a blank result line into a solid bar. + const line = resultLines[row.resultIndex] ?? ''; + const fitted = truncateDisplay(line, RESULT_INNER); + const padding = ' '.repeat(Math.max(0, RESULT_INNER - displayWidth(fitted))); + const highlighted = + range !== null && + row.resultIndex >= range.from && + row.resultIndex <= range.to && + fitted.trim() !== ''; + // The cursor replaces the left frame cell (`▸` is single-width, so the + // box stays aligned) — visible only while the region has focus. + const cursorHere = focus === 'result' && row.resultIndex === resultCursor; + return ( + + {cursorHere ? '▸' : '│'} + {fitted} + {padding} + {'│'} + + ); + } + if (row.type === 'input') { + const param = row.param; + return ( + + {DETAIL_VALUE_INDENT} + { + setFormValues({ ...formValuesRef.current, [param.name]: next }); + }} + /> + + ); + } + if (row.type === 'select') { + const value = formatParamValue(row.param, formValues); + const options = + row.param.kind === 'boolean' + ? row.param.required + ? 'true/false' + : '(unset)/true/false' + : (row.param.enumValues ?? []).map((v) => String(v)).join('/'); + return ( + + {DETAIL_VALUE_INDENT} + {value} + {` ◂ Space cycles: ${truncateDisplay(options, PANEL_WIDTH - 26)}`} + + ); + } + if (row.segments !== undefined) { + // One wrapping parent so truncation and line-breaking stay row-level; + // children only contribute color (the harness drops SGR entirely). + return ( + + {row.segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); + } + return ( + + {row.text} + + ); + }; + return ( - Tools for: {service.name} + + Tools for: {service.name} + {totalToolsCount > 0 && ( @@ -288,14 +1217,12 @@ export const ServiceTools: React.FC = ({ )} - Transport: {service.transport} | {endpointInfo} + + Transport: {service.transport} | {endpointInfo} + - {error && ( - - {error} - - )} + {error && {truncateDisplay(error, effectiveWidth)}} {tools.length === 0 ? ( @@ -303,9 +1230,9 @@ 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')} + : 'Service URL not configured - tools can only be discovered when service is reachable'} {Object.keys(toolStates).length > 0 && ( @@ -313,10 +1240,7 @@ export const ServiceTools: React.FC = ({ {Object.entries(toolStates).map(([name, enabled]) => ( - - {enabled ? '+' : '-'} - - {' '}{name} + {enabled ? '+' : '-'} {name} ))} @@ -325,17 +1249,20 @@ export const ServiceTools: React.FC = ({ ) : ( - {/* Search bar */} + {/* Search bar + divider */} - 🔍 + + 🔍{' '} + {searchMode || searchQuery ? ( <> Search: {searchQuery} {searchMode && _} - {' '}[{filteredTools.length}/{totalToolsCount} matched] + {' '} + [{filteredTools.length}/{totalToolsCount} matched] ) : ( @@ -343,54 +1270,87 @@ export const ServiceTools: React.FC = ({ )} + + {'─'.repeat(Math.min(effectiveWidth, 200))} + - - - {filteredTools.length === 0 ? ( - - No tools match "{searchQuery}" - - ) : ( - <> - {filteredTools.slice(toolScrollOffset, toolScrollOffset + VISIBLE_TOOLS).map((tool, index) => ( - - - {index === selectedIndex - toolScrollOffset ? '▶ ' : ' '} - - {tool.enabled ? '✓' : '✗'} - - {' '} + + {!fullWidth && ( + + {filteredTools.length === 0 ? ( + No tools match "{searchQuery}" + ) : ( + <> + {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`} - {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 ? '↓' : ''} + + {focus === 'json' ? ( + + + Arguments (raw JSON): - + + { + setJsonText(next); + }} + height={Math.max(1, Math.min(12, PANEL_LINES - 4))} + /> + + {jsonError !== null && ( + ✗ JSON: {truncateDisplay(jsonError, PANEL_WIDTH - 8)} + )} + ) : ( - No description + <> + {flowRows + .slice(clampedPanelScroll, clampedPanelScroll + FLOW_VISIBLE) + .map((row, i) => + renderDetailRow(row, `row-${clampedPanelScroll + i}-${row.type}`) + )} + {scrollHint(clampedPanelScroll, maxPanelScroll)} + )} @@ -398,15 +1358,39 @@ export const ServiceTools: React.FC = ({ )} - Quick Actions: - - {' '}↑/↓: Navigate • Space/T: Toggle tool • /: Search{searchMode ? ' (Enter to confirm)' : ''} - - - {' '}a: Enable {searchQuery ? 'filtered' : 'all'} • A: Disable {searchQuery ? 'filtered' : 'all'} + {terminalHeight >= 10 && ( + + Quick Actions: + + )} + + {' '} + {searchMode + ? 'Type to search • ↑/↓ Navigate matches • Enter Confirm' + : focus === 'params' + ? '↑/↓ Param • ←/→ Cursor • Space Cycle option • Enter Next' + : focus === 'result' + ? `↑/↓ Cursor • ←/→ Page • v ${ + selectAnchor !== null ? 'Cancel select' : 'Select lines' + } • f Full width` + : focus === 'json' + ? 'Edit raw JSON arguments' + : '↑/↓ Navigate • Space Toggle • a/A All on/off • / Search'} - - {' '}←/→: Scroll description • Esc: {searchQuery ? 'Clear search' : 'Return to service list'} + + {' '} + {copyNotice ?? + (searchMode + ? 'Ctrl+R Run • Esc Leave search' + : focus === 'params' || focus === 'json' + ? 'Tab Next region • Ctrl+R Run • Ctrl+J JSON • Esc Done' + : focus === 'result' + ? selectAnchor !== null + ? 'Ctrl+Y Copy selection • Esc Cancel' + : 'Ctrl+Y Copy result • Ctrl+P Raw • Ctrl+O Save' + : `←/→ Page • Tab Region • Ctrl+R Run • f Full width • Ctrl+E ${ + descExpanded ? 'Collapse' : 'Expand' + } desc`)} diff --git a/src/tui/components/SingleLineInput.tsx b/src/tui/components/SingleLineInput.tsx new file mode 100644 index 0000000..045a69b --- /dev/null +++ b/src/tui/components/SingleLineInput.tsx @@ -0,0 +1,112 @@ +/** + * TUI single-line controlled text input + * + * Used by the ServiceTools detail panel for scalar parameters. Unlike + * ink-text-input, this component explicitly ignores control chords + * (Ctrl+R/J/O/P), Tab, and Enter, so control characters can never be inserted + * into the field value — the parent component owns those keys exclusively + * (its useInput fires after this child's, which is safe because nothing is + * inserted here for chords). + * + * Renders at most ONE terminal row: when `width` is given, the value is shown + * through a cursor-following window and overlong segments are truncated. + */ + +import React, { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; + +export interface SingleLineInputProps { + value: string; + onChange: (next: string) => void; + placeholder?: string; + /** Render width; the value is windowed to fit one terminal row. */ + width?: number; +} + +export const SingleLineInput: React.FC = ({ + value, + onChange, + placeholder, + width, +}) => { + const [cursor, setCursor] = useState(value.length); + const clampedCursor = Math.min(cursor, value.length); + + useInput((input, key) => { + if (key.ctrl || key.escape || key.tab || key.return || key.upArrow || key.downArrow) { + return; + } + + if (key.leftArrow) { + setCursor((prev) => Math.max(0, Math.min(prev, value.length) - 1)); + return; + } + if (key.rightArrow) { + setCursor((prev) => Math.min(value.length, Math.min(prev, value.length) + 1)); + return; + } + if (key.backspace || key.delete) { + if (clampedCursor > 0) { + onChange(value.slice(0, clampedCursor - 1) + value.slice(clampedCursor)); + setCursor(clampedCursor - 1); + } + return; + } + + // Printable character + if (input && input.length === 1 && input >= ' ' && input !== '\n') { + onChange(value.slice(0, clampedCursor) + input + value.slice(clampedCursor)); + setCursor(clampedCursor + input.length); + } + }); + + const showPlaceholder = value === '' && placeholder !== undefined && placeholder !== ''; + + // Cursor-following window so the input always occupies exactly one row. + const windowed = + width === undefined + ? null + : (() => { + const w = Math.max(4, width); + const winStart = Math.max(0, Math.min(clampedCursor - (w - 2), value.length)); + return { + start: winStart, + before: value.slice(winStart, clampedCursor), + at: value.slice(clampedCursor, clampedCursor + 1), + after: value.slice(clampedCursor + 1, winStart + w - 1), + clippedLeft: winStart > 0, + clippedRight: winStart + w - 1 < value.length, + }; + })(); + + return ( + + {showPlaceholder ? ( + <> + + {placeholder} + + + + ) : windowed !== null ? ( + <> + {windowed.clippedLeft && {'…'}} + {windowed.before} + {windowed.at === '' ? ' ' : windowed.at} + {windowed.after} + {windowed.clippedRight && {'…'}} + + ) : ( + <> + {value.slice(0, clampedCursor)} + + {value.slice(clampedCursor, clampedCursor + 1) === '' + ? ' ' + : value.slice(clampedCursor, clampedCursor + 1)} + + {value.slice(clampedCursor + 1)} + + )} + + ); +}; diff --git a/src/tui/discovery-worker.ts b/src/tui/discovery-worker.ts index ca595a7..65edb60 100644 --- a/src/tui/discovery-worker.ts +++ b/src/tui/discovery-worker.ts @@ -7,6 +7,8 @@ import EventSource from 'eventsource'; import { StdioTransport } from '../transport/stdio.js'; import { isSessionExpiryError } from '../routing/session-error.js'; import { getPackageVersion } from '../utils/package-version.js'; +import { isRecord } from './tool-param-schema.js'; +import type { JsonRpcMessage } from '../types/jsonrpc.js'; import type { ServiceDefinition } from '../types/service.js'; import type { Tool } from '../types/tool.js'; @@ -95,12 +97,107 @@ function parseCommandString(command: string): { command: string; args: string[] } /** - * Discover tools via stdio transport + * A request sender bound to an established MCP session (stdio process, + * Streamable HTTP session id, or SSE endpoint). Each call sends one JSON-RPC + * request and resolves with the raw response message (which may carry an + * `error` field — classification is the caller's job). */ -async function discoverToolsViaStdio(service: ServiceDefinition, timeout: number): Promise { - if (service.command === undefined || service.command === null) { +type SessionRequest = (msg: Record) => Promise>; + +const MCP_PROTOCOL_VERSION = '2024-11-05'; + +function initializeParams(): Record { + return { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'onemcp-tui', + version: getPackageVersion(), + }, + }; +} + +function isTimeoutError(err: unknown): boolean { + return err instanceof Error && err.message.toLowerCase().includes('timeout'); +} + +/** + * Parse an HTTP response body that is either a bare JSON-RPC message or an + * SSE-framed stream (`event:` / `data:` lines) containing one. + */ +function parseJsonRpcBody(text: string): Record { + if (text.startsWith('event:') || text.includes('\ndata: ')) { + for (const line of text.split('\n')) { + if (line.startsWith('data: ')) { + return JSON.parse(line.slice(6)) as Record; + } + } + return {}; + } + return JSON.parse(text) as Record; +} + +/** + * Map a tools/list result payload to Tool records (empty when absent). + */ +function mapRawTools(result: unknown, service: ServiceDefinition): Tool[] { + if (typeof result !== 'object' || result === null) { return []; } + const tools = (result as Record)['tools']; + if (!Array.isArray(tools)) { + return []; + } + return tools.map((t) => { + const raw = t as { name: string; description?: string; inputSchema?: unknown }; + return { + name: raw.name, + namespacedName: `${service.name}__${raw.name}`, + serviceName: service.name, + description: raw.description || '', + inputSchema: (raw.inputSchema as Tool['inputSchema']) || { + type: 'object', + properties: {}, + }, + enabled: true, + }; + }); +} + +/** + * Send tools/list over an established session and map the response. + */ +async function discoverToolsList( + send: SessionRequest, + service: ServiceDefinition +): Promise { + const resp = await send({ + jsonrpc: '2.0', + id: `tools-${Date.now()}`, + method: 'tools/list', + params: {}, + }); + if (resp['error'] !== undefined) { + throw new Error( + `tools/list failed: ${String((resp['error'] as Record)['message'] ?? 'unknown error')}` + ); + } + return mapRawTools(resp['result'], service); +} + +/** + * Shared stdio session: spawn process → initialize → notifications/initialized, + * then hand a request sender to `fn` and close the process afterwards. + * Errors propagate raw — callers decide the classification. + */ +async function stdioSession( + service: ServiceDefinition, + timeout: number, + fn: (sendRequest: SessionRequest) => Promise +): Promise { + if (service.command === undefined || service.command === null) { + throw new Error('Service has no command configured'); + } // Parse command string if args not provided or command contains spaces let command: string; @@ -152,20 +249,12 @@ async function discoverToolsViaStdio(service: ServiceDefinition, timeout: number ]); // Send initialize request - const initRequest = { + await transport.send({ jsonrpc: '2.0' as const, id: `init-${Date.now()}`, method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { - name: 'onemcp-tui', - version: getPackageVersion(), - }, - }, - }; - await transport.send(initRequest); + params: initializeParams(), + }); // Wait for initialize response const initIter = transport.receive(); @@ -188,66 +277,18 @@ async function discoverToolsViaStdio(service: ServiceDefinition, timeout: number params: {}, }); - // Send tools/list request - const toolsRequest = { - jsonrpc: '2.0' as const, - id: `tools-${Date.now()}`, - method: 'tools/list', - params: {}, - }; - await transport.send(toolsRequest); - - // Wait for tools response - const toolsIter = transport.receive(); - const toolsResult = await toolsIter.next(); - - if (toolsResult.value === undefined || toolsResult.value === null) { - throw new Error('No response for tools/list request'); - } - - if ('error' in toolsResult.value) { - throw new Error( - `tools/list failed: ${(toolsResult.value as { error: { message: string } }).error.message}` - ); - } - - const result = toolsResult.value as { - result?: { tools?: Array<{ name: string; description?: string; inputSchema?: unknown }> }; + const boundTransport = transport; + const sendRequest: SessionRequest = async (msg) => { + await boundTransport.send(msg as unknown as JsonRpcMessage); + const iter = boundTransport.receive(); + const res = await iter.next(); + if (res.value === undefined || res.value === null) { + throw new Error(`No response for ${String(msg['method'])} request`); + } + return res.value as Record; }; - if (result.result?.tools !== undefined && result.result.tools !== null) { - return result.result.tools.map((t) => ({ - name: t.name, - namespacedName: `${service.name}__${t.name}`, - serviceName: service.name, - description: t.description || '', - inputSchema: (t.inputSchema as { - type: 'object'; - properties: Record; - required?: string[]; - }) || { - type: 'object', - properties: {}, - }, - enabled: true, - })); - } - return []; - } catch (err) { - if (err instanceof Error && err.message.includes('timeout')) { - throw new DiscoveryError( - DiscoveryErrorType.TIMEOUT, - service.name, - `Discovery timeout after ${timeout}ms`, - err - ); - } - throw new DiscoveryError( - DiscoveryErrorType.CONNECTION_FAILED, - service.name, - err instanceof Error ? err.message : String(err), - err instanceof Error ? err : undefined - ); + return await fn(sendRequest); } finally { if (transport !== null) { try { @@ -260,32 +301,133 @@ async function discoverToolsViaStdio(service: ServiceDefinition, timeout: number } /** - * Discover tools via standard MCP SSE transport (two-phase handshake). - * - * Protocol: + * Shared Streamable HTTP session: POST initialize (capture mcp-session-id) → + * notifications/initialized, then hand a request sender to `fn`. + * Errors propagate raw — callers decide the classification. + */ +async function httpSession( + service: ServiceDefinition, + timeout: number, + fn: (sendRequest: SessionRequest) => Promise +): Promise { + if (service.url === undefined || service.url === null) { + throw new Error('Service has no URL configured'); + } + const url = service.url; + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }; + + if (service.headers !== undefined && service.headers !== null) { + Object.assign(headers, service.headers); + } + + // Initialize with timeout + const initResponse = await Promise.race([ + fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: `init-${Date.now()}`, + method: 'initialize', + params: initializeParams(), + }), + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection timeout')), timeout) + ), + ]); + + const sessionId = initResponse.headers.get('mcp-session-id') || undefined; + const initData = parseJsonRpcBody(await initResponse.text()); + + if (initData['error'] !== undefined) { + throw new Error( + String((initData['error'] as Record)['message'] ?? 'initialize failed') + ); + } + + // Send initialized notification (no id field - it's a notification, not a request) + await fetch(url, { + method: 'POST', + headers: { + ...headers, + ...(sessionId !== undefined ? { 'mcp-session-id': sessionId } : {}), + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + params: {}, + }), + }); + + const sendRequest: SessionRequest = async (msg) => { + const resp = await fetch(url, { + method: 'POST', + headers: { + ...headers, + ...(sessionId !== undefined ? { 'mcp-session-id': sessionId } : {}), + }, + body: JSON.stringify(msg), + }); + + if (!resp.ok) { + // Per the MCP Streamable HTTP spec, a 404 on a request carrying + // Mcp-Session-Id means the backend terminated the session — word it so + // the session-expiry recovery in fetchServiceTools retries it. + const method = String(msg['method'] ?? 'request'); + if (resp.status === 404) { + throw new Error(`${method} failed: HTTP 404, session not found or expired`); + } + throw new Error(`${method} failed: HTTP ${resp.status}`); + } + + return parseJsonRpcBody(await resp.text()); + }; + + return await fn(sendRequest); +} + +/** + * Shared MCP SSE session (two-phase handshake): * 1. Client opens SSE connection (GET sseUrl) * 2. Server sends 'endpoint' SSE event containing the POST URL (may be relative) * 3. Client POSTs JSON-RPC messages to that URL; responses arrive via SSE 'message' events + * + * Handshake/transport failures become DiscoveryError (TIMEOUT / CONNECTION_FAILED). + * Errors thrown by `fn` keep their identity and propagate unwrapped. */ -async function discoverToolsViaSse(service: ServiceDefinition, timeout: number): Promise { +async function sseSession( + service: ServiceDefinition, + timeout: number, + timeoutLabel: string, + fn: (sendRequest: SessionRequest) => Promise +): Promise { if (service.url === undefined || service.url === null) { - return []; + throw new Error('Service has no URL configured'); } const sseUrl = service.url; const extraHeaders: Record = service.headers !== undefined && service.headers !== null ? { ...service.headers } : {}; - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { let done = false; - const fail = (err: Error): void => { + const rejectWith = (err: Error): void => { if (done) return; done = true; clearTimeout(timer); es.close(); + reject(err); + }; + + const fail = (err: Error): void => { const isTimeout = err.message.toLowerCase().includes('timeout'); - reject( + rejectWith( new DiscoveryError( isTimeout ? DiscoveryErrorType.TIMEOUT : DiscoveryErrorType.CONNECTION_FAILED, service.name, @@ -296,7 +438,7 @@ async function discoverToolsViaSse(service: ServiceDefinition, timeout: number): }; const timer = setTimeout( - () => fail(new Error(`Discovery timeout after ${timeout}ms`)), + () => fail(new Error(`${timeoutLabel} timeout after ${timeout}ms`)), timeout ); @@ -358,7 +500,7 @@ async function discoverToolsViaSse(service: ServiceDefinition, timeout: number): if (!r.ok) throw new Error(`POST ${postUrl} failed: HTTP ${r.status}`); }); - const sendRequest = (msg: Record): Promise> => + const sendRequest: SessionRequest = (msg) => new Promise((res, rej) => { const id = msg['id'] as string; pending.set(id, res); @@ -373,11 +515,7 @@ async function discoverToolsViaSse(service: ServiceDefinition, timeout: number): jsonrpc: '2.0', id: `init-${Date.now()}`, method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'onemcp-tui', version: getPackageVersion() }, - }, + params: initializeParams(), }); if (initResp['error'] !== undefined) { @@ -389,44 +527,20 @@ async function discoverToolsViaSse(service: ServiceDefinition, timeout: number): // 'notifications/initialized' is a notification — no response expected await postJson({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }); - const toolsResp = await sendRequest({ - jsonrpc: '2.0', - id: `tools-${Date.now()}`, - method: 'tools/list', - params: {}, - }); - - if (toolsResp['error'] !== undefined) { - throw new Error( - String( - (toolsResp['error'] as Record)['message'] ?? 'tools/list failed' - ) - ); + let outcome: T; + try { + outcome = await fn(sendRequest); + } catch (err) { + // fn errors keep their identity — no DiscoveryError wrapping here. + rejectWith(err instanceof Error ? err : new Error(String(err))); + return; } - const rawTools = - ((toolsResp['result'] as Record | undefined)?.['tools'] as - | Array<{ name: string; description?: string; inputSchema?: unknown }> - | undefined) ?? []; - if (!done) { done = true; clearTimeout(timer); es.close(); - resolve( - rawTools.map((t) => ({ - name: t.name, - namespacedName: `${service.name}__${t.name}`, - serviceName: service.name, - description: t.description ?? '', - inputSchema: (t.inputSchema as { - type: 'object'; - properties: Record; - required?: string[]; - }) ?? { type: 'object', properties: {} }, - enabled: true, - })) - ); + resolve(outcome); } }; @@ -435,6 +549,67 @@ async function discoverToolsViaSse(service: ServiceDefinition, timeout: number): }); } +/** + * Discover tools via stdio transport + */ +async function discoverToolsViaStdio(service: ServiceDefinition, timeout: number): Promise { + if (service.command === undefined || service.command === null) { + return []; + } + + try { + return await stdioSession(service, timeout, (send) => discoverToolsList(send, service)); + } catch (err) { + if (isTimeoutError(err)) { + throw new DiscoveryError( + DiscoveryErrorType.TIMEOUT, + service.name, + `Discovery timeout after ${timeout}ms`, + err instanceof Error ? err : undefined + ); + } + throw new DiscoveryError( + DiscoveryErrorType.CONNECTION_FAILED, + service.name, + err instanceof Error ? err.message : String(err), + err instanceof Error ? err : undefined + ); + } +} + +/** + * Discover tools via standard MCP SSE transport (two-phase handshake). + */ +async function discoverToolsViaSse(service: ServiceDefinition, timeout: number): Promise { + if (service.url === undefined || service.url === null) { + return []; + } + + try { + return await sseSession(service, timeout, 'Discovery', (send) => + discoverToolsList(send, service) + ); + } catch (err) { + if (err instanceof DiscoveryError) { + throw err; + } + if (isTimeoutError(err)) { + throw new DiscoveryError( + DiscoveryErrorType.TIMEOUT, + service.name, + `Discovery timeout after ${timeout}ms`, + err instanceof Error ? err : undefined + ); + } + throw new DiscoveryError( + DiscoveryErrorType.CONNECTION_FAILED, + service.name, + err instanceof Error ? err.message : String(err), + err instanceof Error ? err : undefined + ); + } +} + /** * Discover tools via HTTP transport */ @@ -447,174 +622,315 @@ async function discoverToolsViaHttp(service: ServiceDefinition, timeout: number) return []; } - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - }; - - if (service.headers !== undefined && service.headers !== null) { - Object.assign(headers, service.headers); + try { + return await httpSession(service, timeout, (send) => discoverToolsList(send, service)); + } catch (err) { + if (isTimeoutError(err)) { + throw new DiscoveryError( + DiscoveryErrorType.TIMEOUT, + service.name, + `Discovery timeout after ${timeout}ms`, + err instanceof Error ? err : undefined + ); + } + throw new DiscoveryError( + DiscoveryErrorType.CONNECTION_FAILED, + service.name, + err instanceof Error ? err.message : String(err), + err instanceof Error ? err : undefined + ); } +} - let sessionId: string | undefined; +/** + * Outcome of one tools/call, normalized for display. + */ +export interface ToolCallOutcome { + /** Backend flagged the tool execution as failed (result.isError === true). Not a transport failure. */ + isError: boolean; + /** Text assembled from result.content (text blocks joined with \n). */ + text: string; + /** The tool's text re-formatted as pretty JSON when it parses as JSON, else verbatim. */ + formatted: string; + /** Non-text content block types present, e.g. ['image','resource']. */ + nonTextTypes: string[]; + /** Pretty-printed raw JSON-RPC result, for the full-output dump. */ + raw: string; +} - try { - // Initialize with timeout - const initResponse = await Promise.race([ - fetch(service.url, { - method: 'POST', - headers, - body: JSON.stringify({ - jsonrpc: '2.0', - id: `init-${Date.now()}`, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { - name: 'onemcp-tui', - version: getPackageVersion(), - }, - }, - }), - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Connection timeout')), timeout) - ), - ]); +/** + * JSON-RPC level failure returned by the backend for a tools/call + * (has a numeric code / data). Transport failures use DiscoveryError. + */ +export class ToolCallError extends Error { + public readonly serviceName: string; + public readonly code?: number; + public readonly data?: unknown; - sessionId = initResponse.headers.get('mcp-session-id') || undefined; + constructor(serviceName: string, message: string, code?: number, data?: unknown) { + super(message); + this.name = 'ToolCallError'; + this.serviceName = serviceName; + if (code !== undefined) { + this.code = code; + } + if (data !== undefined) { + this.data = data; + } + } +} - const initText = await initResponse.text(); - let initData: { error?: { message: string } } | undefined; +/** Safely pretty-print a value, degrading on circular refs / BigInt. */ +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } +} - // Handle SSE response format - if (initText.startsWith('event:') || initText.includes('\ndata: ')) { - const lines = initText.split('\n'); - for (const line of lines) { - if (line.startsWith('data: ')) { - initData = JSON.parse(line.slice(6)) as { error?: { message: string } }; - break; - } - } +/** + * Re-format tool output as pretty JSON when it parses as JSON; otherwise + * return the text verbatim. Only strings starting with { or [ are considered + * JSON candidates, so plain text output never gets mangled. + */ +export function formatToolOutput(text: string): string { + const trimmed = text.trim(); + const first = trimmed.charAt(0); + if (first !== '{' && first !== '[') { + return text; + } + try { + return JSON.stringify(JSON.parse(trimmed), null, 2) ?? text; + } catch { + return text; + } +} + +/** + * Flatten an MCP CallToolResult into display text plus the raw pretty JSON. + */ +export function normalizeToolResult(result: unknown): ToolCallOutcome { + const isError = isRecord(result) && result['isError'] === true; + const textParts: string[] = []; + const nonTextTypes: string[] = []; + + const content = isRecord(result) && Array.isArray(result['content']) ? result['content'] : []; + for (const block of content) { + if (!isRecord(block)) { + textParts.push(String(block)); + continue; + } + const type = typeof block['type'] === 'string' ? block['type'] : 'unknown'; + if (type === 'text' && typeof block['text'] === 'string') { + textParts.push(block['text']); + } else if (type === 'resource' && isRecord(block['resource'])) { + const uri = block['resource']['uri']; + nonTextTypes.push(type); + textParts.push(typeof uri === 'string' ? `[resource: ${uri}]` : '[resource]'); } else { - initData = JSON.parse(initText) as { error?: { message: string } }; + nonTextTypes.push(type); + textParts.push(`[${type}]`); } + } - if (initData !== undefined && initData.error !== undefined) { - throw new Error(initData.error.message); - } + if (textParts.length === 0 && isRecord(result) && result['structuredContent'] !== undefined) { + textParts.push(safeJsonStringify(result['structuredContent'])); + } - // Send initialized notification (no id field - it's a notification, not a request) - await fetch(service.url, { - method: 'POST', - headers: { - ...headers, - ...(sessionId !== undefined ? { 'mcp-session-id': sessionId } : {}), - }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'notifications/initialized', - params: {}, - }), - }); + const joinedText = textParts.length > 0 ? textParts.join('\n') : '(empty result)'; + return { + isError, + text: joinedText, + formatted: formatToolOutput(joinedText), + nonTextTypes, + raw: safeJsonStringify(result), + }; +} - // List tools - const toolsResponse = await fetch(service.url, { - method: 'POST', - headers: { - ...headers, - ...(sessionId !== undefined ? { 'mcp-session-id': sessionId } : {}), - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: `tools-${Date.now()}`, - method: 'tools/list', - params: {}, - }), - }); +/** + * Unwrap a tools/call response: JSON-RPC error → ToolCallError, else the result. + */ +function unwrapToolCallResult(resp: Record, serviceName: string): unknown { + const error = resp['error']; + if (error !== undefined) { + const errRecord = isRecord(error) ? error : {}; + const code = typeof errRecord['code'] === 'number' ? errRecord['code'] : undefined; + const message = + typeof errRecord['message'] === 'string' ? errRecord['message'] : 'tool call failed'; + throw new ToolCallError(serviceName, message, code, errRecord['data']); + } + return resp['result']; +} - if (!toolsResponse.ok) { - // Per the MCP Streamable HTTP spec, a 404 on a request carrying - // Mcp-Session-Id means the backend terminated the session — word it so - // the session-expiry recovery in fetchServiceTools retries it. - if (toolsResponse.status === 404) { - throw new Error('tools/list failed: HTTP 404, session not found or expired'); - } - throw new Error(`tools/list failed: HTTP ${toolsResponse.status}`); +/** + * Reject with `message` when `promise` does not settle within `ms`. + * The timer is always cleared so fast responses leave nothing pending. + */ +async function withTimeout(promise: Promise, ms: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); } + } +} - const toolsText = await toolsResponse.text(); - let toolsData: - | { - error?: { message: string }; - result?: { tools?: Array<{ name: string; description?: string; inputSchema?: unknown }> }; - } - | undefined; - - if (toolsText.startsWith('event:') || toolsText.includes('\ndata: ')) { - const lines = toolsText.split('\n'); - for (const line of lines) { - if (line.startsWith('data: ')) { - toolsData = JSON.parse(line.slice(6)) as { - error?: { message: string }; - result?: { - tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>; - }; - }; - break; - } - } - } else { - toolsData = JSON.parse(toolsText) as { - error?: { message: string }; - result?: { tools?: Array<{ name: string; description?: string; inputSchema?: unknown }> }; - }; - } +/** Map a raw transport failure to the call-path DiscoveryError taxonomy. */ +function toCallFailure(err: unknown, service: ServiceDefinition, timeout: number): DiscoveryError { + if (isTimeoutError(err)) { + return new DiscoveryError( + DiscoveryErrorType.TIMEOUT, + service.name, + `Tool call timeout after ${timeout}ms — the tool may still be running on the backend`, + err instanceof Error ? err : undefined + ); + } + return new DiscoveryError( + DiscoveryErrorType.CONNECTION_FAILED, + service.name, + err instanceof Error ? err.message : String(err), + err instanceof Error ? err : undefined + ); +} - if (toolsData !== undefined && toolsData.error !== undefined) { - throw new Error(toolsData.error.message); - } +function sendToolsCallRequest( + send: SessionRequest, + serviceName: string, + toolName: string, + args: Record +): Promise { + return send({ + jsonrpc: '2.0', + id: `call-${Date.now()}`, + method: 'tools/call', + params: { name: toolName, arguments: args }, + }).then((resp) => unwrapToolCallResult(resp, serviceName)); +} - if ( - toolsData !== undefined && - toolsData.result?.tools !== undefined && - toolsData.result.tools !== null - ) { - return toolsData.result.tools.map((t) => ({ - name: t.name, - namespacedName: `${service.name}__${t.name}`, - serviceName: service.name, - description: t.description || '', - inputSchema: (t.inputSchema as { - type: 'object'; - properties: Record; - required?: string[]; - }) || { - type: 'object', - properties: {}, - }, - enabled: true, - })); +async function callToolViaStdio( + service: ServiceDefinition, + toolName: string, + args: Record, + timeout: number +): Promise { + try { + const result = await withTimeout( + stdioSession(service, timeout, (send) => + sendToolsCallRequest(send, service.name, toolName, args) + ), + timeout, + `tools/call timeout after ${timeout}ms` + ); + return normalizeToolResult(result); + } catch (err) { + if (err instanceof ToolCallError || err instanceof DiscoveryError) { + throw err; } + throw toCallFailure(err, service, timeout); + } +} + +async function callToolViaSse( + service: ServiceDefinition, + toolName: string, + args: Record, + timeout: number +): Promise { + try { + const result = await sseSession(service, timeout, 'Tool call', (send) => + sendToolsCallRequest(send, service.name, toolName, args) + ); + return normalizeToolResult(result); } catch (err) { - if (err instanceof Error && err.message.includes('timeout')) { - throw new DiscoveryError( - DiscoveryErrorType.TIMEOUT, - service.name, - `Discovery timeout after ${timeout}ms`, - err - ); + if (err instanceof ToolCallError || err instanceof DiscoveryError) { + throw err; } - throw new DiscoveryError( - DiscoveryErrorType.CONNECTION_FAILED, - service.name, - err instanceof Error ? err.message : String(err), - err instanceof Error ? err : undefined + throw toCallFailure(err, service, timeout); + } +} + +async function callToolViaHttp( + service: ServiceDefinition, + toolName: string, + args: Record, + timeout: number +): Promise { + try { + const result = await withTimeout( + httpSession(service, timeout, (send) => + sendToolsCallRequest(send, service.name, toolName, args) + ), + timeout, + `tools/call timeout after ${timeout}ms` ); + return normalizeToolResult(result); + } catch (err) { + if (err instanceof ToolCallError || err instanceof DiscoveryError) { + throw err; + } + throw toCallFailure(err, service, timeout); + } +} + +async function callServiceToolOnce( + service: ServiceDefinition, + toolName: string, + args: Record, + timeout: number +): Promise { + if (service.transport === 'stdio') { + return callToolViaStdio(service, toolName, args, timeout); + } else if (service.transport === 'sse') { + return callToolViaSse(service, toolName, args, timeout); + } + return callToolViaHttp(service, toolName, args, timeout); +} + +/** + * Whether a call failure may be transparently retried on a fresh connection. + * + * A backend that refused to execute the tool (validation error, unknown tool, + * ...) must NOT be replayed — isSessionExpiryError's message regex could + * misread such an error, so a ToolCallError only retries on a genuine -32001. + */ +function isRetryableToolCallFailure(err: unknown): boolean { + if (err instanceof ToolCallError) { + return err.code === -32001; } + return isSessionExpiryError(err); +} - return []; +/** + * Invoke a tool on a service over a one-shot connection. + * + * Mirrors fetchServiceTools: each attempt opens initialize → + * notifications/initialized → tools/call → close, so a session expiry + * (-32001 / HTTP 404) is recovered by retrying once on a fresh session. + * Tool-level backend refusals (other JSON-RPC errors) are thrown as + * ToolCallError without a retry; result.isError outcomes resolve normally. + */ +export async function callServiceTool( + service: ServiceDefinition, + toolName: string, + args: Record, + timeout: number +): Promise { + try { + return await callServiceToolOnce(service, toolName, args, timeout); + } catch (err) { + if (isRetryableToolCallFailure(err)) { + return await callServiceToolOnce(service, toolName, args, timeout); + } + throw err; + } } /** diff --git a/src/tui/text-layout.ts b/src/tui/text-layout.ts new file mode 100644 index 0000000..8852775 --- /dev/null +++ b/src/tui/text-layout.ts @@ -0,0 +1,143 @@ +/** + * Display-width-aware text layout primitives. + * + * Terminal cells do not map 1:1 to UTF-16 code units — CJK characters occupy + * two cells — so every width computation in the TUI panel goes through these + * helpers instead of String.length. + */ + +import stringWidth from 'string-width'; +import wrapAnsi from 'wrap-ansi'; + +/** Visible cell width of a string (CJK counts as 2, ANSI codes as 0). */ +export function displayWidth(s: string): number { + return stringWidth(s); +} + +/** Right-pad with spaces until the string occupies exactly `width` cells. */ +export function padDisplay(s: string, width: number): string { + const missing = Math.max(0, width - displayWidth(s)); + return s + ' '.repeat(missing); +} + +/** Truncate to at most `width` cells, appending … when something was cut. */ +export function truncateDisplay(s: string, width: number): string { + if (displayWidth(s) <= width) { + return s; + } + const budget = Math.max(1, width - 1); + let out = ''; + for (const char of s) { + const next = out + char; + if (displayWidth(next) > budget) { + break; + } + out = next; + } + return out + '…'; +} + +const MIN_WRAP_WIDTH = 4; +const MAX_HANGING_INDENT = 8; + +/** + * Word/hard wrap at `width` display cells, PRESERVING each logical line's + * leading indentation as a hanging indent on continuation lines. + * + * wrap-ansi handles the CJK-aware breaking; it deliberately does NOT add + * hanging indents and mangles very narrow widths, so this wrapper: + * - expands tabs to spaces (wrap-ansi passes them through unpredictably), + * - clamps the width (width < one CJK char makes wrap-ansi emit a stray + * leading empty line), + * - re-prefixes continuation lines with the source indent, + * - maps an empty input line to [''] so callers never lose a row. + */ +export function wrapDisplay(text: string, width: number): string[] { + const effectiveWidth = Math.max(MIN_WRAP_WIDTH, width); + const lines: string[] = []; + for (const rawLine of text.split('\n')) { + if (rawLine === '') { + lines.push(''); + continue; + } + const expanded = rawLine.replace(/\t/g, ' '); + const indentMatch = /^ */.exec(expanded); + const indent = (indentMatch?.[0] ?? '').slice( + 0, + Math.min( + expanded.length - expanded.trimStart().length, + MAX_HANGING_INDENT, + Math.max(0, effectiveWidth - MIN_WRAP_WIDTH) + ) + ); + const body = expanded.slice(indent.length); + const wrapped = wrapAnsi(body, Math.max(MIN_WRAP_WIDTH, effectiveWidth - indent.length), { + hard: true, + trim: false, + wordWrap: true, + }); + const pieces = wrapped.split('\n'); + if (pieces.length === 1 && pieces[0] === '') { + lines.push(''); + continue; + } + for (const piece of pieces) { + if (piece === '') { + continue; // wrap-ansi can emit stray empty lines at extreme widths + } + lines.push(indent + piece); + } + } + return lines.length > 0 ? lines : ['']; +} + +/** The top edge of a hand-drawn box, with the title embedded after the corner. */ +export function boxTop(title: string, innerWidth: number): string { + const inner = Math.max(0, innerWidth); + const fitted = truncateDisplay(title, inner); + const fill = Math.max(0, inner - displayWidth(fitted)); + return `╭${fitted}${'─'.repeat(fill)}╮`; +} + +/** The bottom edge of a hand-drawn box, with an optional right-aligned hint. */ +export function boxBottom(hint: string, innerWidth: number): string { + const inner = Math.max(0, innerWidth); + const fitted = truncateDisplay(hint, inner); + const fill = Math.max(0, inner - displayWidth(fitted)); + return `╰${'─'.repeat(fill)}${fitted}╯`; +} + +/** One content row of a hand-drawn box, padded to the inner width. */ +export function boxRow(content: string, innerWidth: number): string { + const inner = Math.max(0, innerWidth); + return `│${padDisplay(truncateDisplay(content, inner), inner)}│`; +} + +/** Focus bar glyph for section headers; always ONE cell so row math is stable. */ +export const SECTION_BAR = '▌'; + +/** + * A section header row: accent bar + uppercased label + a rule filling the rest + * of `width`. The bar glyph is identical whether or not the section is focused — + * focus shows up through the bar's COLOR, which the caller applies to the first + * `SECTION_BAR.length` cells (the test harness discards SGR, so color cannot be + * asserted; callers keep the glyph constant on purpose). + */ +export function sectionTitle(label: string, width: number): string { + const safeWidth = Math.max(0, width); + const bar = SECTION_BAR; + // Too narrow for `bar + ' ' + label + ' '` — degrade to the bare focus bar. + if (safeWidth <= 0) { + return ''; + } + if (safeWidth < 4) { + return bar; + } + const maxLabel = Math.max(0, safeWidth - displayWidth(bar) - 2); + const text = truncateDisplay(label.toUpperCase(), maxLabel); + if (text === '') { + return bar; + } + const fill = Math.max(0, safeWidth - displayWidth(bar) - 1 - displayWidth(text) - 1); + return `${bar} ${text} ${'─'.repeat(fill)}`; +} diff --git a/src/tui/tool-param-schema.ts b/src/tui/tool-param-schema.ts new file mode 100644 index 0000000..b2854db --- /dev/null +++ b/src/tui/tool-param-schema.ts @@ -0,0 +1,531 @@ +/** + * Tool parameter schema utilities + * + * Pure, React-free helpers that turn a tool's `inputSchema` into render-ready + * parameter descriptors, display lines, and a validated `arguments` payload. + * Shared by the ServiceTools detail panel and the ToolRunner form. + */ + +import { displayWidth, truncateDisplay, wrapDisplay } from './text-layout.js'; +import type { Tool } from '../types/tool.js'; + +/** + * Base kind of a parameter, used to pick the form widget. + */ +export type ParamKind = + | 'string' + | 'number' + | 'integer' + | 'boolean' + | 'array' + | 'object' + | 'unknown'; + +/** + * Render-ready descriptor for one property of a tool's inputSchema. + */ +export interface ToolParam { + /** Property key in inputSchema.properties (the wire-level argument name). */ + name: string; + /** Base kind used to pick the form widget. */ + kind: ParamKind; + /** Human label, e.g. 'string', 'array', 'string | number', 'unknown ($ref)'. */ + typeLabel: string; + /** Element kind for arrays; absent for non-arrays. */ + itemKind?: ParamKind; + required: boolean; + /** Property's own description, '' when absent. */ + description: string; + /** Present only when the schema declares a non-empty enum. */ + enumValues?: ReadonlyArray; + /** Present only when the schema declares a default. */ + defaultValue?: unknown; + /** The raw property schema, for object/array JSON hints. */ + raw: Record; +} + +/** + * Result of turning form values into a tool-call arguments payload. + */ +export type BuildArgsResult = + | { ok: true; args: Record } + | { ok: false; errors: Record }; + +/** Sentinel form value for "no value chosen" on select widgets. */ +export const UNSET_SENTINEL = '__unset__'; + +/** + * Whether a value is a plain object (not array, not null). + */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const PRIMITIVE_KINDS: ReadonlySet = new Set(['string', 'number', 'integer', 'boolean']); + +function asString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** + * One-line type label + widget kind for a property schema. + * Never throws on malformed input — anything unrecognized degrades to 'unknown'. + */ +export function describeParamType(prop: Record): { + label: string; + kind: ParamKind; +} { + const rawEnum = prop['enum']; + if (Array.isArray(rawEnum) && rawEnum.length > 0) { + const kind: ParamKind = typeof rawEnum[0] === 'number' ? 'number' : 'string'; + return { label: `${kind} (enum)`, kind }; + } + + if (prop['type'] === 'array') { + const items = prop['items']; + if (isRecord(items)) { + const inner = describeParamType(items); + return { label: `array<${inner.label}>`, kind: 'array' }; + } + return { label: 'array', kind: 'array' }; + } + + const anyOf = prop['anyOf']; + if (Array.isArray(anyOf) && anyOf.length > 0) { + return { label: joinUnionLabels(anyOf), kind: 'unknown' }; + } + const oneOf = prop['oneOf']; + if (Array.isArray(oneOf) && oneOf.length > 0) { + return { label: joinUnionLabels(oneOf), kind: 'unknown' }; + } + + const type = prop['type']; + if (typeof type === 'string' && PRIMITIVE_KINDS.has(type)) { + return { label: type, kind: type as ParamKind }; + } + if (type === 'object') { + return { label: 'object', kind: 'object' }; + } + + if (typeof prop['$ref'] === 'string') { + return { label: 'unknown ($ref)', kind: 'unknown' }; + } + + return { label: 'unknown', kind: 'unknown' }; +} + +function joinUnionLabels(members: unknown[]): string { + const labels = members + .slice(0, 3) + .map((member) => (isRecord(member) ? describeParamType(member).label : 'unknown')) + .join(' | '); + return members.length > 3 ? `${labels} | …` : labels; +} + +/** + * Turn an inputSchema into ordered render-ready descriptors. + * Follows the declaration order of `properties`; malformed entries are skipped. + */ +export function buildToolParams(schema: Tool['inputSchema'] | undefined): ToolParam[] { + if (schema === undefined || !isRecord(schema.properties)) { + return []; + } + + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter((r): r is string => typeof r === 'string') + : [] + ); + + const params: ToolParam[] = []; + for (const [name, rawValue] of Object.entries(schema.properties)) { + if (!isRecord(rawValue)) { + continue; + } + const { label, kind } = describeParamType(rawValue); + const param: ToolParam = { + name, + kind, + typeLabel: label, + required: required.has(name), + description: asString(rawValue['description']), + raw: rawValue, + }; + if (kind === 'array' && isRecord(rawValue['items'])) { + param.itemKind = describeParamType(rawValue['items']).kind; + } + const enumValues = readEnumValues(rawValue); + if (enumValues !== undefined) { + param.enumValues = enumValues; + } + if ('default' in rawValue) { + param.defaultValue = rawValue['default']; + } + params.push(param); + } + return params; +} + +function readEnumValues(raw: Record): ReadonlyArray | undefined { + const values = raw['enum']; + if (!Array.isArray(values) || values.length === 0) { + return undefined; + } + const filtered = values.filter( + (v): v is string | number => typeof v === 'string' || typeof v === 'number' + ); + return filtered.length > 0 ? filtered : undefined; +} + +/** + * Word wrap at `width` DISPLAY cells (CJK-aware), preserving leading + * indentation as hanging indents. Delegates to wrapDisplay — do not measure + * with String.length, double-width characters would overflow the panel. + */ +export function wrapText(text: string, width: number): string[] { + return wrapDisplay(text, width); +} + +/** + * Semantic tone of a styled segment; the component maps tones to colors. + */ +export type SegmentTone = 'primary' | 'muted' | 'critical' | 'value' | 'accent'; + +/** A styled span of a row. `segments.map(s => s.text).join('') === text`. */ +export interface StyledSegment { + text: string; + tone: SegmentTone; +} + +/** + * One row of the flattened detail-panel parameter block. Every row renders as + * exactly ONE terminal line; `text` is always authoritative, and `segments` is + * present only when the row was NOT truncated (truncation happens on the joined + * string, so post-hoc segments would mis-align and could drop the required mark). + */ +export type ParamRow = + | { kind: 'text'; text: string; segments?: StyledSegment[]; anchor?: string } + | { kind: 'input'; param: ToolParam } + | { kind: 'select'; param: ToolParam }; + +/** Indent for the detail rows of the expanded parameter. */ +const DETAIL_INDENT = ' '; +/** Indent for the rule between parameters (aligned with the name rows). */ +const SEPARATOR_INDENT = ' '; + +/** Current-value summary for a parameter row. */ +export function formatParamValue( + param: ToolParam, + values: Readonly> +): string { + const rawValue = values[param.name] ?? ''; + if (rawValue === '' || rawValue === UNSET_SENTINEL) { + return '(unset)'; + } + return rawValue; +} + +/** Build a text row, keeping segments only when nothing had to be truncated. */ +function textRow(text: string, segments: StyledSegment[], width: number): ParamRow { + if (displayWidth(text) > width) { + return { kind: 'text', text: truncateDisplay(text, width) }; + } + return { kind: 'text', text, segments }; +} + +/** + * Flatten parameters into the detail panel's row stream (one terminal line per + * row, every text row ≤ width). + * + * Every parameter gets a numbered name row (with the + * `name typeLabel *required` template intact), ONE line of description and + * its current value, with a horizontal rule separating consecutive parameters. + * The focused parameter expands instead to the full wrapped description plus + * `enum:` / `default:` details and swaps its `= value` row for a live editor + * row — so a tool with many parameters stays scannable while still explaining + * every parameter at a glance. + */ +export function buildParamRows( + params: readonly ToolParam[], + values: Readonly>, + width: number, + focusedName: string | null +): ParamRow[] { + const effectiveWidth = Math.max(8, width); + if (params.length === 0) { + return [{ kind: 'text', text: '(no parameters)' }]; + } + + const indexWidth = String(params.length).length; + const ruleBody = '─'.repeat(Math.max(4, effectiveWidth - SEPARATOR_INDENT.length)); + const rows: ParamRow[] = []; + + params.forEach((param, index) => { + const focused = param.name === focusedName; + + if (index > 0) { + rows.push({ + kind: 'text', + text: `${SEPARATOR_INDENT}${ruleBody}`, + segments: [ + { text: SEPARATOR_INDENT, tone: 'muted' }, + { text: ruleBody, tone: 'muted' }, + ], + }); + } + + // `▶` is DOUBLE-width, so `'▶ '` is 3 cells — the unfocused marker is + // padded to 3 cells too, otherwise focused rows shift a column right. + const marker = focused ? '▶ ' : ' '; + const idx = String(index + 1).padStart(indexWidth, ' '); + const head = `${marker}${idx} ${param.name} ${param.typeLabel}${ + param.required ? ' *required' : '' + }`; + const headSegments: StyledSegment[] = [ + { text: marker, tone: focused ? 'accent' : 'muted' }, + { text: idx, tone: 'muted' }, + { text: ' ', tone: 'muted' }, + { text: param.name, tone: focused ? 'accent' : 'primary' }, + { text: ` ${param.typeLabel}`, tone: 'muted' }, + ]; + if (param.required) { + headSegments.push({ text: ' *required', tone: 'critical' }); + } + const headRow = textRow(head, headSegments, effectiveWidth); + // The name row is the scroll anchor for this parameter's block. + rows.push(headRow.kind === 'text' ? { ...headRow, anchor: param.name } : headRow); + + const detailWidth = Math.max(1, effectiveWidth - DETAIL_INDENT.length); + // Description: the focused parameter expands fully (plus enum / default), + // while the others keep exactly ONE line — truncated, so the trailing `…` + // tells the reader a fuller text is there and that moving the cursor onto + // the parameter reveals it. + if (param.description !== '') { + const lines = focused + ? wrapText(param.description, detailWidth) + : [truncateDisplay(param.description.replace(/\s+/g, ' ').trim(), detailWidth)]; + for (const line of lines) { + const text = `${DETAIL_INDENT}${line}`; + rows.push({ + kind: 'text', + text, + segments: [ + { text: DETAIL_INDENT, tone: 'muted' }, + { text: line, tone: 'muted' }, + ], + }); + } + } + if (focused && param.enumValues !== undefined) { + const body = `enum: ${param.enumValues.join(' | ')}`; + rows.push( + textRow( + `${DETAIL_INDENT}${body}`, + [ + { text: DETAIL_INDENT, tone: 'muted' }, + { text: body, tone: 'muted' }, + ], + effectiveWidth + ) + ); + } + if (focused && 'defaultValue' in param) { + const body = `default: ${formatValue(param.defaultValue)}`; + rows.push( + textRow( + `${DETAIL_INDENT}${body}`, + [ + { text: DETAIL_INDENT, tone: 'muted' }, + { text: body, tone: 'muted' }, + ], + effectiveWidth + ) + ); + } + + const isSelectField = param.kind === 'boolean' || param.enumValues !== undefined; + if (focused && isSelectField) { + rows.push({ kind: 'select', param }); + } else if (focused) { + rows.push({ kind: 'input', param }); + } else { + const summary = formatParamValue(param, values); + const prefix = `${DETAIL_INDENT}= `; + const maxSummary = Math.max(1, effectiveWidth - displayWidth(prefix)); + const valueText = prefix + truncateDisplay(summary, maxSummary); + const segments: StyledSegment[] = [ + { text: prefix, tone: 'muted' }, + { text: truncateDisplay(summary, maxSummary), tone: 'value' }, + ]; + rows.push(textRow(valueText, segments, effectiveWidth)); + } + }); + + return rows; +} + +/** Initial form values: declared defaults prefilled, everything else blank. */ +export function seedFormValues(params: readonly ToolParam[]): Record { + const values: Record = {}; + for (const param of params) { + if ('defaultValue' in param && param.defaultValue !== undefined) { + const d = param.defaultValue; + values[param.name] = typeof d === 'string' ? d : JSON.stringify(d); + } else { + values[param.name] = ''; + } + } + return values; +} + +function formatValue(value: unknown): string { + if (typeof value === 'string') { + return value === '' ? '""' : value; + } + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +/** + * Coerce one trimmed form value into the wire value for its parameter kind. + * Returns the coerced value or a field-level error message. + */ +function coerceFieldValue( + param: ToolParam, + trimmed: string +): { value: unknown } | { error: string } { + switch (param.kind) { + case 'string': + return { value: trimmed }; + case 'number': { + const n = Number(trimmed); + return Number.isFinite(n) ? { value: n } : { error: 'must be a number' }; + } + case 'integer': { + const n = Number(trimmed); + if (!Number.isFinite(n) || !Number.isInteger(n)) { + return { error: 'must be an integer' }; + } + return { value: n }; + } + case 'boolean': + if (trimmed === 'true' || trimmed === 'false') { + return { value: trimmed === 'true' }; + } + return { error: "must be 'true' or 'false'" }; + case 'array': { + try { + const parsed: unknown = JSON.parse(trimmed); + return Array.isArray(parsed) + ? { value: parsed } + : { error: 'must be a JSON array, e.g. [1,2]' }; + } catch { + return { error: 'must be a JSON array, e.g. [1,2]' }; + } + } + case 'object': { + try { + const parsed: unknown = JSON.parse(trimmed); + return isRecord(parsed) + ? { value: parsed } + : { error: 'must be a JSON object, e.g. {"k":1}' }; + } catch { + return { error: 'must be a JSON object, e.g. {"k":1}' }; + } + } + case 'unknown': { + try { + return { value: JSON.parse(trimmed) as unknown }; + } catch { + return { value: trimmed }; + } + } + } +} + +function coerceEnumValue( + param: ToolParam, + trimmed: string +): { value: unknown } | { error: string } { + const enumValues = param.enumValues; + if (enumValues === undefined) { + return { value: trimmed }; + } + const allNumeric = enumValues.every((v) => typeof v === 'number'); + const candidate: string | number = + allNumeric && trimmed !== '' && Number.isFinite(Number(trimmed)) ? Number(trimmed) : trimmed; + if (enumValues.includes(candidate)) { + return { value: candidate }; + } + return { error: `must be one of: ${enumValues.join(', ')}` }; +} + +/** + * Build the arguments payload from form values. + * Core rule: a blank value means the key is OMITTED entirely — never '' or null. + * All field errors are collected, not just the first. + */ +export function buildToolArguments( + params: readonly ToolParam[], + formValues: Readonly> +): BuildArgsResult { + const args: Record = {}; + const errors: Record = {}; + + for (const param of params) { + const rawValue = formValues[param.name] ?? ''; + const trimmed = rawValue.trim(); + + if (trimmed === '' || trimmed === UNSET_SENTINEL) { + if (param.required) { + errors[param.name] = 'is required'; + } + continue; + } + + const result = + param.enumValues !== undefined + ? coerceEnumValue(param, trimmed) + : coerceFieldValue(param, trimmed); + if ('error' in result) { + errors[param.name] = result.error; + } else { + args[param.name] = result.value; + } + } + + if (Object.keys(errors).length > 0) { + return { ok: false, errors }; + } + return { ok: true, args }; +} + +/** + * Best-effort form → arguments projection that never fails: each field is + * coerced independently and fields that do not coerce are simply omitted. + * Used when projecting the form into the JSON editor. + */ +export function bestEffortArgs( + params: readonly ToolParam[], + formValues: Readonly> +): Record { + const args: Record = {}; + for (const param of params) { + const rawValue = formValues[param.name] ?? ''; + const trimmed = rawValue.trim(); + if (trimmed === '' || trimmed === UNSET_SENTINEL) { + continue; + } + const result = + param.enumValues !== undefined + ? coerceEnumValue(param, trimmed) + : coerceFieldValue(param, trimmed); + if ('value' in result) { + args[param.name] = result.value; + } + } + return args; +} diff --git a/tests/integration/discovery-worker-session-expiry.test.ts b/tests/integration/discovery-worker-session-expiry.test.ts index 14fa9ca..6c7bf90 100644 --- a/tests/integration/discovery-worker-session-expiry.test.ts +++ b/tests/integration/discovery-worker-session-expiry.test.ts @@ -10,7 +10,13 @@ import { describe, it, expect, afterEach } from 'vitest'; import http from 'node:http'; import { AddressInfo } from 'node:net'; -import { fetchServiceTools } from '../../src/tui/discovery-worker.js'; +import { + callServiceTool, + DiscoveryError, + DiscoveryErrorType, + fetchServiceTools, + ToolCallError, +} from '../../src/tui/discovery-worker.js'; import type { ServiceDefinition } from '../../src/types/service.js'; const TOOLS = [ @@ -19,15 +25,38 @@ const TOOLS = [ type Mode = 'first-session-expired' | 'first-session-http404' | 'always-expired'; -function startMockBackend(mode: Mode): Promise<{ +/** How the mock backend answers tools/call requests. */ +type CallMode = + | 'never' + | 'first-call-32001' + | 'first-call-http404' + | 'always' + | 'param-error' + | 'is-error'; + +interface CallStats { + callAttempts: number; + callExpired: number; + calls: Array<{ name: string; arguments: unknown }>; +} + +function startMockBackend( + mode: Mode, + callMode: CallMode = 'never', + callDelayMs = 0 +): Promise<{ url: string; close: () => Promise; stats: () => { initializes: number; expiredErrors: number }; + callStats: () => CallStats; }> { const sessions = new Map(); let sidCounter = 0; let initializeCount = 0; let expiredErrors = 0; + let callAttempts = 0; + let callExpired = 0; + const calls: Array<{ name: string; arguments: unknown }> = []; const server = http.createServer((req, res) => { let raw = ''; @@ -99,6 +128,64 @@ function startMockBackend(mode: Mode): Promise<{ return; } + if (msg['method'] === 'tools/call') { + callAttempts++; + const params = (msg['params'] ?? {}) as { name?: string; arguments?: unknown }; + calls.push({ name: params.name ?? '', arguments: params.arguments }); + + const callExpiredNow = + callMode === 'always' || + (callMode === 'first-call-32001' && callAttempts === 1) || + (callMode === 'first-call-http404' && callAttempts === 1); + if (callExpiredNow) { + callExpired++; + if (callMode === 'first-call-http404' && callAttempts === 1) { + res.writeHead(404).end('Not Found'); + return; + } + sendJson(200, { + jsonrpc: '2.0', + id: msg['id'], + error: { + code: -32001, + message: 'Session not found or expired. Please send initialize again.', + }, + }); + return; + } + + const respond = () => { + if (callMode === 'param-error') { + sendJson(200, { + jsonrpc: '2.0', + id: msg['id'], + error: { code: -32602, message: 'Invalid params for tool' }, + }); + return; + } + if (callMode === 'is-error') { + sendJson(200, { + jsonrpc: '2.0', + id: msg['id'], + result: { content: [{ type: 'text', text: 'boom' }], isError: true }, + }); + return; + } + sendJson(200, { + jsonrpc: '2.0', + id: msg['id'], + result: { content: [{ type: 'text', text: `called ${String(params.name)}` }] }, + }); + }; + + if (callDelayMs > 0) { + setTimeout(respond, callDelayMs); + } else { + respond(); + } + return; + } + // notifications/initialized and anything else sendJson(200, { jsonrpc: '2.0', id: msg['id'] ?? 'notification', result: {} }); }); @@ -111,6 +198,11 @@ function startMockBackend(mode: Mode): Promise<{ url: `http://127.0.0.1:${port}/mcp`, close: () => new Promise((r) => server.close(() => r())), stats: () => ({ initializes: initializeCount, expiredErrors }), + callStats: () => ({ + callAttempts, + callExpired, + calls, + }), }); }); }); @@ -166,3 +258,103 @@ describe('discovery-worker session expiry recovery', () => { expect(backend.stats()).toEqual({ initializes: 2, expiredErrors: 2 }); }); }); + +describe('callServiceTool against a mock MCP backend', () => { + let backend: Awaited> | undefined; + + afterEach(async () => { + if (backend) { + await backend.close().catch(() => {}); + backend = undefined; + } + }); + + it('calls a tool over a single one-shot session', async () => { + backend = await startMockBackend('first-session-expired', 'never'); + + const outcome = await callServiceTool(makeService(backend.url), 'alpha', { x: 1 }, 5000); + + expect(outcome.isError).toBe(false); + expect(outcome.text).toBe('called alpha'); + expect(backend.callStats().calls).toEqual([{ name: 'alpha', arguments: { x: 1 } }]); + // One-shot: no tools/list, so a single initialize for the whole call. + expect(backend.stats().initializes).toBe(1); + expect(backend.callStats().callAttempts).toBe(1); + }); + + it('recovers from a JSON-RPC -32001 expired session via one retry', async () => { + backend = await startMockBackend('first-session-expired', 'first-call-32001'); + + const outcome = await callServiceTool(makeService(backend.url), 'alpha', {}, 5000); + + expect(outcome.isError).toBe(false); + expect(backend.stats().initializes).toBe(2); + expect(backend.callStats().callExpired).toBe(1); + expect(backend.callStats().callAttempts).toBe(2); + }); + + it('recovers from a spec-conformant HTTP 404 session expiry via one retry', async () => { + backend = await startMockBackend('first-session-expired', 'first-call-http404'); + + const outcome = await callServiceTool(makeService(backend.url), 'alpha', {}, 5000); + + expect(outcome.isError).toBe(false); + expect(backend.stats().initializes).toBe(2); + expect(backend.callStats().callAttempts).toBe(2); + }); + + it('fails after the single retry when every call hits an expired session', async () => { + backend = await startMockBackend('first-session-expired', 'always'); + + const err = await callServiceTool(makeService(backend.url), 'alpha', {}, 5000).then( + () => undefined, + (e: unknown) => e + ); + + expect(err).toBeInstanceOf(ToolCallError); + expect((err as ToolCallError).code).toBe(-32001); + expect(backend.stats().initializes).toBe(2); + expect(backend.callStats().callAttempts).toBe(2); + }); + + it('does NOT retry a tool-level JSON-RPC error (-32602)', async () => { + backend = await startMockBackend('first-session-expired', 'param-error'); + + const err = await callServiceTool(makeService(backend.url), 'alpha', {}, 5000).then( + () => undefined, + (e: unknown) => e + ); + + expect(err).toBeInstanceOf(ToolCallError); + expect((err as ToolCallError).code).toBe(-32602); + expect((err as ToolCallError).message).toBe('Invalid params for tool'); + // A backend refusal must never be replayed. + expect(backend.stats().initializes).toBe(1); + expect(backend.callStats().callAttempts).toBe(1); + }); + + it('resolves (without retry) when the tool reports isError', async () => { + backend = await startMockBackend('first-session-expired', 'is-error'); + + const outcome = await callServiceTool(makeService(backend.url), 'alpha', {}, 5000); + + expect(outcome.isError).toBe(true); + expect(outcome.text).toBe('boom'); + expect(backend.stats().initializes).toBe(1); + expect(backend.callStats().callAttempts).toBe(1); + }); + + it('surfaces a TIMEOUT DiscoveryError when the backend is too slow', async () => { + backend = await startMockBackend('first-session-expired', 'never', 1500); + + const err = await callServiceTool(makeService(backend.url), 'alpha', {}, 300).then( + () => undefined, + (e: unknown) => e + ); + + expect(err).toBeInstanceOf(DiscoveryError); + expect((err as DiscoveryError).type).toBe(DiscoveryErrorType.TIMEOUT); + // Timeouts are not retried — a second attempt could repeat side effects. + expect(backend.callStats().callAttempts).toBe(1); + }); +}); diff --git a/tests/integration/tui-service-tools-run.test.ts b/tests/integration/tui-service-tools-run.test.ts new file mode 100644 index 0000000..9e44880 --- /dev/null +++ b/tests/integration/tui-service-tools-run.test.ts @@ -0,0 +1,757 @@ +/** + * Integration tests for the flattened ServiceTools detail panel: inline + * parameter editing (Tab into fields), Ctrl+R tool invocation, JSON output + * formatting, and the layered Esc behavior. Renders the REAL ServiceTools + * component with a mocked discovery-worker (no real backend) and drives + * keystrokes through a fake TTY. + */ +import { describe, it, expect, vi, beforeEach } 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 type { ToolCallOutcome } from '../../src/tui/discovery-worker.js'; +import type { Tool } from '../../src/types/tool.js'; +import type { ServiceDefinition } from '../../src/types/service.js'; + +const { tools, fetchServiceToolsMock, callServiceToolMock, mockOutcome } = vi.hoisted(() => { + const tools: Tool[] = [ + { + name: 'alpha', + namespacedName: 'demo__alpha', + serviceName: 'demo', + description: 'mock tool with parameters', + inputSchema: { + type: 'object' as const, + properties: { + q: { type: 'string', description: 'query text' }, + limit: { type: 'integer', description: 'max results' }, + verbose: { type: 'boolean', description: 'chatty output' }, + tags: { type: 'array', items: { type: 'string' }, description: 'filter tags' }, + }, + required: ['q'], + }, + enabled: true, + }, + ]; + const outcome: ToolCallOutcome = { + isError: false, + text: '{"ok":true}', + formatted: '{\n "ok": true\n}', + nonTextTypes: [], + raw: '{\n "content": []\n}', + }; + return { + tools, + mockOutcome: outcome, + fetchServiceToolsMock: vi.fn(() => Promise.resolve(tools)), + callServiceToolMock: vi.fn( + ( + _service: ServiceDefinition, + _toolName: string, + _args: Record, + _timeout: number + ) => Promise.resolve(outcome) + ), + }; +}); + +vi.mock('../../src/tui/discovery-worker.js', () => ({ + __esModule: true, + fetchServiceTools: fetchServiceToolsMock, + callServiceTool: callServiceToolMock, + // Minimal stand-ins: ServiceTools only uses them for instanceof checks. + ToolCallError: class ToolCallError extends Error {}, + DiscoveryError: class DiscoveryError extends Error {}, + DiscoveryErrorType: { TIMEOUT: 'timeout', CONNECTION_FAILED: 'connection_failed' }, + default: fetchServiceToolsMock, +})); + +const { copyToClipboardMock } = vi.hoisted(() => ({ + copyToClipboardMock: vi.fn((_text: string) => true), +})); +vi.mock('../../src/tui/clipboard.js', () => ({ + __esModule: true, + copyToClipboard: copyToClipboardMock, +})); + +// Minimal ANSI terminal emulator (same harness as tui-service-tools-scroll) +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; +}; + +const MiniApp: React.FC<{ rows: number; onBack?: (() => void) | undefined }> = ({ + rows, + onBack, +}) => { + const { stdout } = useStdout(); + const terminalHeight = stdout?.rows || rows; + + const service: ServiceDefinition = { + name: 'demo', + transport: 'http', + url: 'http://127.0.0.1:1/mcp', + enabled: true, + tags: [], + connectionPool: { maxConnections: 1, idleTimeout: 60000, connectionTimeout: 10000 }, + }; + + return React.createElement( + Box, + { flexDirection: 'column', height: terminalHeight }, + React.createElement(ServiceTools, { + service, + onBack: onBack ?? (() => {}), + onToggleTool: () => {}, + toolStates: {}, + terminalHeight, + }) + ); +}; + +function renderApp(rows: number, cols: number, onBack?: () => void) { + 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, onBack }), { + stdout, + stdin, + exitOnCtrlC: false, + }); + return { instance, term, stdin, stdout }; +} + +const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); +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(); +}; +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); + } +}; +const pressKey = async (stdin: any, bytes: string) => { + stdin.push(Buffer.from(bytes, 'utf8')); + await new Promise((r) => setImmediate(r)); + await sleep(80); +}; + +describe('ServiceTools flattened detail panel', () => { + beforeEach(() => { + fetchServiceToolsMock.mockReset(); + fetchServiceToolsMock.mockImplementation(() => Promise.resolve(tools)); + callServiceToolMock.mockClear(); + callServiceToolMock.mockImplementation(() => Promise.resolve(mockOutcome)); + copyToClipboardMock.mockClear(); + copyToClipboardMock.mockImplementation(() => true); + }); + + it('shows description and parameters flattened, without section focus markers', async () => { + const { instance, term } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + + const text = term.text(); + expect(text).toContain('DESCRIPTION'); + expect(text).toContain('PARAMETERS (4)'); + expect(text).toContain('mock tool with parameters'); + expect(text).toContain('q string *required'); + expect(text).toContain('limit integer'); + expect(text).toContain('verbose boolean'); + expect(text).toContain('tags array'); + // No section-focus markers from the old two-section design. + expect(text).not.toContain('▸'); + + instance.unmount(); + }); + + it('expands the description with Ctrl+E and re-collapses it for the next tool', async () => { + const longLines = Array.from({ length: 40 }, (_, i) => `detail line ${i}`); + const described = { ...tools[0]!, description: longLines.join('\n') }; + fetchServiceToolsMock.mockImplementation(() => + Promise.resolve([described, { ...described, name: 'beta', namespacedName: 'demo__beta' }]) + ); + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('more line(s)')); + expect(term.text()).toContain('detail line 0'); + expect(term.text()).toContain('Ctrl+E expands'); + + await pressKey(stdin, '\x05'); // Ctrl+E → expand + await waitFor(() => !term.text().includes('more line(s)')); + // The tail is now reachable by paging the panel (Ctrl+D = page down). + for (let i = 0; i < 4; i++) { + await pressKey(stdin, '\x04'); + } + await waitFor(() => term.text().includes('detail line 39')); + + await pressKey(stdin, '\x05'); // Ctrl+E → collapse + await waitFor(() => term.text().includes('more line(s)')); + expect(term.text()).not.toContain('detail line 39'); + + // The toggle is temporary: the next tool starts collapsed again. + await pressKey(stdin, '\x05'); // expand once more + await waitFor(() => !term.text().includes('more line(s)')); + await pressKey(stdin, '\x1b[B'); // ↓ → beta + await waitFor(() => term.text().includes('▶ ✓ beta')); + await waitFor(() => term.text().includes('more line(s)')); + + instance.unmount(); + }); + + it('Tab enters the first field; typed letters land in the field, not the list', async () => { + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + expect(term.text()).not.toContain('value'); + + await pressKey(stdin, '\t'); // list → fields (q focused) + await waitFor(() => term.text().includes('value')); // input placeholder visible + + // Typing 'a' must edit the field, not batch-enable tools. + await typeKeys(stdin, 'zz'); + await waitFor(() => term.text().includes('zz')); + + instance.unmount(); + }); + + it('runs the tool with Ctrl+R: coerced args, blank optionals omitted, formatted output', async () => { + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + + // Tab now switches REGIONS (list → params); ↑/↓ moves between parameters. + await pressKey(stdin, '\t'); // list → params (q expanded) + await typeKeys(stdin, 'hello'); + await pressKey(stdin, '\x1b[B'); // ↓ → limit + await typeKeys(stdin, '3'); + await pressKey(stdin, '\x1b[B'); // ↓ → verbose (select, stays unset) + await pressKey(stdin, '\x1b[B'); // ↓ → tags (left blank) + + await pressKey(stdin, '\x12'); // Ctrl+R + await waitFor(() => term.text().includes('Result: ✓')); + + expect(callServiceToolMock).toHaveBeenCalledTimes(1); + const [calledService, calledTool, calledArgs, calledTimeout] = + callServiceToolMock.mock.calls[0]!; + expect(calledTool).toBe('alpha'); + expect(calledService).toMatchObject({ name: 'demo' }); + expect(typeof calledTimeout).toBe('number'); + expect(calledArgs).toEqual({ q: 'hello', limit: 3 }); + + // Output defaults to the formatted JSON, not the compact original. + expect(term.text()).toContain('"ok": true'); + expect(term.text()).not.toContain('{"ok":true}'); + + // The result is framed in a bordered box whose content keeps its indent. + const text = term.text(); + expect(text).toContain('╭'); + expect(text).toContain('╰'); + const framedLine = text.split('\n').find((l) => l.includes('│') && l.includes('"ok"')); + expect(framedLine).toBeDefined(); + expect(framedLine!.indexOf('│')).toBeLessThan(framedLine!.indexOf('"ok"')); + + instance.unmount(); + }); + + it('PgDn scrolls inside the result box for tall outputs', async () => { + const tall = Array.from({ length: 20 }, (_, i) => `"line-${String(i).padStart(2, '0')}": ${i}`); + callServiceToolMock.mockImplementation(() => + Promise.resolve({ + ...mockOutcome, + formatted: `{\n ${tall.join(',\n ')}\n}`, + }) + ); + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); // Ctrl+R + await waitFor(() => term.text().includes('Result: ✓')); + expect(term.text()).toContain('"line-00"'); + + await pressKey(stdin, '\x1b[6~'); // PgDn + await waitFor(() => !term.text().includes('"line-00"')); + expect(term.text()).toContain('"line-08"'); + expect(term.text()).toContain('↓'); // box hint shows remaining rows + + instance.unmount(); + }); + + it('blocks the request and shows field errors when validation fails', async () => { + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + await pressKey(stdin, '\t'); // → q (left empty) + + await pressKey(stdin, '\x12'); // Ctrl+R with required q blank + await waitFor(() => term.text().includes('q: is required')); + + expect(callServiceToolMock).not.toHaveBeenCalled(); + + instance.unmount(); + }); + + it('Ctrl+J toggles the inline raw-JSON editor over the parameter rows', async () => { + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + + await pressKey(stdin, '\n'); // Ctrl+J → json editor + await waitFor(() => term.text().includes('Arguments (raw JSON):')); + expect(term.text()).not.toContain('q string *required'); + + await pressKey(stdin, '\n'); // Ctrl+J → back to fields + await waitFor(() => term.text().includes('q string *required')); + + instance.unmount(); + }); + + it('shows the tool-error marker when the backend reports isError', async () => { + callServiceToolMock.mockImplementation(() => + Promise.resolve({ + ...mockOutcome, + isError: true, + text: 'boom from backend', + formatted: 'boom from backend', + }) + ); + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); + await waitFor(() => term.text().includes('boom from backend')); + + expect(term.text()).toContain('tool reported an error'); + + instance.unmount(); + }); + + it('Esc layers: leave editing first, then trigger onBack', async () => { + const onBack = vi.fn(); + const { instance, term, stdin } = renderApp(30, 100, onBack); + + await waitFor(() => term.text().includes('Parameters (4)')); + + await pressKey(stdin, '\t'); // → fields + await waitFor(() => term.text().includes('value')); + + await pressKey(stdin, '\x1b'); // leave editing → list focus + await waitFor(() => term.text().includes('= (unset)')); + expect(onBack).not.toHaveBeenCalled(); + + await pressKey(stdin, '\x1b'); // list focus → back to services + await waitFor(() => onBack.mock.calls.length > 0); + + instance.unmount(); + }); + + it('keeps one description line per parameter while browsing and expands the focused one', async () => { + const longDesc = + 'first detail sentence that runs well past the panel width so it must be cut off'; + fetchServiceToolsMock.mockImplementation(() => + Promise.resolve([ + { + ...tools[0]!, + inputSchema: { + type: 'object' as const, + properties: { + q: { type: 'string', description: longDesc }, + limit: { type: 'integer', description: 'max results' }, + }, + required: ['q'], + }, + }, + ]) + ); + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('PARAMETERS (2)')); + // Browsing: one truncated description line per parameter — the trailing `…` + // is the cue that the full text is available. + expect(term.text()).toContain('1 q string *required'); + expect(term.text()).toContain('= (unset)'); + expect(term.text()).toMatch(/first detail sentence[^\n]*…/); + expect(term.text()).not.toContain('must be cut off'); + expect(term.text()).toContain('max results'); + // Consecutive parameters are separated by a rule. + expect( + term + .text() + .split('\n') + .some((l) => l.trim().startsWith('───')) + ).toBe(true); + + await pressKey(stdin, '\t'); // → params: the focused one expands fully + await waitFor(() => term.text().includes('▶ 1 q string *required')); + await waitFor(() => term.text().includes('must be cut off')); + // The other parameter keeps its single line. + expect(term.text()).toMatch(/max results/); + + instance.unmount(); + }); + + it('Tab cycles regions and ↑/↓ scrolls the panel from the result region', async () => { + const tall = Array.from({ length: 24 }, (_, i) => `"line-${String(i).padStart(2, '0')}": ${i}`); + callServiceToolMock.mockImplementation(() => + Promise.resolve({ ...mockOutcome, formatted: `{\n ${tall.join(',\n ')}\n}` }) + ); + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('PARAMETERS (4)')); + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); // Ctrl+R → focus lands on the result region + await waitFor(() => term.text().includes('Result: ✓')); + + // The footer advertises the result-region keys once it has focus. + await waitFor(() => term.text().includes('PgUp/PgDn Page')); + + // ↑/↓ scroll the panel line by line from the result region (this is the + // capability that was previously missing entirely). + const before = term.text(); + expect(before).toContain('"line-00"'); + await pressKey(stdin, '\x1b[B'); // ↓ + await waitFor(() => !term.text().includes('"line-00"')); + expect(term.text()).toContain('"line-01"'); + + await pressKey(stdin, '\t'); // result → list + await waitFor(() => term.text().includes('a/A All on/off')); + + instance.unmount(); + }); + + it('reaches the last line of a large result (no row cap)', async () => { + const lines = Array.from({ length: 300 }, (_, i) => `"l${String(i).padStart(3, '0')}": ${i}`); + callServiceToolMock.mockImplementation(() => + Promise.resolve({ ...mockOutcome, formatted: `{\n ${lines.join(',\n ')}\n}` }) + ); + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); // Ctrl+R → focus lands on the result region + await waitFor(() => term.text().includes('Result: ✓')); + expect(term.text()).toContain('"l000"'); + + // Page to the very bottom; the tail of a 300-line result must be reachable. + for (let i = 0; i < 18; i++) { + await pressKey(stdin, '\x1b[C'); // → + } + await waitFor(() => term.text().includes('"l299"')); + expect(term.text()).toContain('"l299"'); + expect(term.text()).toContain('╰'); // the closing border came into view + + instance.unmount(); + }); + + it('pages the panel with ←/→ and back again', async () => { + const lines = Array.from({ length: 60 }, (_, i) => `"p${String(i).padStart(3, '0')}": ${i}`); + callServiceToolMock.mockImplementation(() => + Promise.resolve({ ...mockOutcome, formatted: `{\n ${lines.join(',\n ')}\n}` }) + ); + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); + await waitFor(() => term.text().includes('Result: ✓')); + expect(term.text()).toContain('"p000"'); + + // → pages forward by a whole viewport, not a single line. + await pressKey(stdin, '\x1b[C'); + await waitFor(() => !term.text().includes('"p000"')); + expect(term.text()).toContain('"p020"'); + + // ← pages back. + await pressKey(stdin, '\x1b[D'); + await waitFor(() => term.text().includes('"p000"')); + + instance.unmount(); + }); + + it('copies the whole result (unwrapped) with Ctrl+Y', async () => { + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); // Ctrl+R → result focus + await waitFor(() => term.text().includes('Result: ✓')); + + await pressKey(stdin, '\x19'); // Ctrl+Y + expect(copyToClipboardMock).toHaveBeenCalledTimes(1); + // The UNWRAPPED text, so pasted data has no display line breaks. + expect(copyToClipboardMock.mock.calls[0]?.[0]).toBe('{\n "ok": true\n}'); + await waitFor(() => term.text().includes('Copied')); + + instance.unmount(); + }); + + it('selects a range of result lines with v and copies only those', async () => { + const lines = Array.from({ length: 20 }, (_, i) => `"r${String(i).padStart(2, '0')}": ${i}`); + callServiceToolMock.mockImplementation(() => + Promise.resolve({ ...mockOutcome, formatted: `{\n ${lines.join(',\n ')}\n}` }) + ); + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); + await waitFor(() => term.text().includes('Result: ✓')); + + await pressKey(stdin, 'v'); // anchor at the first visible result line + await waitFor(() => term.text().includes('Copy selection')); + await pressKey(stdin, '\x1b[B'); // ↓ extend + await pressKey(stdin, '\x1b[B'); // ↓ extend + await pressKey(stdin, '\x19'); // Ctrl+Y + + const copied = copyToClipboardMock.mock.calls[0]?.[0] as string; + const copiedLines = copied.split('\n'); + // Exactly the three highlighted lines, without the box frame glyphs. + expect(copiedLines).toHaveLength(3); + expect(copiedLines[0]).toBe('{'); + expect(copied).toContain('"r01"'); + expect(copied).not.toContain('│'); + + instance.unmount(); + }); + + it('marks the result cursor, moves it with ↑/↓ and selects from it with v', async () => { + const lines = Array.from({ length: 20 }, (_, i) => `"r${String(i).padStart(2, '0')}": ${i}`); + callServiceToolMock.mockImplementation(() => + Promise.resolve({ ...mockOutcome, formatted: `{\n ${lines.join(',\n ')}\n}` }) + ); + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); + await waitFor(() => term.text().includes('Result: ✓')); + + // A successful run focuses the result region: the cursor sits on line 0. + const cursorRow = (): number => + term + .text() + .split('\n') + .findIndex((l) => l.includes('▸')); + await waitFor(() => cursorRow() > -1); + const first = cursorRow(); + expect(term.text().split('\n')[first]).toContain('{'); + + await pressKey(stdin, '\x1b[B'); // ↓ + await waitFor(() => cursorRow() === first + 1); + await pressKey(stdin, '\x1b[A'); // ↑ back + await waitFor(() => cursorRow() === first); + + // v anchors at the cursor, ↓ extends, Ctrl+Y copies exactly that range. + await pressKey(stdin, '\x1b[B'); // ↓ → line 1 + await pressKey(stdin, 'v'); + await waitFor(() => term.text().includes('Copy selection')); + await pressKey(stdin, '\x1b[B'); // ↓ extend → line 2 + await pressKey(stdin, '\x19'); // Ctrl+Y + + const copied = copyToClipboardMock.mock.calls[0]?.[0] as string; + expect(copied.split('\n')).toHaveLength(2); + expect(copied).toContain('"r00"'); + expect(copied).toContain('"r01"'); + expect(copied).not.toContain('"r02"'); + + instance.unmount(); + }); + + it('toggles a full-width view with f so mouse selection cannot stray', async () => { + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); + await waitFor(() => term.text().includes('Result: ✓')); + // Two-column layout: the tool list occupies the left column. + expect(term.text()).toContain('▶ ✓'); + + // Measure the BOX itself: rendered rows are full lines, and in the + // two-column layout the box starts partway across. + const boxWidth = (line: string): number => + line.trimEnd().length - Math.max(0, line.indexOf('╭')); + const narrowBox = + term + .text() + .split('\n') + .find((l) => l.includes('╭')) ?? ''; + + await pressKey(stdin, 'f'); // full width + await waitFor(() => !term.text().includes('▶ ✓')); + + const wideBox = + term + .text() + .split('\n') + .find((l) => l.includes('╭')) ?? ''; + expect(boxWidth(wideBox)).toBeGreaterThan(boxWidth(narrowBox) + 20); + expect(term.text()).toContain('Full width'); + + await pressKey(stdin, 'f'); // back to two columns + await waitFor(() => term.text().includes('▶ ✓')); + + instance.unmount(); + }); + + it('leaves exactly one blank row under each section title', async () => { + const { instance, term } = renderApp(30, 100); + + await waitFor(() => term.text().includes('PARAMETERS (4)')); + const lines = term.text().split('\n'); + const descTitle = lines.findIndex((l) => l.includes('DESCRIPTION')); + const descBody = lines.findIndex((l) => l.includes('mock tool with parameters')); + const paramTitle = lines.findIndex((l) => l.includes('PARAMETERS (4)')); + const firstParam = lines.findIndex((l) => l.includes('1 q string')); + + expect(descTitle).toBeGreaterThan(-1); + expect(paramTitle).toBeGreaterThan(-1); + expect(firstParam).toBeGreaterThan(-1); + // Title, one blank row, then content — for each section. + expect(descBody).toBe(descTitle + 2); + expect(paramTitle).toBe(descBody + 2); + expect(firstParam).toBe(paramTitle + 2); + + instance.unmount(); + }); + + it('keeps a literal section heading for each region with the same focus bar', async () => { + const { instance, term, stdin } = renderApp(30, 100); + + await waitFor(() => term.text().includes('Parameters (4)')); + // The bar glyph is identical in both focus states — focus is carried by the + // bar's color, which the SGR-stripping harness cannot observe. + expect(term.text()).toContain('▌ DESCRIPTION'); + expect(term.text()).toContain('▌ PARAMETERS (4)'); + // The rule is drawn out to the panel width. + expect( + term + .text() + .split('\n') + .some((l) => l.includes('DESCRIPTION ───')) + ).toBe(true); + + // Moving focus between regions must not change the glyph. + await pressKey(stdin, '\t'); + await waitFor(() => term.text().includes('▌ PARAMETERS (4)')); + expect(term.text()).toContain('▌ DESCRIPTION'); + + instance.unmount(); + }); +}); diff --git a/tests/integration/tui-service-tools-scroll.test.ts b/tests/integration/tui-service-tools-scroll.test.ts index aadbaf9..be5d770 100644 --- a/tests/integration/tui-service-tools-scroll.test.ts +++ b/tests/integration/tui-service-tools-scroll.test.ts @@ -28,6 +28,12 @@ const { fetchServiceToolsMock } = vi.hoisted(() => { vi.mock('../../src/tui/discovery-worker.js', () => ({ __esModule: true, fetchServiceTools: fetchServiceToolsMock, + // ServiceTools also imports the call API; tests here never invoke it but + // the named exports must exist for the module import to succeed. + callServiceTool: vi.fn(), + ToolCallError: class ToolCallError extends Error {}, + DiscoveryError: class DiscoveryError extends Error {}, + DiscoveryErrorType: { TIMEOUT: 'timeout', CONNECTION_FAILED: 'connection_failed' }, default: fetchServiceToolsMock, })); @@ -266,7 +272,7 @@ describe('ServiceTools scroll indicator (real components, optimized chrome)', () const ellipsisCol = row.indexOf('…'); expect(ellipsisCol).toBeGreaterThan(0); expect(ellipsisCol).toBeLessThan(LEFT_PANEL_WIDTH); - const descCol = row.indexOf('Description'); + const descCol = row.indexOf('DESCRIPTION'); if (descCol > -1) { expect(ellipsisCol).toBeLessThan(descCol); } diff --git a/tests/unit/tui/clipboard.test.ts b/tests/unit/tui/clipboard.test.ts new file mode 100644 index 0000000..85fdc18 --- /dev/null +++ b/tests/unit/tui/clipboard.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { clipboardCommands } from '../../../src/tui/clipboard.js'; + +describe('clipboardCommands', () => { + it('uses pbcopy on macOS and clip on Windows', () => { + expect(clipboardCommands('darwin')).toEqual([{ command: 'pbcopy', args: [] }]); + expect(clipboardCommands('win32')).toEqual([{ command: 'clip', args: [] }]); + }); + + it('prefers wl-copy over xclip on Linux', () => { + const commands = clipboardCommands('linux'); + expect(commands.map((c) => c.command)).toEqual(['wl-copy', 'xclip']); + expect(commands[1]?.args).toEqual(['-selection', 'clipboard']); + }); +}); diff --git a/tests/unit/tui/text-layout.test.ts b/tests/unit/tui/text-layout.test.ts new file mode 100644 index 0000000..92ce2b9 --- /dev/null +++ b/tests/unit/tui/text-layout.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import { + boxBottom, + boxRow, + boxTop, + displayWidth, + padDisplay, + SECTION_BAR, + sectionTitle, + truncateDisplay, + wrapDisplay, +} from '../../../src/tui/text-layout.js'; + +describe('displayWidth', () => { + it('counts CJK as two cells and ASCII as one', () => { + expect(displayWidth('abc')).toBe(3); + expect(displayWidth('批次')).toBe(4); + expect(displayWidth('批次ID列表')).toBe(10); + expect(displayWidth('batch_ids 数组')).toBe(14); + }); +}); + +describe('truncateDisplay', () => { + it('returns the string unchanged when it fits', () => { + expect(truncateDisplay('abc', 5)).toBe('abc'); + expect(truncateDisplay('批次', 4)).toBe('批次'); + }); + + it('appends … and never exceeds the width', () => { + const out = truncateDisplay('批次ID列表', 6); + expect(displayWidth(out)).toBeLessThanOrEqual(6); + expect(out.endsWith('…')).toBe(true); + }); + + it('never cuts a double-width character in half', () => { + const out = truncateDisplay('a批次列表很长', 5); + expect(displayWidth(out)).toBeLessThanOrEqual(5); + }); + + it('handles widths smaller than one CJK char', () => { + expect(truncateDisplay('批次', 1)).toBe('…'); + }); +}); + +describe('padDisplay', () => { + it('pads to the display width', () => { + expect(displayWidth(padDisplay('ab', 5))).toBe(5); + expect(displayWidth(padDisplay('批次', 6))).toBe(6); + }); + + it('never produces negative repeat counts', () => { + expect(padDisplay('批次列表超宽了', 2)).toBe('批次列表超宽了'); + }); +}); + +describe('wrapDisplay', () => { + it('preserves JSON indentation and hangs continuation lines', () => { + const json = JSON.stringify({ a: 1, b: { c: [1, 2, 3] } }, null, 2); + const lines = wrapDisplay(json, 12); + // Every logical line that was indented keeps its indent on continuations. + for (const line of lines) { + expect(displayWidth(line)).toBeLessThanOrEqual(12); + } + expect(lines).toContain(' "a": 1,'); + // The nested object's inner lines stay indented (not flattened to col 0). + expect(lines.some((l) => l.startsWith(' "c"'))).toBe(true); + }); + + it('wraps CJK by display width', () => { + const lines = wrapDisplay('批次ID列表,若传没有的批次ID则自动忽略,最多100个', 10); + expect(lines.length).toBeGreaterThan(1); + for (const line of lines) { + expect(displayWidth(line)).toBeLessThanOrEqual(10); + } + }); + + it('keeps a leading indent on wrapped CJK continuations', () => { + const lines = wrapDisplay(' 批次ID列表,若传没有的批次ID则自动忽略', 12); + expect(lines[0]?.startsWith(' ')).toBe(true); + expect(lines[1]?.startsWith(' ')).toBe(true); + for (const line of lines) { + expect(displayWidth(line)).toBeLessThanOrEqual(12); + } + }); + + it('maps empty input to a single empty line', () => { + expect(wrapDisplay('', 10)).toEqual(['']); + expect(wrapDisplay('a\n\nb', 10)).toEqual(['a', '', 'b']); + }); + + it('does not loop or drop rows at extremely narrow widths', () => { + const lines = wrapDisplay('批次内容', 1); // clamped to MIN_WRAP_WIDTH + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(displayWidth(line)).toBeLessThanOrEqual(4); + } + expect(wrapDisplay('ab\tcd', 8)).toEqual(['ab cd']); + }); +}); + +describe('box helpers', () => { + it('draws a top edge with an embedded title at exactly the panel width', () => { + const row = boxTop(' Result: ✓ 312ms ', 30); + expect(displayWidth(row)).toBe(32); // 2 corners + inner + expect(row.startsWith('╭')).toBe(true); + expect(row.endsWith('╮')).toBe(true); + expect(row).toContain('Result: ✓ 312ms'); + }); + + it('draws a bottom edge with a right-aligned hint', () => { + const row = boxBottom('↓ 12 more', 30); + expect(displayWidth(row)).toBe(32); + expect(row.startsWith('╰')).toBe(true); + expect(row.endsWith('╯')).toBe(true); + expect(row.trimEnd().endsWith('↓ 12 more╯')).toBe(true); + }); + + it('pads content rows to the inner width', () => { + const row = boxRow(' "total": 1,', 30); + expect(displayWidth(row)).toBe(32); + expect(row.startsWith('│')).toBe(true); + expect(row.endsWith('│')).toBe(true); + }); + + it('truncates oversized titles and CJK content safely', () => { + const top = boxTop(' Result: ✗ 一个非常非常长的错误信息标题 ', 20); + expect(displayWidth(top)).toBe(22); + const row = boxRow(' 中文内容也很长很长很长', 20); + expect(displayWidth(row)).toBe(22); + }); +}); + +describe('sectionTitle', () => { + it('renders a bar, uppercased label and a rule filling the width', () => { + const row = sectionTitle('Description', 40); + expect(displayWidth(row)).toBe(40); + expect(row.startsWith('▌ DESCRIPTION ')).toBe(true); + expect(row.endsWith('─')).toBe(true); + expect(row).toContain('DESCRIPTION'); + }); + + it('always leads with the same single-cell bar (focus is a color cue)', () => { + expect(SECTION_BAR).toBe('▌'); + expect(displayWidth(SECTION_BAR)).toBe(1); + expect(sectionTitle('Result', 20).startsWith(SECTION_BAR)).toBe(true); + // Degenerate widths fall back to the bare bar. + expect(sectionTitle('Description', 1)).toBe(SECTION_BAR); + expect(sectionTitle('Description', 0)).toBe(''); + }); + + it('never exceeds the width for long labels, CJK or tiny terminals', () => { + expect(displayWidth(sectionTitle('Parameters', 12))).toBeLessThanOrEqual(12); + expect(displayWidth(sectionTitle('参数区块标题很长很长', 20))).toBeLessThanOrEqual(20); + expect(displayWidth(sectionTitle('Description', 1))).toBeLessThanOrEqual(1); + }); +}); diff --git a/tests/unit/tui/tool-call-result.test.ts b/tests/unit/tui/tool-call-result.test.ts new file mode 100644 index 0000000..f53814d --- /dev/null +++ b/tests/unit/tui/tool-call-result.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatToolOutput, + normalizeToolResult, + ToolCallError, +} from '../../../src/tui/discovery-worker.js'; + +describe('formatToolOutput', () => { + it('pretty-prints JSON objects and arrays', () => { + expect(formatToolOutput('{"a":1,"b":[2,3]}')).toBe( + '{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}' + ); + expect(formatToolOutput(' [1, 2] ')).toBe('[\n 1,\n 2\n]'); + }); + + it('returns non-JSON text verbatim', () => { + expect(formatToolOutput('plain text output')).toBe('plain text output'); + expect(formatToolOutput('{"unterminated": ')).toBe('{"unterminated": '); + expect(formatToolOutput('42')).toBe('42'); + expect(formatToolOutput('"quoted string"')).toBe('"quoted string"'); + expect(formatToolOutput('')).toBe(''); + }); +}); + +describe('normalizeToolResult', () => { + it('joins multiple text blocks with newlines', () => { + const outcome = normalizeToolResult({ + content: [ + { type: 'text', text: 'a' }, + { type: 'text', text: 'b' }, + ], + }); + expect(outcome.isError).toBe(false); + expect(outcome.text).toBe('a\nb'); + expect(outcome.formatted).toBe('a\nb'); + expect(outcome.nonTextTypes).toEqual([]); + }); + + it('formats JSON text blocks while keeping the original text', () => { + const outcome = normalizeToolResult({ + content: [{ type: 'text', text: '{"total":1,"found":0}' }], + }); + expect(outcome.text).toBe('{"total":1,"found":0}'); + expect(outcome.formatted).toBe('{\n "total": 1,\n "found": 0\n}'); + }); + + it('preserves text for isError results without throwing', () => { + const outcome = normalizeToolResult({ + content: [{ type: 'text', text: 'boom happened' }], + isError: true, + }); + expect(outcome.isError).toBe(true); + expect(outcome.text).toBe('boom happened'); + }); + + it('records non-text block types and placeholders', () => { + const outcome = normalizeToolResult({ + content: [ + { type: 'image', data: 'base64data', mimeType: 'image/png' }, + { type: 'text', text: 'caption' }, + ], + }); + expect(outcome.nonTextTypes).toEqual(['image']); + expect(outcome.text).toBe('[image]\ncaption'); + }); + + it('includes the uri for resource blocks', () => { + const outcome = normalizeToolResult({ + content: [{ type: 'resource', resource: { uri: 'x://y' } }], + }); + expect(outcome.nonTextTypes).toEqual(['resource']); + expect(outcome.text).toBe('[resource: x://y]'); + }); + + it('falls back to structuredContent when there is no text content', () => { + const outcome = normalizeToolResult({ content: [], structuredContent: { a: 1 } }); + expect(outcome.text).toBe('{\n "a": 1\n}'); + }); + + it('renders (empty result) for an empty result object', () => { + expect(normalizeToolResult({ content: [] }).text).toBe('(empty result)'); + }); + + it('degrades raw output on circular structures without throwing', () => { + const circular: Record = {}; + circular['self'] = circular; + const outcome = normalizeToolResult({ content: [], extra: circular }); + expect(typeof outcome.raw).toBe('string'); + expect(outcome.text).toBe('(empty result)'); + }); + + it('handles non-object results without throwing', () => { + expect(normalizeToolResult(null).isError).toBe(false); + expect(normalizeToolResult('plain').text).toBe('(empty result)'); + expect(normalizeToolResult(42).raw).toBe('42'); + expect(normalizeToolResult(undefined).text).toBe('(empty result)'); + }); +}); + +describe('ToolCallError', () => { + it('carries the backend code and service name', () => { + const err = new ToolCallError('svc', 'bad params', -32602, { detail: 'x' }); + expect(err.name).toBe('ToolCallError'); + expect(err.serviceName).toBe('svc'); + expect(err.code).toBe(-32602); + expect(err.data).toEqual({ detail: 'x' }); + expect(err.message).toBe('bad params'); + }); + + it('leaves code/data undefined when absent', () => { + const err = new ToolCallError('svc', 'failed'); + // With useDefineForClassFields the declared fields exist as own + // properties — what matters for error classification is the value. + expect(err.code).toBeUndefined(); + expect(err.data).toBeUndefined(); + }); +}); diff --git a/tests/unit/tui/tool-param-schema.test.ts b/tests/unit/tui/tool-param-schema.test.ts new file mode 100644 index 0000000..98fa9a3 --- /dev/null +++ b/tests/unit/tui/tool-param-schema.test.ts @@ -0,0 +1,685 @@ +import { describe, expect, it } from 'vitest'; + +import { + bestEffortArgs, + buildParamRows, + buildToolArguments, + buildToolParams, + describeParamType, + formatParamValue, + seedFormValues, + UNSET_SENTINEL, + wrapText, +} from '../../../src/tui/tool-param-schema.js'; +import type { ToolParam } from '../../../src/tui/tool-param-schema.js'; + +describe('describeParamType', () => { + it('passes through the six primitive/object types', () => { + expect(describeParamType({ type: 'string' })).toEqual({ label: 'string', kind: 'string' }); + expect(describeParamType({ type: 'number' })).toEqual({ label: 'number', kind: 'number' }); + expect(describeParamType({ type: 'integer' })).toEqual({ label: 'integer', kind: 'integer' }); + expect(describeParamType({ type: 'boolean' })).toEqual({ label: 'boolean', kind: 'boolean' }); + expect(describeParamType({ type: 'object' })).toEqual({ label: 'object', kind: 'object' }); + }); + + it('labels arrays with their element type', () => { + expect(describeParamType({ type: 'array', items: { type: 'number' } })).toEqual({ + label: 'array', + kind: 'array', + }); + expect(describeParamType({ type: 'array' })).toEqual({ label: 'array', kind: 'array' }); + }); + + it('detects enum from the first value type', () => { + expect(describeParamType({ type: 'string', enum: ['a', 'b'] })).toEqual({ + label: 'string (enum)', + kind: 'string', + }); + expect(describeParamType({ enum: [1, 2] })).toEqual({ label: 'number (enum)', kind: 'number' }); + }); + + it('joins anyOf/oneOf member labels and degrades the kind', () => { + expect(describeParamType({ anyOf: [{ type: 'string' }, { type: 'number' }] })).toEqual({ + label: 'string | number', + kind: 'unknown', + }); + expect(describeParamType({ oneOf: [{ type: 'string' }] })).toEqual({ + label: 'string', + kind: 'unknown', + }); + }); + + it('caps union labels at three members', () => { + const prop = { + anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }, { type: 'object' }], + }; + expect(describeParamType(prop).label).toBe('string | number | boolean | …'); + }); + + it('degrades unknown shapes without throwing', () => { + expect(describeParamType({ $ref: '#/$defs/x' })).toEqual({ + label: 'unknown ($ref)', + kind: 'unknown', + }); + expect(describeParamType({})).toEqual({ label: 'unknown', kind: 'unknown' }); + expect(describeParamType({ type: 42 })).toEqual({ label: 'unknown', kind: 'unknown' }); + expect(describeParamType({ enum: 'not-an-array' })).toEqual({ + label: 'unknown', + kind: 'unknown', + }); + }); +}); + +describe('buildToolParams', () => { + it('returns [] for undefined schema and empty properties', () => { + expect(buildToolParams(undefined)).toEqual([]); + expect(buildToolParams({ type: 'object', properties: {} })).toEqual([]); + }); + + it('preserves declaration order and required flags', () => { + const params = buildToolParams({ + type: 'object', + properties: { + b: { type: 'string' }, + a: { type: 'number' }, + c: { type: 'boolean' }, + }, + required: ['a'], + }); + expect(params.map((p) => p.name)).toEqual(['b', 'a', 'c']); + expect(params.map((p) => p.required)).toEqual([false, true, false]); + }); + + it('keeps falsy defaults (default: 0 / empty string)', () => { + const params = buildToolParams({ + type: 'object', + properties: { + zero: { type: 'number', default: 0 }, + empty: { type: 'string', default: '' }, + }, + }); + const zero = params.find((p) => p.name === 'zero'); + const empty = params.find((p) => p.name === 'empty'); + expect(zero?.defaultValue).toBe(0); + expect(empty?.defaultValue).toBe(''); + }); + + it('skips non-record properties and copies descriptions', () => { + const params = buildToolParams({ + type: 'object', + properties: { + good: { type: 'string', description: 'hello' }, + bad: true, + }, + }); + expect(params).toHaveLength(1); + expect(params[0]?.description).toBe('hello'); + }); + + it('records enum values and array item kinds', () => { + const params = buildToolParams({ + type: 'object', + properties: { + tags: { type: 'array', items: { type: 'string' } }, + level: { type: 'number', enum: [1, 2, 3] }, + }, + }); + expect(params.find((p) => p.name === 'tags')?.itemKind).toBe('string'); + expect(params.find((p) => p.name === 'level')?.enumValues).toEqual([1, 2, 3]); + }); +}); + +describe('wrapText', () => { + it('keeps every line within width and preserves word order', () => { + const lines = wrapText('aaa bbb ccc ddd eee fff', 7); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(7); + } + // wrap-ansi (trim:false) may leave the breaking space on the next line; + // whitespace-collapsed join must still reproduce the original words. + const rejoined = lines.join(' ').split(/\s+/).filter(Boolean).join(' '); + expect(rejoined).toBe('aaa bbb ccc ddd eee fff'); + }); + + it('hard-splits words longer than width', () => { + const lines = wrapText('abcdefghij', 4); + expect(lines).toEqual(['abcd', 'efgh', 'ij']); + }); + + it('returns a single empty line for empty input and handles newlines', () => { + expect(wrapText('', 10)).toEqual(['']); + expect(wrapText('a\n\nb', 10)).toEqual(['a', '', 'b']); + }); + + it('preserves JSON indentation with hanging continuations', () => { + const lines = wrapText('{\n "tool_names": ["aaa", "bbb"],\n}', 16); + // The 4-space JSON indent survives (old wrapText stripped it entirely). + expect(lines).toContain('{'); + expect(lines).toContain(' "tool_names"'); + for (const line of lines) { + if (line.startsWith(' ') || line.startsWith(' ')) { + expect(line.startsWith(' ')).toBe(true); // hanging indent, not col 0 + } + expect(line.length).toBeLessThanOrEqual(18); // indent + body ≤ width + slack + } + expect(lines[lines.length - 1]).toBe('}'); + }); + + it('never collapses to [] for non-empty input', () => { + expect(wrapText('x', 1)).toEqual(['x']); + }); +}); + +describe('buildParamRows', () => { + const makeParam = (overrides: Partial & { name: string }): ToolParam => ({ + kind: 'string', + typeLabel: 'string', + required: false, + description: '', + raw: {}, + ...overrides, + }); + + it('renders a placeholder row for no parameters', () => { + expect(buildParamRows([], {}, 40, null)).toEqual([{ kind: 'text', text: '(no parameters)' }]); + }); + + it('keeps one description line per unfocused parameter, separated by a rule', () => { + const params: ToolParam[] = [ + makeParam({ name: 'q', required: true, description: 'query text' }), + makeParam({ name: 'limit', kind: 'integer', typeLabel: 'integer' }), + ]; + const rows = buildParamRows(params, { q: 'hello', limit: '' }, 40, null); + const texts = rows.map((r) => (r.kind === 'text' ? r.text : '')); + expect(texts).toEqual([ + ' 1 q string *required', + ' query text', + ' = hello', + ` ${'─'.repeat(37)}`, + ' 2 limit integer', + ' = (unset)', + ]); + }); + + it('truncates an unfocused description to a single line and marks the cut', () => { + const long = 'alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu'; + const params: ToolParam[] = [ + { ...makeParam({ name: 'q', description: long }), raw: {} }, + makeParam({ + name: 'multi', + description: 'line one\nline two\nline three', + enumValues: [1, 2], + kind: 'number', + typeLabel: 'number (enum)', + }), + ]; + const texts = buildParamRows(params, {}, 40, null).map((r) => + r.kind === 'text' ? r.text : '' + ); + // One truncated line each: the `…` is the "there is more" cue, and a + // multi-line description collapses onto a single row. + expect(texts).toEqual([ + ' 1 q string', + ' alpha beta gamma delta epsilon zet…', + ' = (unset)', + ` ${'─'.repeat(37)}`, + ' 2 multi number (enum)', + ' line one line two line three', + ' = (unset)', + ]); + const firstDesc = texts[1] ?? ''; + expect(firstDesc.endsWith('…')).toBe(true); + expect(firstDesc.length).toBeLessThanOrEqual(40); + }); + + it('expands description, enum and default only for the focused parameter', () => { + const params: ToolParam[] = [ + makeParam({ name: 'q', description: 'query text' }), + makeParam({ + name: 'level', + kind: 'number', + typeLabel: 'number (enum)', + description: 'which level', + enumValues: [1, 2], + defaultValue: 1, + }), + ]; + const toText = (rows: ReturnType): string[] => + rows.map((r) => (r.kind === 'text' ? r.text : '')); + + expect(toText(buildParamRows(params, {}, 40, null))).toEqual([ + ' 1 q string', + ' query text', + ' = (unset)', + ` ${'─'.repeat(37)}`, + ' 2 level number (enum)', + ' which level', + ' = (unset)', + ]); + expect(toText(buildParamRows(params, {}, 40, 'level'))).toEqual([ + ' 1 q string', + ' query text', + ' = (unset)', + ` ${'─'.repeat(37)}`, + '▶ 2 level number (enum)', + ' which level', + ' enum: 1 | 2', + ' default: 1', + '', + ]); + }); + + it('swaps only the focused parameter value row for an editor row', () => { + const params: ToolParam[] = [ + makeParam({ name: 'q', required: true, description: 'query text' }), + makeParam({ name: 'limit', kind: 'integer', typeLabel: 'integer' }), + ]; + const rows = buildParamRows(params, { q: 'hello', limit: '' }, 40, 'q'); + const texts = rows.map((r) => (r.kind === 'text' ? r.text : '')); + expect(texts).toEqual([ + '▶ 1 q string *required', + ' query text', + '', + ` ${'─'.repeat(37)}`, + ' 2 limit integer', + ' = (unset)', + ]); + }); + + it('keeps segments consistent with text and drops them when truncated', () => { + const params: ToolParam[] = [ + makeParam({ name: 'q', required: true, description: 'query text' }), + makeParam({ name: 'averyveryverylongparametername', typeLabel: 'unknown ($ref)' }), + ]; + const rows = buildParamRows(params, { q: 'hello' }, 40, 'q'); + for (const row of rows) { + if (row.kind === 'text' && row.segments !== undefined) { + expect(row.segments.map((s) => s.text).join('')).toBe(row.text); + } + } + // A row too narrow to render verbatim must carry NO segments: truncation + // happens on the joined string, so post-hoc segments would mis-align. + const narrow = buildParamRows(params, { q: 'hello' }, 20, null); + for (const row of narrow) { + if (row.kind === 'text' && row.segments !== undefined) { + expect(row.text.length).toBeLessThanOrEqual(20); + expect(row.segments.map((s) => s.text).join('')).toBe(row.text); + } + } + expect(narrow.some((r) => r.kind === 'text' && r.segments === undefined)).toBe(true); + }); + + it('tones the name as primary, the type as muted and the required mark as critical', () => { + const params: ToolParam[] = [makeParam({ name: 'q', required: true })]; + const row = buildParamRows(params, {}, 40, null)[0]; + expect(row?.kind).toBe('text'); + if (row?.kind === 'text') { + const tones = new Map(row.segments?.map((s) => [s.text, s.tone])); + expect(tones.get('q')).toBe('primary'); + expect(tones.get(' string')).toBe('muted'); + expect(tones.get(' *required')).toBe('critical'); + } + }); + + it('keeps the load-bearing name-row template as a substring', () => { + const params: ToolParam[] = [ + makeParam({ name: 'q', required: true }), + makeParam({ name: 'limit', kind: 'integer', typeLabel: 'integer' }), + makeParam({ name: 'tags', kind: 'array', typeLabel: 'array' }), + ]; + const texts = buildParamRows(params, {}, 40, null).map((r) => + r.kind === 'text' ? r.text : '' + ); + const joined = texts.join('\n'); + // Integration tests assert these as substrings — the gutter must only + // ever be a PREFIX of the name row. + expect(joined).toContain('q string *required'); + expect(joined).toContain('limit integer'); + expect(joined).toContain('tags array'); + }); + + it('marks the expanded field with ▶ and emits an input row', () => { + const params: ToolParam[] = [makeParam({ name: 'q' })]; + const rows = buildParamRows(params, { q: '' }, 40, 'q'); + expect(rows[0]?.kind).toBe('text'); + expect(rows[0]?.kind === 'text' ? rows[0].text : '').toBe('▶ 1 q string'); + expect(rows[rows.length - 1]).toEqual({ kind: 'input', param: params[0] }); + }); + + it('emits a select row for focused boolean/enum fields', () => { + const boolParam = makeParam({ name: 'flag', kind: 'boolean', typeLabel: 'boolean' }); + const enumParam = makeParam({ + name: 'level', + kind: 'number', + typeLabel: 'number (enum)', + enumValues: [1, 2], + }); + expect(buildParamRows([boolParam], { flag: '' }, 40, 'flag').at(-1)).toEqual({ + kind: 'select', + param: boolParam, + }); + expect(buildParamRows([enumParam], { level: '' }, 40, 'level').at(-1)).toEqual({ + kind: 'select', + param: enumParam, + }); + }); + + it('keeps every text row within width', () => { + const params: ToolParam[] = [ + makeParam({ + name: 'averyveryverylongparameternamewithatype', + description: 'word '.repeat(30).trim(), + }), + ]; + const rows = buildParamRows(params, {}, 30, null); + for (const row of rows) { + if (row.kind === 'text') { + expect(row.text.length).toBeLessThanOrEqual(30); + } + } + }); + + it('truncates long unfocused values instead of wrapping', () => { + const params: ToolParam[] = [makeParam({ name: 'v' })]; + const rows = buildParamRows(params, { v: 'x'.repeat(100) }, 20, null); + const valueRow = rows.at(-1); + expect(valueRow?.kind).toBe('text'); + if (valueRow?.kind === 'text') { + expect(valueRow.text.length).toBeLessThanOrEqual(20); + expect(valueRow.text.endsWith('…')).toBe(true); + } + }); +}); + +describe('formatParamValue', () => { + const param: ToolParam = { + name: 'v', + kind: 'string', + typeLabel: 'string', + required: false, + description: '', + raw: {}, + }; + + it('shows (unset) for blank and sentinel values', () => { + expect(formatParamValue(param, { v: '' })).toBe('(unset)'); + expect(formatParamValue(param, { v: UNSET_SENTINEL })).toBe('(unset)'); + expect(formatParamValue(param, {})).toBe('(unset)'); + }); + + it('returns the raw value otherwise', () => { + expect(formatParamValue(param, { v: '[1,2]' })).toBe('[1,2]'); + }); +}); + +describe('seedFormValues', () => { + it('prefills declared defaults and blanks everything else', () => { + const params: ToolParam[] = [ + { + name: 'a', + kind: 'number', + typeLabel: 'number', + required: false, + description: '', + defaultValue: 5, + raw: {}, + }, + { + name: 'b', + kind: 'array', + typeLabel: 'array', + required: false, + description: '', + defaultValue: [1, 2], + raw: {}, + }, + { name: 'c', kind: 'string', typeLabel: 'string', required: false, description: '', raw: {} }, + ]; + expect(seedFormValues(params)).toEqual({ a: '5', b: '[1,2]', c: '' }); + }); +}); + +describe('buildToolArguments', () => { + it('omits blank optional fields entirely', () => { + const params: ToolParam[] = [ + { + name: 'req', + kind: 'string', + typeLabel: 'string', + required: true, + description: '', + raw: {}, + }, + { + name: 'opt', + kind: 'string', + typeLabel: 'string', + required: false, + description: '', + raw: {}, + }, + ]; + const result = buildToolArguments(params, { req: 'x', opt: '' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(Object.keys(result.args)).toEqual(['req']); + expect(Object.keys(result.args)).not.toContain('opt'); + } + }); + + it('collects an error for a blank required field', () => { + const params: ToolParam[] = [ + { + name: 'req', + kind: 'string', + typeLabel: 'string', + required: true, + description: '', + raw: {}, + }, + ]; + const result = buildToolArguments(params, {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors['req']).toBe('is required'); + } + }); + + it('coerces numbers and rejects non-numeric input', () => { + const params: ToolParam[] = [ + { name: 'n', kind: 'number', typeLabel: 'number', required: false, description: '', raw: {} }, + { + name: 'i', + kind: 'integer', + typeLabel: 'integer', + required: false, + description: '', + raw: {}, + }, + ]; + const ok = buildToolArguments(params, { n: '42.5', i: '7' }); + expect(ok).toEqual({ ok: true, args: { n: 42.5, i: 7 } }); + + const bad = buildToolArguments(params, { n: 'abc', i: '1.5' }); + expect(bad.ok).toBe(false); + if (!bad.ok) { + expect(bad.errors['n']).toBe('must be a number'); + expect(bad.errors['i']).toBe('must be an integer'); + } + }); + + it('treats the unset sentinel as blank', () => { + const params: ToolParam[] = [ + { + name: 'flag', + kind: 'boolean', + typeLabel: 'boolean', + required: false, + description: '', + raw: {}, + }, + { + name: 'must', + kind: 'boolean', + typeLabel: 'boolean', + required: true, + description: '', + raw: {}, + }, + ]; + const omitted = buildToolArguments(params, { flag: UNSET_SENTINEL, must: 'true' }); + expect(omitted.ok).toBe(true); + if (omitted.ok) { + expect(Object.keys(omitted.args)).toEqual(['must']); + } + + const error = buildToolArguments(params, { flag: 'true', must: UNSET_SENTINEL }); + expect(error.ok).toBe(false); + if (!error.ok) { + expect(error.errors['must']).toBe('is required'); + } + }); + + it('coerces booleans and rejects other strings', () => { + const params: ToolParam[] = [ + { + name: 'flag', + kind: 'boolean', + typeLabel: 'boolean', + required: false, + description: '', + raw: {}, + }, + ]; + expect(buildToolArguments(params, { flag: 'true' })).toEqual({ + ok: true, + args: { flag: true }, + }); + expect(buildToolArguments(params, { flag: 'false' })).toEqual({ + ok: true, + args: { flag: false }, + }); + const bad = buildToolArguments(params, { flag: 'yes' }); + expect(bad.ok).toBe(false); + }); + + it('parses arrays strictly', () => { + const params: ToolParam[] = [ + { name: 'ids', kind: 'array', typeLabel: 'array', required: false, description: '', raw: {} }, + ]; + expect(buildToolArguments(params, { ids: '[1,2]' })).toEqual({ + ok: true, + args: { ids: [1, 2] }, + }); + const notArray = buildToolArguments(params, { ids: '{"a":1}' }); + expect(notArray.ok).toBe(false); + const malformed = buildToolArguments(params, { ids: '[1' }); + expect(malformed.ok).toBe(false); + }); + + it('parses objects strictly', () => { + const params: ToolParam[] = [ + { + name: 'cfg', + kind: 'object', + typeLabel: 'object', + required: false, + description: '', + raw: {}, + }, + ]; + expect(buildToolArguments(params, { cfg: '{"a":1}' })).toEqual({ + ok: true, + args: { cfg: { a: 1 } }, + }); + const notObject = buildToolArguments(params, { cfg: '[1,2]' }); + expect(notObject.ok).toBe(false); + }); + + it('coerces numeric enum members to numbers and validates membership', () => { + const params: ToolParam[] = [ + { + name: 'level', + kind: 'number', + typeLabel: 'number (enum)', + required: false, + description: '', + enumValues: [1, 2, 3], + raw: {}, + }, + ]; + const ok = buildToolArguments(params, { level: '2' }); + expect(ok).toEqual({ ok: true, args: { level: 2 } }); + const bad = buildToolArguments(params, { level: '9' }); + expect(bad.ok).toBe(false); + if (!bad.ok) { + expect(bad.errors['level']).toBe('must be one of: 1, 2, 3'); + } + }); + + it('collects every field error at once', () => { + const params: ToolParam[] = [ + { name: 'a', kind: 'number', typeLabel: 'number', required: true, description: '', raw: {} }, + { name: 'b', kind: 'array', typeLabel: 'array', required: false, description: '', raw: {} }, + { name: 'c', kind: 'string', typeLabel: 'string', required: true, description: '', raw: {} }, + ]; + const result = buildToolArguments(params, { a: 'NaN?', b: '[', c: '' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(Object.keys(result.errors).sort()).toEqual(['a', 'b', 'c']); + } + }); + + it('falls back to raw string when unknown-kind JSON parsing fails', () => { + const params: ToolParam[] = [ + { + name: 'mystery', + kind: 'unknown', + typeLabel: 'unknown', + required: false, + description: '', + raw: {}, + }, + ]; + expect(buildToolArguments(params, { mystery: '{"k":1}' })).toEqual({ + ok: true, + args: { mystery: { k: 1 } }, + }); + expect(buildToolArguments(params, { mystery: 'plain text' })).toEqual({ + ok: true, + args: { mystery: 'plain text' }, + }); + }); +}); + +describe('bestEffortArgs', () => { + it('drops fields that fail coercion instead of erroring', () => { + const params: ToolParam[] = [ + { + name: 'good', + kind: 'number', + typeLabel: 'number', + required: false, + description: '', + raw: {}, + }, + { + name: 'bad', + kind: 'number', + typeLabel: 'number', + required: false, + description: '', + raw: {}, + }, + { + name: 'missing', + kind: 'string', + typeLabel: 'string', + required: true, + description: '', + raw: {}, + }, + ]; + expect(bestEffortArgs(params, { good: '1', bad: 'nope' })).toEqual({ good: 1 }); + }); +}); From 28b6acdd84762eabe41e49e4934ad990ab059a0b Mon Sep 17 00:00:00 2001 From: kugouming Date: Sat, 12 Sep 2026 17:34:20 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix(config):=20=E6=8B=92=E7=BB=9D=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=8C=96=E5=90=8E=E4=B8=8D=E5=90=AB=20ASCII=20?= =?UTF-8?q?=E5=AD=97=E6=AF=8D=E6=95=B0=E5=AD=97=E7=9A=84=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置校验只在「两个名字归一化后相同」时报冲突,而归一化会剥掉所有非 ASCII 字符: 单个纯中文名(如「服务端-甲」)静默通过、命名空间前缀退化成 "-",两个不同的中文名 则被判为冲突,报错信息("collides with … after namespace normalization")用户无从下手。 改为归一化后不含 [a-z0-9] 时直接给出可读错误(名字即工具命名空间前缀);带空格的名字 (如 "yapi product")不受影响。新增单测覆盖两种情形,并把属性测试的服务名生成器约束到 该契约(原先 fc.string() 会生成被规则拒绝的名字)。 --- src/config/file-provider.ts | 16 ++++++++++ .../service-registry.property.test.ts | 10 ++++-- tests/unit/config/file-provider.test.ts | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/config/file-provider.ts b/src/config/file-provider.ts index 3254e00..e66ef9f 100644 --- a/src/config/file-provider.ts +++ b/src/config/file-provider.ts @@ -437,6 +437,22 @@ export class FileConfigProvider implements ConfigProvider { .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9\-_]/g, ''); + + // The normalized name becomes the tool namespace prefix + // (`{service}__{tool}`), so it has to stay distinguishable. A name that + // normalizes to separators only (e.g. a fully non-ASCII name) yields the + // SAME prefix for every such service — with one such name the collision + // check below can't see it, and with two the error is unreadable. + if (!/[a-z0-9]/.test(normalized)) { + errors.push({ + field: `mcpServers.${serviceName}`, + message: `Service name "${serviceName}" must contain at least one ASCII letter or digit — the name becomes the tool namespace prefix`, + expected: 'a name containing [A-Za-z0-9]', + actual: serviceName, + }); + continue; + } + const existing = normalizedNames.get(normalized); if (existing !== undefined) { errors.push({ diff --git a/tests/property/service-registry.property.test.ts b/tests/property/service-registry.property.test.ts index 2dc5800..98f45ef 100644 --- a/tests/property/service-registry.property.test.ts +++ b/tests/property/service-registry.property.test.ts @@ -33,10 +33,16 @@ const DANGEROUS_KEYS = new Set([ 'hasOwnProperty', ]); +/** + * Service names must satisfy the documented contract: the name becomes the tool + * namespace prefix, so its normalized form (lowercased, whitespace → '-', other + * non-ASCII stripped) has to keep at least one ASCII alphanumeric. Generating + * raw `fc.string()` produced names the config validator now rejects. + */ const serviceNameArbitrary = (): fc.Arbitrary => fc - .string({ minLength: 1, maxLength: 50 }) - .filter((s) => s.trim().length > 0 && !DANGEROUS_KEYS.has(s.trim())) + .stringMatching(/[a-zA-Z0-9][a-zA-Z0-9 _.-]{0,20}/) + .filter((s) => s.trim().length > 0 && s.trim().length <= 50 && !DANGEROUS_KEYS.has(s.trim())) .map((s) => s.trim()); /** diff --git a/tests/unit/config/file-provider.test.ts b/tests/unit/config/file-provider.test.ts index b73c534..90033de 100644 --- a/tests/unit/config/file-provider.test.ts +++ b/tests/unit/config/file-provider.test.ts @@ -276,6 +276,37 @@ describe('FileConfigProvider', () => { expect(result.errors.some((error) => error.message.includes('collides'))).toBe(true); }); + it('should reject a service name that normalizes to separators only', () => { + // A fully non-ASCII name normalizes to '-' — every such name would share + // one tool-namespace prefix, so a single one has to be rejected too (the + // collision check cannot see it). + const invalidConfig: SystemConfig = { + ...validConfig, + mcpServers: { '服务端-甲': validConfig.mcpServers['test-service']! }, + }; + + const result = provider.validate(invalidConfig); + + expect(result.valid).toBe(false); + expect( + result.errors.some((error) => error.message.includes('at least one ASCII letter or digit')) + ).toBe(true); + }); + + it('should accept names with spaces (normalized to hyphens)', () => { + const config: SystemConfig = { + ...validConfig, + mcpServers: { + 'yapi product': validConfig.mcpServers['test-service']!, + 'yapi supply': validConfig.mcpServers['test-service']!, + }, + }; + + const result = provider.validate(config); + + expect(result.valid).toBe(true); + }); + it('should reject missing required fields', () => { // Arrange const invalidConfig = { ...validConfig }; From 64485ed6f8c95ff7cb569d32ece4623004bf91cf Mon Sep 17 00:00:00 2001 From: kugouming Date: Sat, 12 Sep 2026 17:35:26 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(tui):=20=E4=BF=AE=E5=A4=8D=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=BA=A2=E5=87=BA/=E8=BE=93=E5=85=A5=E6=B1=A1?= =?UTF-8?q?=E6=9F=93/=E9=80=80=E5=87=BA=E5=A4=B1=E6=95=88=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=AF=B9=E9=BD=90=E8=B7=AF=E7=94=B1=E5=B1=82=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两轮走查确认的问题合并修复: - 帧溢出导致整屏错乱(根因):列表按「每服务 1 行」估高而端点实际折行 2-3 行、表单按 「每字段 1 行」估高,内容高于视口后 ink 的绝对定位写入落到错误行。改为每服务严格一行 (端点/标签省略号截断)、表单按真实字段行高开窗、app 层下传正确的 height 预算; 窄终端(<90 列)依次丢 tags 列 → tools 列 → 收窄 name 列,端点保底 10 列 - Ctrl+S 会把 's' 敲进当前输入框(连按两次可将 's' 当 command 落库):文本框改用 SingleLineInput,组合键不再泄漏;统一表单校验失败改为可见提示 - 粘贴/多字符输入被静默丢弃(参数、原始 JSON、搜索框):新增 input-text 判定 - Ctrl+C 不退出(有服务配置时进程被连接池/定时器/stdio 子进程挂住):ink 在 exitOnCtrlC=true 时不把 Ctrl+C 交给 useInput,改为 exitOnCtrlC:false + 应用内 unmount 后显式 process.exit;删除/重名覆盖新增 y/n 确认框(仅显式 y 生效) - 工具调用重试只覆盖「会话过期」而注释声称与 ToolRouter 一致:改为同一判定 (会话过期 + 可重连传输死亡),并给「一次性会话在应答前结束」引入可机读的 SessionClosedError —— stdio 后端崩在调用中途会重连重放 - 旧 UI(ONEMCP_USE_LEGACY_UI):高度预算未跟随组件语义变更(帧溢出仍可复现)、 `t` 键被前面的分支劫持成「打开工具视图」(与文档「Space/t 切换启用」不符) - 单行输入按显示宽度开窗(中文不再被切坏);结果被 200K 字符截断时给出提示; 自身写盘不再触发「外部变更」误导提示;footer 显示真实配置目录 - 存档提示在无剪贴板工具时不再把失败藏成「已保存」 - 清理:删除死组件(ServiceJsonEditor/FileImportDialog/Footer)、空转 props (showDetails/globalToolStats)、重复的 JSON 包装(统一到 utils/safe-json) --- src/cli.ts | 4 +- src/tui.ts | 20 +- src/tui/app-optimized.tsx | 184 +++++++--- src/tui/app.tsx | 224 ++++++------ src/tui/components/FileImportDialog.tsx | 114 ------ src/tui/components/Footer.tsx | 68 ---- src/tui/components/Header.tsx | 10 +- src/tui/components/HelpDialog.tsx | 5 +- src/tui/components/JsonTextArea.tsx | 14 +- src/tui/components/ServiceForm.tsx | 204 ++++++++--- src/tui/components/ServiceFormUnified.tsx | 333 +++++++++++------ src/tui/components/ServiceJsonEditor.tsx | 422 ---------------------- src/tui/components/ServiceList.tsx | 260 ++++++++----- src/tui/components/ServiceTools.tsx | 91 +++-- src/tui/components/SingleLineInput.tsx | 67 +++- src/tui/components/StatusBar.tsx | 39 +- src/tui/discovery-worker.ts | 153 ++++---- src/tui/input-text.ts | 36 ++ src/utils/safe-json.ts | 16 + 19 files changed, 1112 insertions(+), 1152 deletions(-) delete mode 100644 src/tui/components/FileImportDialog.tsx delete mode 100644 src/tui/components/Footer.tsx delete mode 100644 src/tui/components/ServiceJsonEditor.tsx create mode 100644 src/tui/input-text.ts create mode 100644 src/utils/safe-json.ts diff --git a/src/cli.ts b/src/cli.ts index 4e1ec20..702c8e9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -614,7 +614,9 @@ async function main(): Promise { log.setStderrEnabled(false); const { runApp } = await import('./tui.js'); - await runApp(config, configProvider); + // Pass the RESOLVED directory (CLI flag / env / default), not the stale + // value recorded inside config.json. + await runApp(config, configProvider, configDir); } else { // Daemon mode: spawn detached process and exit if (args.daemon) { diff --git a/src/tui.ts b/src/tui.ts index 25c34c1..420608a 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -27,9 +27,23 @@ const USE_OPTIMIZED_UI = process.env['ONEMCP_USE_LEGACY_UI'] !== 'true'; /** * Run the TUI application with a config and configProvider */ -export async function runApp(config: SystemConfig, configProvider: ConfigProvider): Promise { +export async function runApp( + config: SystemConfig, + configProvider: ConfigProvider, + configDir?: string +): Promise { const AppComponent = USE_OPTIMIZED_UI ? TuiAppOptimized : TuiApp; - const { waitUntilExit } = render(React.createElement(AppComponent, { config, configProvider })); + const { waitUntilExit } = render( + React.createElement( + AppComponent, + configDir === undefined ? { config, configProvider } : { config, configProvider, configDir } + ), + // Ink's built-in Ctrl+C handling only UNMOUNTS, and it swallows the key from + // every useInput subscriber when enabled. With pools, health timers and + // stdio children alive, unmounting leaves the process running forever — so + // the app handles Ctrl+C itself and exits for real. + { exitOnCtrlC: false } + ); await waitUntilExit(); } @@ -192,7 +206,7 @@ async function main(): Promise { configureLogger(config); setStderrEnabled(false); - await runApp(config, configProvider); + await runApp(config, configProvider, configDir); } const isTuiDirectCall = process.argv[1]?.includes('tui'); diff --git a/src/tui/app-optimized.tsx b/src/tui/app-optimized.tsx index dfafb53..1cd6055 100644 --- a/src/tui/app-optimized.tsx +++ b/src/tui/app-optimized.tsx @@ -5,7 +5,7 @@ */ import React, { useState, useEffect, useRef } from 'react'; -import { Box, Text, useInput, useStdout } from 'ink'; +import { Box, Text, useApp, useInput, useStdout } from 'ink'; import { FileConfigProvider } from '../config/file-provider.js'; import { FileStorageAdapter } from '../storage/file.js'; import { ServiceRegistry } from '../registry/service-registry.js'; @@ -43,6 +43,11 @@ type AppState = 'loading' | 'ready' | 'error'; */ type ViewState = 'list' | 'add' | 'edit' | 'tools' | 'help'; +/** Destructive actions that require an explicit y/n answer. */ +type PendingConfirm = + | { kind: 'delete'; serviceName: string } + | { kind: 'overwrite'; service: ServiceDefinition; existingName: string }; + /** * Main TUI Application Component (Optimized) */ @@ -52,6 +57,7 @@ export const TuiAppOptimized: React.FC = ({ configProvider: propConfigProvider, }) => { const { stdout } = useStdout(); + const { exit } = useApp(); const [state, setState] = useState('loading'); const [view, setView] = useState('list'); const [config, setConfig] = useState(propConfig || null); @@ -67,6 +73,11 @@ export const TuiAppOptimized: React.FC = ({ const [refreshKey, setRefreshKey] = useState(0); const [useUnifiedForm, setUseUnifiedForm] = useState(true); const [lastRefresh, setLastRefresh] = useState(new Date()); + /** + * Pending destructive action. Config writes go straight to disk, so deleting + * or overwriting a service asks first instead of acting on a stray keystroke. + */ + const [pendingConfirm, setPendingConfirm] = useState(null); // In-memory discovery state (never persisted to disk) const [discoveryStatuses, setDiscoveryStatuses] = useState>( @@ -81,6 +92,27 @@ export const TuiAppOptimized: React.FC = ({ const terminalHeight = stdout?.rows || 24; + /** + * Quitting must happen in two steps: `exit()` unmounts (which puts the tty + * back out of raw mode), then process.exit() actually terminates — unmounting + * alone leaves the process alive because the connection pools, health timers + * and stdio children keep the event loop busy. + */ + const exitApp = (): void => { + exit(); + setTimeout(() => process.exit(0), 0); + }; + + /** + * Writes the app itself performs also fire the config watcher. Remember when + * that happens so its "updated from external changes" notice isn't shown for + * a change the user just made here. + */ + const selfWriteUntilRef = useRef(0); + const markSelfWrite = (): void => { + selfWriteUntilRef.current = Date.now() + 1500; + }; + // 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. @@ -88,18 +120,13 @@ export const TuiAppOptimized: React.FC = ({ 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; - let enabled = 0; - services.forEach((s) => { - const count = toolCountCache.get(s.name) ?? 0; - total += count; - const disabled = Object.values(s.toolStates ?? {}).filter((v) => v === false).length; - enabled += Math.max(0, count - disabled); - }); - return { enabled, total }; - }, [services, toolCountCache]); + // The confirmation dialog is rendered inside the same column, so the content + // below it gets the remainder — otherwise the frame grows past the terminal + // and ink's output starts landing on the wrong rows. + const CONFIRM_DIALOG_LINES = pendingConfirm !== null ? 4 : 0; + const panelHeight = Math.max(6, contentHeight - CONFIRM_DIALOG_LINES); + // The service list also shares its column with the "Last refresh" line. + const listHeight = Math.max(6, panelHeight - 2); // Register discovery event listeners useEffect(() => { @@ -155,7 +182,7 @@ export const TuiAppOptimized: React.FC = ({ const registry = new ServiceRegistry(provider); await registry.initialize(); - const serviceList = await registry.list(); + const serviceList = registry.list(); // Set up configuration watch to handle external changes unwatch = provider.watch((newConfig) => { @@ -169,11 +196,13 @@ export const TuiAppOptimized: React.FC = ({ void registry.initialize(); } - setStatusMessage({ - type: 'info', - message: 'Configuration updated from external changes', - duration: 3000, - }); + if (Date.now() >= selfWriteUntilRef.current) { + setStatusMessage({ + type: 'info', + message: 'Configuration updated from external changes', + duration: 3000, + }); + } }); setServiceRegistry(registry); @@ -208,14 +237,15 @@ export const TuiAppOptimized: React.FC = ({ unwatch(); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // Intentionally keyed on the inputs that identify the config source: the + // registry is created inside, so depending on it would re-run forever. }, [configDir, propConfig, propConfigProvider]); // Reload services - const reloadServices = async () => { + const reloadServices = (): ServiceDefinition[] | undefined => { if (serviceRegistry) { try { - const serviceList = await serviceRegistry.list(); + const serviceList = serviceRegistry.list(); setServices(serviceList); setLastRefresh(new Date()); @@ -243,10 +273,28 @@ export const TuiAppOptimized: React.FC = ({ }; // Handle service form submission - const handleServiceSubmit = async (service: ServiceDefinition) => { + const handleServiceSubmit = (service: ServiceDefinition): void => { + if (!serviceRegistry) return; + + // Registering by name replaces whatever was there — including tags, args + // and tool states the form never showed. Ask before discarding them. + const collides = services.some( + (s) => s.name === service.name && s.name !== editingService?.name + ); + if (collides) { + setPendingConfirm({ kind: 'overwrite', service, existingName: service.name }); + return; + } + + void submitService(service); + }; + + /** Persist a service (add / edit / rename) and refresh discovery state. */ + const submitService = async (service: ServiceDefinition): Promise => { if (!serviceRegistry) return; try { + markSelfWrite(); // Check if this is a rename operation (editing service with name change) if (editingService && editingService.name !== service.name) { // Delete the old service first @@ -255,7 +303,7 @@ export const TuiAppOptimized: React.FC = ({ // Register the new/updated service await serviceRegistry.register(service); - const updatedList = await reloadServices(); + const updatedList = reloadServices(); // Handle discovery state for the saved service const isRename = editingService && editingService.name !== service.name; @@ -284,6 +332,7 @@ export const TuiAppOptimized: React.FC = ({ setView('list'); setEditingService(undefined); + setPendingConfirm(null); setStatusMessage({ type: 'success', message: `Service '${service.name}' ${editingService ? 'updated' : 'created'} successfully`, @@ -314,6 +363,7 @@ export const TuiAppOptimized: React.FC = ({ if (!config || !configProvider || !editingService) return; try { + markSelfWrite(); const toolStates = editingService.toolStates || {}; const newToolStates = { ...toolStates, [toolName]: enabled }; @@ -355,6 +405,7 @@ export const TuiAppOptimized: React.FC = ({ if (!config || !configProvider || !editingService) return; try { + markSelfWrite(); const currentToolStates = editingService.toolStates || {}; const newToolStates = { ...currentToolStates, ...toolStates }; @@ -401,6 +452,7 @@ export const TuiAppOptimized: React.FC = ({ if (!config || !configProvider) return; try { + markSelfWrite(); const updatedServices = services.map((s) => (s.name === serviceName ? { ...s, enabled } : s)); const newConfig = { ...config, services: updatedServices }; await configProvider.save(newConfig); @@ -430,6 +482,7 @@ export const TuiAppOptimized: React.FC = ({ if (!config || !configProvider) return; try { + markSelfWrite(); // Unregister from service registry first if (serviceRegistry) { await serviceRegistry.unregister(serviceName); @@ -462,8 +515,35 @@ export const TuiAppOptimized: React.FC = ({ // Handle keyboard input useInput((input, key) => { + // Ctrl+C is the documented exit shortcut and must work in every state — + // including while the config is still loading. ink only unmounts on it, so + // the process would otherwise keep running on its background handles. + if (key.ctrl && input === 'c') { + exitApp(); + return; + } + if (state !== 'ready') return; + // A pending confirmation owns the keyboard until it is answered. Only an + // explicit 'y' destroys anything — a stray Enter must not (the dialog says + // exactly that). + if (pendingConfirm !== null) { + if (input === 'y') { + const pending = pendingConfirm; + setPendingConfirm(null); + if (pending.kind === 'delete') { + void handleDeleteService(pending.serviceName); + } else { + void submitService(pending.service); + } + } else if (input === 'n' || key.escape) { + setPendingConfirm(null); + setStatusMessage({ type: 'info', message: 'Cancelled', duration: 1500 }); + } + return; + } + // Global help shortcut if (input === '?' && view !== 'help') { setView('help'); @@ -480,7 +560,8 @@ export const TuiAppOptimized: React.FC = ({ // Global quit if (input === 'q' && view === 'list') { - process.exit(0); + exitApp(); + return; } // Tools view: ServiceTools handles its own input (search Esc layering, @@ -527,17 +608,14 @@ export const TuiAppOptimized: React.FC = ({ void handleToggleService(service.name, !service.enabled); } } else if (input === 'd') { - if (services[selectedIndex]) { - const service = services[selectedIndex]; - void handleDeleteService(service.name); + const target = services[selectedIndex]; + if (target) { + setPendingConfirm({ kind: 'delete', serviceName: target.name }); } } else if (input === 'r' && !key.ctrl) { // Reload config then re-discover zero-tool services - void (async () => { - const refreshed = await reloadServices(); - const list = refreshed ?? services; - void discoveryManagerRef.current.refreshZeroToolServices(list); - })(); + const refreshed = reloadServices(); + void discoveryManagerRef.current.refreshZeroToolServices(refreshed ?? services); } else if (input === 'y') { setUseUnifiedForm(!useUnifiedForm); setStatusMessage({ @@ -610,6 +688,29 @@ export const TuiAppOptimized: React.FC = ({ setStatusMessage(null)} /> + {pendingConfirm !== null && ( + + + {pendingConfirm.kind === 'delete' + ? `Delete service '${pendingConfirm.serviceName}'?` + : `Service '${pendingConfirm.existingName}' already exists — overwrite it?`} + + + {pendingConfirm.kind === 'delete' + ? 'Removes it from the configuration file.' + : 'Its tags, args, env and tool states will be replaced.'}{' '} + + y + {' '} + confirm ·{' '} + + n + + /Esc cancel + + + )} + {view === 'list' && ( = ({ services={services} selectedIndex={selectedIndex} onSelect={setSelectedIndex} - showDetails={true} - globalToolStats={globalToolStats} - terminalHeight={terminalHeight} + terminalHeight={listHeight} discoveryStatus={discoveryStatuses} toolCounts={toolCountCache} /> @@ -631,6 +730,7 @@ export const TuiAppOptimized: React.FC = ({ service={editingService} onSubmit={handleServiceSubmit} onCancel={handleServiceCancel} + terminalHeight={panelHeight} /> ) : ( = ({ setView('list'); setRefreshKey((k) => k + 1); }} - onToggleTool={handleToggleTool} - onBatchToggleTools={handleBatchToggleTools} + onToggleTool={(name, enabled) => void handleToggleTool(name, enabled)} + onBatchToggleTools={(states) => void handleBatchToggleTools(states)} toolStates={editingService.toolStates || {}} onToolsDiscovered={handleToolsDiscovered} - terminalHeight={contentHeight} + terminalHeight={panelHeight} /> )} - {view === 'list' && ( + {view === 'list' && pendingConfirm === null && ( - - Last refresh: {lastRefresh.toLocaleTimeString()} • Config: {configDir} + + Last refresh: {lastRefresh.toLocaleTimeString()} • Config: {configDir ?? 'unknown'} )} diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 25d1578..71b8084 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -48,12 +48,18 @@ interface StatusMessage { /** * Main TUI Application Component */ -export const TuiApp: React.FC = ({ configDir, config: propConfig, configProvider: propConfigProvider }) => { +export const TuiApp: React.FC = ({ + configDir, + config: propConfig, + configProvider: propConfigProvider, +}) => { const { stdout } = useStdout(); const [state, setState] = useState('loading'); const [view, setView] = useState('list'); const [config, setConfig] = useState(propConfig || null); - const [configProvider, setConfigProvider] = useState(propConfigProvider || null); + const [configProvider, setConfigProvider] = useState( + propConfigProvider || null + ); const [services, setServices] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [error, setError] = useState(null); @@ -72,28 +78,10 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c 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; - let enabledTools = 0; - - services.forEach(service => { - if (service.enabled && service.toolStates) { - const toolCount = Object.keys(service.toolStates).length; - totalTools += toolCount; - const disabledCount = Object.values(service.toolStates) - .filter(v => v === false).length; - enabledTools += Math.max(0, toolCount - disabledCount); - } - }); - - return { enabled: enabledTools, total: totalTools }; - }, [services]); - // Load configuration on mount useEffect(() => { let unwatch: (() => void) | null = null; - + const loadConfig = async () => { try { let loadedConfig = config; @@ -107,36 +95,41 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c configDir: dir, }); loadedConfig = await provider.load(); - + const validation = provider.validate(loadedConfig); if (!validation.valid) { - const errorMessages = validation.errors.map(e => `${e.field}: ${e.message}`).join(', '); + const errorMessages = validation.errors + .map((e) => `${e.field}: ${e.message}`) + .join(', '); throw new Error(`Configuration validation failed: ${errorMessages}`); } - + setConfigProvider(provider); } const registry = new ServiceRegistry(provider); await registry.initialize(); - - const serviceList = await registry.list(); - + + const serviceList = registry.list(); + unwatch = provider.watch((newConfig) => { - const updatedServices = Object.entries(newConfig.mcpServers).map(([name, def]) => ({ ...def, name })); + const updatedServices = Object.entries(newConfig.mcpServers).map(([name, def]) => ({ + ...def, + name, + })); setServices(updatedServices); - + if (serviceRegistry) { serviceRegistry.initialize().catch(console.error); } - + setStatusMessage({ type: 'info', message: 'Configuration updated from external changes', duration: 3000, }); }); - + setServiceRegistry(registry); if (!config) setConfig(loadedConfig); setServices(serviceList); @@ -147,8 +140,8 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c } }; - loadConfig(); - + void loadConfig(); + return () => { if (unwatch) { unwatch(); @@ -157,9 +150,9 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c }, [configDir, propConfig, propConfigProvider]); // Reload services when registry changes - const reloadServices = async () => { + const reloadServices = (): void => { if (serviceRegistry) { - const serviceList = await serviceRegistry.list(); + const serviceList = serviceRegistry.list(); setServices(serviceList); // Reset selection if out of bounds if (selectedIndex >= serviceList.length && serviceList.length > 0) { @@ -178,10 +171,10 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c // Delete the old service first await serviceRegistry.unregister(editingService.name); } - + // Register the new/updated service await serviceRegistry.register(service); - await reloadServices(); + reloadServices(); setView('list'); setEditingService(undefined); setStatusMessage({ @@ -211,9 +204,9 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c try { const toolStates = editingService.toolStates || {}; const newToolStates = { ...toolStates, [toolName]: enabled }; - + const updatedService = { ...editingService, toolStates: newToolStates }; - + if (serviceRegistry) { await serviceRegistry.register(updatedService); } else { @@ -228,12 +221,10 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c setEditingService(updatedService); // Update services list to reflect the change immediately - setServices(prevServices => - prevServices.map(s => - s.name === editingService.name ? updatedService : s - ) + setServices((prevServices) => + prevServices.map((s) => (s.name === editingService.name ? updatedService : s)) ); - + setStatusMessage({ type: 'success', message: `Tool '${toolName}' ${enabled ? 'enabled' : 'disabled'}`, @@ -253,9 +244,9 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c try { const currentToolStates = editingService.toolStates || {}; const newToolStates = { ...currentToolStates, ...toolStates }; - + const updatedService = { ...editingService, toolStates: newToolStates }; - + if (serviceRegistry) { await serviceRegistry.register(updatedService); } else { @@ -267,22 +258,20 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c } setEditingService(updatedService); - setServices(prevServices => - prevServices.map(s => - s.name === editingService.name ? updatedService : s - ) + setServices((prevServices) => + prevServices.map((s) => (s.name === editingService.name ? updatedService : s)) ); - + setStatusMessage({ type: 'success', message: `Updated ${Object.keys(toolStates).length} tool(s)`, - duration: 3000 + duration: 3000, }); } catch (err) { setStatusMessage({ type: 'error', message: `Failed to update tools: ${err instanceof Error ? err.message : String(err)}`, - duration: 5000 + duration: 5000, }); } }; @@ -296,27 +285,28 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c if (!config || !configProvider) return; try { - const updatedServices = services.map(s => - s.name === serviceName ? { ...s, enabled } : s - ); + const updatedServices = services.map((s) => (s.name === serviceName ? { ...s, enabled } : s)); const newConfig = { ...config, services: updatedServices }; await configProvider.save(newConfig); - + if (serviceRegistry) { - await serviceRegistry.register(updatedServices.find(s => s.name === serviceName)!); + const target = updatedServices.find((s) => s.name === serviceName); + if (target !== undefined) { + await serviceRegistry.register(target); + } } - + setServices(updatedServices); setStatusMessage({ type: 'success', message: `Service '${serviceName}' ${enabled ? 'enabled' : 'disabled'}`, - duration: 3000 + duration: 3000, }); } catch (err) { setStatusMessage({ type: 'error', message: `Failed to toggle service: ${err instanceof Error ? err.message : String(err)}`, - duration: 5000 + duration: 5000, }); } }; @@ -330,13 +320,13 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c await serviceRegistry.unregister(serviceName); } else { // Fallback: update config directly - const updatedServices = services.filter(s => s.name !== serviceName); + const updatedServices = services.filter((s) => s.name !== serviceName); const newConfig = { ...config, services: updatedServices }; await configProvider.save(newConfig); } - + // Update local state - const updatedServices = services.filter(s => s.name !== serviceName); + const updatedServices = services.filter((s) => s.name !== serviceName); setServices(updatedServices); if (selectedIndex >= updatedServices.length && updatedServices.length > 0) { setSelectedIndex(updatedServices.length - 1); @@ -344,13 +334,13 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c setStatusMessage({ type: 'success', message: `Service '${serviceName}' deleted`, - duration: 3000 + duration: 3000, }); } catch (err) { setStatusMessage({ type: 'error', message: `Failed to delete service: ${err instanceof Error ? err.message : String(err)}`, - duration: 5000 + duration: 5000, }); } }; @@ -363,12 +353,12 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c if (input === 'q' && view === 'list') { process.exit(0); } - + if (input === '?') { setStatusMessage({ type: 'info', message: 'Help: ?=help, q=quit, ↑↓=navigate, Enter=edit, Space=toggle, T=tools, D=delete', - duration: 5000 + duration: 5000, }); return; } @@ -381,23 +371,18 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c // List view navigation if (view === 'list') { if (key.upArrow) { - setSelectedIndex(prev => Math.max(0, prev - 1)); + setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setSelectedIndex(prev => Math.min(services.length - 1, prev + 1)); + setSelectedIndex((prev) => Math.min(services.length - 1, prev + 1)); } else if (input === ' ') { if (services[selectedIndex]) { const service = services[selectedIndex]; - handleToggleService(service.name, !service.enabled); - } - } else if (input === 't' || input === 'T') { - if (services[selectedIndex]) { - setEditingService(services[selectedIndex]); - setView('tools'); + void handleToggleService(service.name, !service.enabled); } } else if (input === 'd' || input === 'D') { if (services[selectedIndex]) { const service = services[selectedIndex]; - handleDeleteService(service.name); + void handleDeleteService(service.name); } } else if (key.return) { if (services[selectedIndex]) { @@ -420,44 +405,39 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c setEditingService(services[selectedIndex]); setView('tools'); } - } else if (input === ' ' || input === 't') { - if (services[selectedIndex]) { - const service = services[selectedIndex]; - handleToggleService(service.name, !service.enabled); - } - } else if (input === 'd') { + } else if (input === ' ' || input === 't' || input === 'T') { if (services[selectedIndex]) { const service = services[selectedIndex]; - handleDeleteService(service.name); + void handleToggleService(service.name, !service.enabled); } } else if (input === 'y') { // Toggle form mode setUseUnifiedForm(!useUnifiedForm); } } - + if (view === 'add' || view === 'edit') { // Allow help shortcut from forms if (input === '?') { setStatusMessage({ type: 'info', message: 'Help: ?=help, q=quit, ↑↓=navigate, Enter=edit, Space=toggle, T=tools, D=delete', - duration: 5000 + duration: 5000, }); return; } - + // Allow quit shortcut from forms if (input === 'q') { process.exit(0); } - + // Allow refresh shortcut from forms if (input === 'r') { - reloadServices(); + void reloadServices(); return; } - + // Forms handle other input return; } @@ -477,7 +457,9 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c if (state === 'error') { return ( - Error loading configuration + + Error loading configuration + {error} Configuration directory: {configDir} @@ -493,25 +475,48 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c return ( - MCP Router System - Configuration Manager + + MCP Router System - Configuration Manager + - + - Configuration directory: {configDir} - Services: {services.length} - Mode: {config?.mode || 'unknown'} + + Configuration directory: {configDir} + + + Services: {services.length} + + + Mode: {config?.mode || 'unknown'} + {/* Status message */} {statusMessage && ( - - {statusMessage.type === 'error' ? '✗' : statusMessage.type === 'success' ? '✓' : 'ℹ'} {statusMessage.message} + + {statusMessage.type === 'error' ? '✗' : statusMessage.type === 'success' ? '✓' : 'ℹ'}{' '} + {statusMessage.message} )} @@ -523,36 +528,35 @@ export const TuiApp: React.FC = ({ configDir, config: propConfig, c services={services} selectedIndex={selectedIndex} onSelect={setSelectedIndex} - globalToolStats={globalToolStats} - terminalHeight={terminalHeight} + terminalHeight={contentHeight} /> )} - {(view === 'add' || view === 'edit') && ( - useUnifiedForm ? ( + {(view === 'add' || view === 'edit') && + (useUnifiedForm ? ( void handleServiceSubmit(svc)} onCancel={handleServiceCancel} + terminalHeight={contentHeight} /> ) : ( void handleServiceSubmit(svc)} onCancel={handleServiceCancel} /> - ) - )} + ))} {view === 'tools' && editingService && ( { setView('list'); - setRefreshKey(k => k + 1); + setRefreshKey((k) => k + 1); }} - onToggleTool={handleToggleTool} - onBatchToggleTools={handleBatchToggleTools} + onToggleTool={(name, enabled) => void handleToggleTool(name, enabled)} + onBatchToggleTools={(states) => void handleBatchToggleTools(states)} toolStates={editingService.toolStates || {}} onToolsDiscovered={handleToolsDiscovered} terminalHeight={contentHeight} diff --git a/src/tui/components/FileImportDialog.tsx b/src/tui/components/FileImportDialog.tsx deleted file mode 100644 index 28daea0..0000000 --- a/src/tui/components/FileImportDialog.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** - * File Import Dialog Component - * - * Dialog for importing JSON configuration from a file. - */ - -import React, { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; -import TextInput from 'ink-text-input'; -import fs from 'fs/promises'; -import path from 'path'; - -export interface FileImportDialogProps { - /** Callback when file is imported */ - onImport: (content: string) => void; - /** Callback when dialog is cancelled */ - onCancel: () => void; -} - -/** - * File Import Dialog Component - */ -export const FileImportDialog: React.FC = ({ - onImport, - onCancel, -}) => { - const [filePath, setFilePath] = useState(''); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - // Handle file import - const handleImport = async () => { - if (!filePath.trim()) { - setError('File path is required'); - return; - } - - setLoading(true); - setError(null); - - try { - // Resolve path (support ~ for home directory) - let resolvedPath = filePath.trim(); - if (resolvedPath.startsWith('~')) { - const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; - resolvedPath = path.join(homeDir, resolvedPath.slice(1)); - } - - // Read file - const content = await fs.readFile(resolvedPath, 'utf-8'); - - // Validate it's valid JSON - try { - JSON.parse(content); - } catch { - setError('File does not contain valid JSON'); - setLoading(false); - return; - } - - onImport(content); - } catch (err) { - const error = err as Error; - setError(`Failed to read file: ${error.message}`); - setLoading(false); - } - }; - - // Handle keyboard input - useInput((_input, key) => { - if (key.escape) { - onCancel(); - return; - } - }); - - return ( - - - Import from File - - - - File Path: - Enter the path to a JSON configuration file - Supports: ~/path/to/file.json or /absolute/path/file.json - - - - - - {error && ( - - ✗ {error} - - )} - - {loading && ( - - Loading file... - - )} - - - Enter: Import | Esc: Cancel - - - ); -}; diff --git a/src/tui/components/Footer.tsx b/src/tui/components/Footer.tsx deleted file mode 100644 index b4518bb..0000000 --- a/src/tui/components/Footer.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/** - * TUI Footer Component - * - * Displays keyboard shortcuts and help information - */ - -import React from 'react'; -import { Box, Text } from 'ink'; - -export interface Shortcut { - key: string; - description: string; - group?: string; -} - -export interface FooterProps { - shortcuts?: Shortcut[]; - compact?: boolean; -} - -export const Footer: React.FC = ({ shortcuts = [], compact = false }) => { - if (shortcuts.length === 0) { - return null; - } - - // Group shortcuts if not compact - if (!compact && shortcuts.some(s => s.group)) { - const groups = shortcuts.reduce((acc, shortcut) => { - const group = shortcut.group || 'Other'; - if (!acc[group]) acc[group] = []; - acc[group]!.push(shortcut); - return acc; - }, {} as Record); - - return ( - - {Object.entries(groups).map(([group, items]) => ( - - {group}: - - {items.map((shortcut, index) => ( - - {shortcut.key} - : {shortcut.description} - - ))} - - - ))} - - ); - } - - // Compact mode - single line - return ( - - - {shortcuts.map((shortcut, index) => ( - - {index > 0 && | } - {shortcut.key} - : {shortcut.description} - - ))} - - - ); -}; diff --git a/src/tui/components/Header.tsx b/src/tui/components/Header.tsx index 395969d..7394d78 100644 --- a/src/tui/components/Header.tsx +++ b/src/tui/components/Header.tsx @@ -1,6 +1,6 @@ /** * TUI Header Component - * + * * Displays application title, status, and key information */ @@ -31,7 +31,9 @@ export const Header: React.FC = ({ justifyContent="space-between" > - {title} + + {title} + {subtitle && ( <> @@ -39,9 +41,7 @@ export const Header: React.FC = ({ )} - {showHelp && ( - Press ? for help - )} + {showHelp && Press ? for help} {/* Stats bar */} diff --git a/src/tui/components/HelpDialog.tsx b/src/tui/components/HelpDialog.tsx index ac58fdc..e084d59 100644 --- a/src/tui/components/HelpDialog.tsx +++ b/src/tui/components/HelpDialog.tsx @@ -44,7 +44,7 @@ export const HelpDialog: React.FC = ({ onClose }) => { e - Edit selected service - d - Delete selected service + d - Delete selected service (asks to confirm) Space/t - Toggle service enabled/disabled @@ -75,6 +75,9 @@ export const HelpDialog: React.FC = ({ onClose }) => { Tab - Next field + + ↑/↓ - Previous / next field + Shift+Tab - Previous field diff --git a/src/tui/components/JsonTextArea.tsx b/src/tui/components/JsonTextArea.tsx index 309fc2e..f473c8a 100644 --- a/src/tui/components/JsonTextArea.tsx +++ b/src/tui/components/JsonTextArea.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useState } from 'react'; import { Box, Text, useInput } from 'ink'; +import { isEditableChunk } from '../input-text.js'; export interface JsonTextAreaProps { value: string; @@ -91,10 +92,15 @@ export const JsonTextArea: React.FC = ({ value, onChange, hei return; } - // Printable character - if (input && input.length === 1 && input >= ' ' && input !== '\n') { - onChange(value.slice(0, cursor) + input + value.slice(cursor)); - setCursor(cursor + input.length); + // Printable text: one keystroke, or a pasted chunk — which may legitimately + // span several lines, so newlines are kept (only other control bytes are + // dropped). Filtering on `length === 1` would silently discard a paste. + if (input.length > 0) { + const chunk = input.replace(/\r\n?/g, '\n'); + if (isEditableChunk(chunk)) { + onChange(value.slice(0, cursor) + chunk + value.slice(cursor)); + setCursor(cursor + chunk.length); + } } }); diff --git a/src/tui/components/ServiceForm.tsx b/src/tui/components/ServiceForm.tsx index b5427c3..1263947 100644 --- a/src/tui/components/ServiceForm.tsx +++ b/src/tui/components/ServiceForm.tsx @@ -1,6 +1,6 @@ /** * Service Form Component - * + * * Interactive form for adding and editing services. * Provides step-by-step configuration with validation and helpful error messages. * Shows/hides fields based on transport type selection. @@ -96,15 +96,36 @@ function getFieldOrder(transport: TransportType, quickMode: boolean): FormField[ if (transport === 'stdio') { return [ - 'name', 'transport', 'command', 'args', 'env', 'tags', - 'enabled', 'maxConnections', 'idleTimeout', 'connectionTimeout', - 'triggerHintsStart', 'triggerHintsEnd', 'triggerHintsPhrases', 'confirm', + 'name', + 'transport', + 'command', + 'args', + 'env', + 'tags', + 'enabled', + 'maxConnections', + 'idleTimeout', + 'connectionTimeout', + 'triggerHintsStart', + 'triggerHintsEnd', + 'triggerHintsPhrases', + 'confirm', ]; } else { return [ - 'name', 'transport', 'url', 'headers', 'tags', - 'enabled', 'maxConnections', 'idleTimeout', 'connectionTimeout', - 'triggerHintsStart', 'triggerHintsEnd', 'triggerHintsPhrases', 'confirm', + 'name', + 'transport', + 'url', + 'headers', + 'tags', + 'enabled', + 'maxConnections', + 'idleTimeout', + 'connectionTimeout', + 'triggerHintsStart', + 'triggerHintsEnd', + 'triggerHintsPhrases', + 'confirm', ]; } } @@ -152,7 +173,10 @@ function validateFormData(data: FormData): ValidationError[] { if (!data.name.trim()) { errors.push({ field: 'name', message: 'Service name is required' }); } else if (!/^[a-zA-Z0-9_-]+$/.test(data.name)) { - errors.push({ field: 'name', message: 'Service name can only contain letters, numbers, hyphens, and underscores' }); + errors.push({ + field: 'name', + message: 'Service name can only contain letters, numbers, hyphens, and underscores', + }); } // Validate transport-specific fields @@ -181,7 +205,10 @@ function validateFormData(data: FormData): ValidationError[] { const connTimeout = parseInt(data.connectionTimeout, 10); if (isNaN(connTimeout) || connTimeout < 1000) { - errors.push({ field: 'connectionTimeout', message: 'Connection timeout must be at least 1000ms' }); + errors.push({ + field: 'connectionTimeout', + message: 'Connection timeout must be at least 1000ms', + }); } return errors; @@ -195,7 +222,10 @@ export function formDataToService(data: FormData): ServiceDefinition { name: data.name.trim(), transport: data.transport, enabled: data.enabled, - tags: data.tags.split(',').map(t => t.trim()).filter(t => t.length > 0), + tags: data.tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0), connectionPool: { maxConnections: parseInt(data.maxConnections, 10), idleTimeout: parseInt(data.idleTimeout, 10), @@ -207,12 +237,18 @@ export function formDataToService(data: FormData): ServiceDefinition { service.command = data.command.trim(); if (data.args.trim()) { - service.args = data.args.split(',').map(a => a.trim()).filter(a => a.length > 0); + service.args = data.args + .split(',') + .map((a) => a.trim()) + .filter((a) => a.length > 0); } if (data.env.trim()) { service.env = {}; - const envPairs = data.env.split(',').map(e => e.trim()).filter(e => e.length > 0); + const envPairs = data.env + .split(',') + .map((e) => e.trim()) + .filter((e) => e.length > 0); for (const pair of envPairs) { const [key, ...valueParts] = pair.split('='); if (key && valueParts.length > 0) { @@ -225,7 +261,10 @@ export function formDataToService(data: FormData): ServiceDefinition { if (data.headers.trim()) { service.headers = {}; - const headerPairs = data.headers.split(',').map(h => h.trim()).filter(h => h.length > 0); + const headerPairs = data.headers + .split(',') + .map((h) => h.trim()) + .filter((h) => h.length > 0); for (const pair of headerPairs) { const [key, ...valueParts] = pair.split(':'); if (key && valueParts.length > 0) { @@ -237,8 +276,8 @@ export function formDataToService(data: FormData): ServiceDefinition { const phrases = data.triggerHintsPhrases .split(',') - .map(p => p.trim()) - .filter(p => p.length > 0); + .map((p) => p.trim()) + .filter((p) => p.length > 0); const hints: NonNullable = {}; if (data.triggerHintsStart.trim()) hints.onSessionStart = data.triggerHintsStart.trim(); if (data.triggerHintsEnd.trim()) hints.onSessionEnd = data.triggerHintsEnd.trim(); @@ -253,14 +292,10 @@ export function formDataToService(data: FormData): ServiceDefinition { /** * Service Form Component */ -export const ServiceForm: React.FC = ({ - service, - onSubmit, - onCancel, -}) => { +export const ServiceForm: React.FC = ({ service, onSubmit, onCancel }) => { const { stdout } = useStdout(); const terminalHeight = stdout?.rows || 24; - + // Initialize form data from existing service or defaults const [formData, setFormData] = useState(() => { if (service) { @@ -270,8 +305,16 @@ export const ServiceForm: React.FC = ({ command: service.command || '', url: service.url || '', args: service.args?.join(', ') || '', - env: service.env ? Object.entries(service.env).map(([k, v]) => `${k}=${v}`).join(', ') : '', - headers: service.headers ? Object.entries(service.headers).map(([k, v]) => `${k}: ${v}`).join(', ') : '', + env: service.env + ? Object.entries(service.env) + .map(([k, v]) => `${k}=${v}`) + .join(', ') + : '', + headers: service.headers + ? Object.entries(service.headers) + .map(([k, v]) => `${k}: ${v}`) + .join(', ') + : '', tags: service.tags.join(', '), enabled: service.enabled, maxConnections: service.connectionPool.maxConnections.toString(), @@ -381,7 +424,7 @@ export const ServiceForm: React.FC = ({ return ( i.value === formData.transport)} + initialIndex={items.findIndex((i) => i.value === formData.transport)} onSelect={(item) => { const newTransport = item.value as TransportType; setFormData({ ...formData, transport: newTransport }); @@ -484,7 +527,7 @@ export const ServiceForm: React.FC = ({ }; // Get current field error - const currentError = errors.find(e => e.field === currentField); + const currentError = errors.find((e) => e.field === currentField); // Render preview if (showPreview) { @@ -492,40 +535,68 @@ export const ServiceForm: React.FC = ({ return ( - Configuration Preview + + Configuration Preview + - Name: {previewService.name} - Transport: {previewService.transport} - Enabled: {previewService.enabled ? 'Yes' : 'No'} - + + Name: {previewService.name} + + + Transport: {previewService.transport} + + + Enabled: {previewService.enabled ? 'Yes' : 'No'} + + {previewService.command && ( - Command: {previewService.command} + + Command: {previewService.command} + )} - + {previewService.args && previewService.args.length > 0 && ( - Args: {previewService.args.join(', ')} + + Args: {previewService.args.join(', ')} + )} - + {previewService.url && ( - URL: {previewService.url} + + URL: {previewService.url} + )} {previewService.headers && Object.keys(previewService.headers).length > 0 && ( - Headers: {Object.entries(previewService.headers).map(([k, v]) => `${k}: ${v}`).join(', ')} + + Headers:{' '} + {Object.entries(previewService.headers) + .map(([k, v]) => `${k}: ${v}`) + .join(', ')} + )} {previewService.env && Object.keys(previewService.env).length > 0 && ( - Environment: {Object.entries(previewService.env).map(([k, v]) => `${k}=${v}`).join(', ')} + + Environment:{' '} + {Object.entries(previewService.env) + .map(([k, v]) => `${k}=${v}`) + .join(', ')} + )} - - Tags: {previewService.tags.join(', ') || 'none'} - - Connection Pool: - Max Connections: {previewService.connectionPool.maxConnections} - Idle Timeout: {previewService.connectionPool.idleTimeout}ms - Connection Timeout: {previewService.connectionPool.connectionTimeout}ms + + + Tags: {previewService.tags.join(', ') || 'none'} + + + + Connection Pool: + + Max Connections: {previewService.connectionPool.maxConnections} + Idle Timeout: {previewService.connectionPool.idleTimeout}ms + Connection Timeout: {previewService.connectionPool.connectionTimeout}ms @@ -548,7 +619,12 @@ export const ServiceForm: React.FC = ({ return ( - + {service ? 'Edit Service' : 'Add New Service'} @@ -564,25 +640,43 @@ export const ServiceForm: React.FC = ({ )} {/* Current field */} - - {getFieldLabel(currentField)} + + + {getFieldLabel(currentField)} + {getFieldHelp(currentField)} - - {renderField()} - + {renderField()} {/* Validation errors */} {currentError && ( - + ✗ {currentError.message} )} {/* All validation errors - limited in compact mode */} - {(showAllErrors && errors.length > 0) && ( - - Validation Errors: + {showAllErrors && errors.length > 0 && ( + + + Validation Errors: + {errors.map((error, index) => ( • {getFieldLabel(error.field)}: {error.message} @@ -594,7 +688,7 @@ export const ServiceForm: React.FC = ({ {/* Navigation help */} - {currentField === 'confirm' + {currentField === 'confirm' ? '↑/↓: Select | Enter: Confirm | p: Preview | Esc: Cancel' : 'Enter: Next field | Esc: Cancel'} diff --git a/src/tui/components/ServiceFormUnified.tsx b/src/tui/components/ServiceFormUnified.tsx index 027e4c2..6097f6c 100644 --- a/src/tui/components/ServiceFormUnified.tsx +++ b/src/tui/components/ServiceFormUnified.tsx @@ -1,16 +1,16 @@ /** * Unified Service Form Component - * + * * Single-page progressive form for adding and editing services. * Shows all fields on one page with progressive disclosure for optional fields. * Provides inline validation and real-time preview. * Handles terminal height constraints for small terminals. */ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Box, Text, useInput, useStdout } from 'ink'; -import TextInput from 'ink-text-input'; import SelectInput from 'ink-select-input'; +import { SingleLineInput } from './SingleLineInput.js'; import type { ServiceDefinition, TransportType } from '../../types/service.js'; import { fieldHelp, fieldPlaceholder } from './service-field-config.js'; @@ -21,6 +21,11 @@ export interface ServiceFormUnifiedProps { onSubmit: (service: ServiceDefinition) => void; /** Callback when form is cancelled */ onCancel: () => void; + /** + * Vertical space the host actually leaves for this form (terminal height minus + * the host's own header/footer). Falls back to the raw terminal height. + */ + terminalHeight?: number | undefined; } /** @@ -52,7 +57,7 @@ interface FieldConfig { help: string; required: boolean; type: 'text' | 'select'; - dependsOn?: { field: FormField; value: any }; + dependsOn?: { field: FormField; value: string }; } /** @@ -214,50 +219,60 @@ function getFieldConfigs(transport: TransportType): FieldConfig[] { /** * Validate single field */ -function validateField(field: FormField, value: any, transport: TransportType): string | null { +function validateField( + field: FormField, + value: string | boolean, + transport: TransportType +): string | null { + // Only text fields carry text; the Enabled select passes a boolean. + const text = typeof value === 'string' ? value : ''; + const trimmed = text.trim(); + switch (field) { case 'name': - if (!value.trim()) { + if (!trimmed) { return 'Service name is required'; } - if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + if (!/^[a-zA-Z0-9_-]+$/.test(text)) { return 'Only letters, numbers, hyphens, and underscores allowed'; } return null; case 'command': - if (transport === 'stdio' && !value.trim()) { + if (transport === 'stdio' && !trimmed) { return 'Command is required for stdio transport'; } return null; case 'url': - if (transport !== 'stdio' && !value.trim()) { + if (transport !== 'stdio' && !trimmed) { return 'URL is required for HTTP/SSE transport'; } - if (value.trim() && !/^https?:\/\/.+/.test(value)) { + if (trimmed && !/^https?:\/\/.+/.test(text)) { return 'URL must start with http:// or https://'; } return null; - case 'maxConnections': - if (value.trim()) { - const num = parseInt(value, 10); + case 'maxConnections': { + if (trimmed) { + const num = parseInt(text, 10); if (isNaN(num) || num < 1 || num > 100) { return 'Must be between 1 and 100'; } } return null; + } case 'idleTimeout': - case 'connectionTimeout': - if (value.trim()) { - const num = parseInt(value, 10); + case 'connectionTimeout': { + if (trimmed) { + const num = parseInt(text, 10); if (isNaN(num) || num < 1000) { return 'Must be at least 1000ms'; } } return null; + } default: return null; @@ -272,7 +287,10 @@ export function formDataToService(data: FormData): ServiceDefinition { name: data.name.trim(), transport: data.transport, enabled: data.enabled, - tags: data.tags.split(',').map(t => t.trim()).filter(t => t.length > 0), + tags: data.tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0), connectionPool: { maxConnections: parseInt(data.maxConnections || '5', 10), idleTimeout: parseInt(data.idleTimeout || '60000', 10), @@ -282,14 +300,20 @@ export function formDataToService(data: FormData): ServiceDefinition { if (data.transport === 'stdio') { service.command = data.command.trim(); - + if (data.args.trim()) { - service.args = data.args.split(',').map(a => a.trim()).filter(a => a.length > 0); + service.args = data.args + .split(',') + .map((a) => a.trim()) + .filter((a) => a.length > 0); } - + if (data.env.trim()) { service.env = {}; - const envPairs = data.env.split(',').map(e => e.trim()).filter(e => e.length > 0); + const envPairs = data.env + .split(',') + .map((e) => e.trim()) + .filter((e) => e.length > 0); for (const pair of envPairs) { const [key, ...valueParts] = pair.split('='); if (key && valueParts.length > 0) { @@ -299,10 +323,13 @@ export function formDataToService(data: FormData): ServiceDefinition { } } else { service.url = data.url.trim(); - + if (data.headers.trim()) { service.headers = {}; - const headerPairs = data.headers.split(',').map(h => h.trim()).filter(h => h.length > 0); + const headerPairs = data.headers + .split(',') + .map((h) => h.trim()) + .filter((h) => h.length > 0); for (const pair of headerPairs) { const [key, ...valueParts] = pair.split(':'); if (key && valueParts.length > 0) { @@ -314,8 +341,8 @@ export function formDataToService(data: FormData): ServiceDefinition { const phrases = data.triggerHintsPhrases .split(',') - .map(p => p.trim()) - .filter(p => p.length > 0); + .map((p) => p.trim()) + .filter((p) => p.length > 0); const hints: NonNullable = {}; if (data.triggerHintsStart.trim()) hints.onSessionStart = data.triggerHintsStart.trim(); if (data.triggerHintsEnd.trim()) hints.onSessionEnd = data.triggerHintsEnd.trim(); @@ -327,6 +354,19 @@ export function formDataToService(data: FormData): ServiceDefinition { return service; } +/** + * Collapsed-field value. Booleans read Yes/No (the raw `true` leaked the + * internal representation), and empty values read as a grey placeholder. + */ +const formatFieldValue = (field: FormField, data: FormData): React.ReactNode => { + const value = data[field as keyof FormData]; + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + const text = value?.toString() ?? ''; + return text.length > 0 ? text : (empty); +}; + /** * Unified Service Form Component */ @@ -334,10 +374,11 @@ export const ServiceFormUnified: React.FC = ({ service, onSubmit, onCancel, + terminalHeight: terminalHeightProp, }) => { const { stdout } = useStdout(); - const terminalHeight = stdout?.rows || 24; - + const terminalHeight = terminalHeightProp ?? (stdout?.rows || 24); + // Initialize form data const [formData, setFormData] = useState(() => { if (service) { @@ -347,8 +388,19 @@ export const ServiceFormUnified: React.FC = ({ command: service.command || '', url: service.url || '', args: service.args?.join(', ') || '', - env: service.env ? Object.entries(service.env).map(([k, v]) => `${k}=${v}`).join(', ') : '', - headers: service.transport === 'stdio' ? '' : (service.headers ? Object.entries(service.headers).map(([k, v]) => `${k}: ${v}`).join(', ') : ''), + env: service.env + ? Object.entries(service.env) + .map(([k, v]) => `${k}=${v}`) + .join(', ') + : '', + headers: + service.transport === 'stdio' + ? '' + : service.headers + ? Object.entries(service.headers) + .map(([k, v]) => `${k}: ${v}`) + .join(', ') + : '', tags: service.tags.join(', '), enabled: service.enabled, maxConnections: service.connectionPool.maxConnections.toString(), @@ -383,12 +435,11 @@ export const ServiceFormUnified: React.FC = ({ const [showAdvanced, setShowAdvanced] = useState(false); const [fieldErrors, setFieldErrors] = useState>(new Map()); const [touched, setTouched] = useState>(new Set()); - const [scrollOffset, setScrollOffset] = useState(0); - const isCtrlAActive = useRef(false); + const [submitError, setSubmitError] = useState(null); // Get field configurations based on transport type const fieldConfigs = getFieldConfigs(formData.transport); - const requiredFields = fieldConfigs.filter(c => c.required).map(c => c.field); + const requiredFields = fieldConfigs.filter((c) => c.required).map((c) => c.field); const connectionPoolFields: FormField[] = ['maxConnections', 'idleTimeout', 'connectionTimeout']; const advancedOnlyFields: FormField[] = [ ...connectionPoolFields, @@ -397,40 +448,80 @@ export const ServiceFormUnified: React.FC = ({ 'triggerHintsPhrases', ]; - // Calculate visible fields based on terminal height const HEADER_LINES = 4; const FOOTER_LINES = 3; const AVAILABLE_LINES = Math.max(1, terminalHeight - HEADER_LINES - FOOTER_LINES); - + // Default to compact mode - current field expanded, others collapsed const isCompactMode = true; - const COLLAPSED_FIELD_LINES = 1; - const visibleFieldCount = Math.min( - fieldConfigs.length, - Math.max(3, Math.floor(AVAILABLE_LINES / COLLAPSED_FIELD_LINES)) - ); - // Get current field config for help text - const currentFieldConfig = fieldConfigs.find(c => c.field === currentField); - const currentFieldHelp = currentFieldConfig?.help || ''; + /** + * Lines a field actually occupies. Counting rows instead of assuming one line + * per field is what keeps the form inside the viewport: a select renders one + * row per option, so the old `SERVICE_ITEM_LINES`-style math under-counted and + * pushed the whole frame past the terminal, which corrupts the screen. + */ + const fieldHeight = (config: FieldConfig): number => { + if (config.type !== 'select') { + return 2; // label + value/editor row + } + const optionRows = config.field === 'transport' ? 3 : 2; // stdio/sse/http | Yes/No + return optionRows + 1; // label + options + }; - // Ensure scroll shows current field - useEffect(() => { - const currentIdx = fieldConfigs.findIndex(c => c.field === currentField); - if (currentIdx < scrollOffset) { - setScrollOffset(currentIdx); - } else if (currentIdx >= scrollOffset + visibleFieldCount) { - setScrollOffset(Math.max(0, currentIdx - visibleFieldCount + 1)); + // Chrome the form draws itself: title box(3) + fields box borders(2) + + // field-help box(3) + navigation box(3) + advanced hint + scroll indicators(2). + const FORM_CHROME_LINES = 3 + 2 + 3 + 3 + (showAdvanced ? 1 : 2) + 2; + const FIELD_BUDGET = Math.max(3, AVAILABLE_LINES - FORM_CHROME_LINES); + + /** + * Fields to render: the window always contains the current field and expands + * outward while the line budget allows, so the focused field can never be + * scrolled off-screen. + */ + const { visibleConfigs, hasMoreAbove, hasMoreBelow } = useMemo(() => { + const heights = fieldConfigs.map(fieldHeight); + const currentIdx = Math.max( + 0, + fieldConfigs.findIndex((c) => c.field === currentField) + ); + let used = heights[currentIdx] ?? 1; + let start = currentIdx; + let end = currentIdx + 1; + for (;;) { + let grew = false; + if (end < fieldConfigs.length && used + (heights[end] ?? 1) <= FIELD_BUDGET) { + used += heights[end] ?? 1; + end += 1; + grew = true; + } + if (start > 0 && used + (heights[start - 1] ?? 1) <= FIELD_BUDGET) { + used += heights[start - 1] ?? 1; + start -= 1; + grew = true; + } + if (!grew) { + break; + } } - }, [currentField, fieldConfigs]); + return { + visibleConfigs: fieldConfigs.slice(start, end), + hasMoreAbove: start > 0, + hasMoreBelow: end < fieldConfigs.length, + }; + }, [fieldConfigs, currentField, FIELD_BUDGET, showAdvanced]); + + // Get current field config for help text + const currentFieldConfig = fieldConfigs.find((c) => c.field === currentField); + const currentFieldHelp = currentFieldConfig?.help || ''; // Validate current field when it changes useEffect(() => { if (touched.has(currentField)) { const value = formData[currentField as keyof FormData]; const error = validateField(currentField, value, formData.transport); - - setFieldErrors(prev => { + + setFieldErrors((prev) => { const next = new Map(prev); if (error) { next.set(currentField, error); @@ -445,7 +536,7 @@ export const ServiceFormUnified: React.FC = ({ // Check if form is valid const isFormValid = (): boolean => { const errors: ValidationError[] = []; - + for (const config of fieldConfigs) { if (config.required) { const value = formData[config.field as keyof FormData]; @@ -461,18 +552,18 @@ export const ServiceFormUnified: React.FC = ({ const isFieldVisible = (config: FieldConfig): boolean => { const isAdvanced = advancedOnlyFields.includes(config.field); - return (config.required || !isAdvanced) || (showAdvanced && isAdvanced); + return config.required || !isAdvanced || (showAdvanced && isAdvanced); }; // Handle field navigation const goToNextField = (overrideTransport?: TransportType) => { const transportForFields = overrideTransport ?? formData.transport; const configsForTransport = getFieldConfigs(transportForFields); - const currentIndex = configsForTransport.findIndex(c => c.field === currentField); + const currentIndex = configsForTransport.findIndex((c) => c.field === currentField); if (currentIndex < configsForTransport.length - 1) { // Mark current field as touched - setTouched(prev => new Set(prev).add(currentField)); - + setTouched((prev) => new Set(prev).add(currentField)); + // Find next visible field for (let i = currentIndex + 1; i < configsForTransport.length; i++) { const nextConfig = configsForTransport[i]; @@ -485,7 +576,7 @@ export const ServiceFormUnified: React.FC = ({ }; const goToPrevField = () => { - const currentIndex = fieldConfigs.findIndex(c => c.field === currentField); + const currentIndex = fieldConfigs.findIndex((c) => c.field === currentField); if (currentIndex > 0) { // Find previous visible field for (let i = currentIndex - 1; i >= 0; i--) { @@ -502,22 +593,26 @@ export const ServiceFormUnified: React.FC = ({ const handleSubmit = () => { // Mark all required fields as touched const allTouched = new Set(touched); - requiredFields.forEach(f => allTouched.add(f)); + requiredFields.forEach((f) => allTouched.add(f)); setTouched(allTouched); if (!isFormValid()) { - // Jump to first error field + // Focus the first offending field AND say so — a silent no-op looks like + // a broken save button when the field is already focused. for (const config of fieldConfigs) { const value = formData[config.field as keyof FormData]; const error = validateField(config.field, value, formData.transport); if (error) { setCurrentField(config.field); + setSubmitError(`${config.label}: ${error}`); return; } } + setSubmitError('Some fields are invalid.'); return; } + setSubmitError(null); const serviceDefinition = formDataToService(formData); onSubmit(serviceDefinition); }; @@ -529,27 +624,25 @@ export const ServiceFormUnified: React.FC = ({ return; } - // Scroll up/down when form is in scroll mode + // Field navigation — the render window follows the focused field, so + // arrows move between fields instead of scrolling a fixed viewport. if (key.upArrow) { - setScrollOffset(prev => Math.max(0, prev - 1)); + goToPrevField(); return; } if (key.downArrow) { - setScrollOffset(prev => Math.min(fieldConfigs.length - visibleFieldCount, prev + 1)); + goToNextField(); return; } // Toggle advanced options (Ctrl+A) - for optional fields like tags, env, args if (input === 'a' && key.ctrl) { - isCtrlAActive.current = true; setShowAdvanced(!showAdvanced); - setTimeout(() => { - isCtrlAActive.current = false; - }, 2); return; } - // Submit form (Ctrl+S) + // Submit form (Ctrl+S). The chord itself never reaches the text editor + // (SingleLineInput ignores ctrl chords), so no stray 's' is inserted. if (input === 's' && key.ctrl) { handleSubmit(); return; @@ -565,6 +658,13 @@ export const ServiceFormUnified: React.FC = ({ return; } + // Confirm a field and move on (Enter). Selects handle Enter themselves. + if (key.return && currentField !== 'transport' && currentField !== 'enabled') { + setTouched((prev) => new Set(prev).add(currentField)); + goToNextField(); + return; + } + // Handle select fields if (currentField === 'transport' || currentField === 'enabled') { if (key.return) { @@ -584,11 +684,11 @@ export const ServiceFormUnified: React.FC = ({ return ( i.value === formData.transport)} + initialIndex={items.findIndex((i) => i.value === formData.transport)} onSelect={(item) => { const newTransport = item.value as TransportType; setFormData({ ...formData, transport: newTransport }); - setTouched(prev => new Set(prev).add('transport')); + setTouched((prev) => new Set(prev).add('transport')); goToNextField(newTransport); }} /> @@ -608,28 +708,22 @@ export const ServiceFormUnified: React.FC = ({ initialIndex={formData.enabled ? 0 : 1} onSelect={(item) => { setFormData({ ...formData, enabled: item.value as boolean }); - setTouched(prev => new Set(prev).add('enabled')); + setTouched((prev) => new Set(prev).add('enabled')); }} /> ); }; - // Render text input + // Render text input. SingleLineInput ignores control chords outright (so + // Ctrl+S/Ctrl+A can never be typed into the field) and accepts pasted chunks. const renderTextInput = (field: FormField) => { const placeholder = fieldPlaceholder[field]; return ( - { - // Ignore changes when Ctrl+A is being processed - if (isCtrlAActive.current) { - return; - } setFormData({ ...formData, [field]: value }); - }} - onSubmit={() => { - setTouched(prev => new Set(prev).add(field)); - goToNextField(); + setSubmitError(null); }} {...(placeholder ? { placeholder } : {})} /> @@ -641,7 +735,7 @@ export const ServiceFormUnified: React.FC = ({ const error = fieldErrors.get(config.field); const hasError = touched.has(config.field) && error; const isAdvanced = advancedOnlyFields.includes(config.field); - const isVisible = (config.required || !isAdvanced) || (showAdvanced && isAdvanced); + const isVisible = config.required || !isAdvanced || (showAdvanced && isAdvanced); if (!isVisible) { return null; @@ -659,7 +753,7 @@ export const ServiceFormUnified: React.FC = ({ {config.required && *} - + {/* Help text only shown in non-compact mode (it's in dedicated area in compact mode) */} {showHelp && ( @@ -670,16 +764,17 @@ export const ServiceFormUnified: React.FC = ({ {isCurrent ? ( <> - {config.type === 'select' ? ( - config.field === 'transport' ? renderTransportSelector() : - config.field === 'enabled' ? renderEnabledSelector() : null - ) : ( - renderTextInput(config.field) - )} + {config.type === 'select' + ? config.field === 'transport' + ? renderTransportSelector() + : config.field === 'enabled' + ? renderEnabledSelector() + : null + : renderTextInput(config.field)} ) : ( - {formData[config.field as keyof FormData]?.toString() || (empty)} + {formatFieldValue(config.field, formData)} )} @@ -701,32 +796,36 @@ export const ServiceFormUnified: React.FC = ({ const fieldMarginBottom = isCompactMode ? (isUltraCompactMode ? 0 : 0) : 1; const helpPaddingX = isCompactMode ? 0 : 1; - // Calculate visible fields based on scroll - const visibleConfigs = fieldConfigs.slice(scrollOffset, scrollOffset + visibleFieldCount); - const hasMoreAbove = scrollOffset > 0; - const hasMoreBelow = scrollOffset + visibleFieldCount < fieldConfigs.length; - return ( {/* Header */} - + + {' '} {service ? 'Edit Service' : 'Add New Service'} {/* Form fields */} - + {/* Scroll indicator */} {hasMoreAbove && ( ▲ more above )} - - {visibleConfigs.map(config => - renderField(config, config.field === currentField) - )} + + {visibleConfigs.map((config) => renderField(config, config.field === currentField))} {/* Scroll indicator */} {hasMoreBelow && ( @@ -738,28 +837,46 @@ export const ServiceFormUnified: React.FC = ({ {/* Advanced options toggle */} {!showAdvanced && ( - - Press Ctrl+A to show connection pool settings - + Press Ctrl+A to show connection pool settings )} {/* Field help info - prominent display */} {currentFieldHelp && ( - + 💡 - {currentFieldConfig?.label}: + + {currentFieldConfig?.label}:{' '} + {currentFieldHelp} )} + {/* Submission error — visible feedback when Ctrl+S is refused */} + {submitError !== null && ( + + ✗ {submitError} + + )} + {/* Navigation help */} - + - ↑/↓: Scroll | Tab/Enter: Next | Ctrl+A: Advanced | Ctrl+S: Save | Esc: Cancel + ↑/↓/Tab: Field | Enter: Next | Ctrl+A: Advanced | Ctrl+S: Save | Esc: Cancel diff --git a/src/tui/components/ServiceJsonEditor.tsx b/src/tui/components/ServiceJsonEditor.tsx deleted file mode 100644 index f1c18a2..0000000 --- a/src/tui/components/ServiceJsonEditor.tsx +++ /dev/null @@ -1,422 +0,0 @@ -/** - * Service JSON Editor Component - * - * Multi-line JSON editor for service configuration. - * Provides real-time validation, file import, and bulk import support. - */ - -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) */ - initialJson?: string; - /** Callback when JSON is submitted */ - onSubmit: (services: ServiceDefinition[]) => void; - /** Callback when editor is cancelled */ - onCancel: () => void; -} - -/** - * Validation result - */ -export interface ValidationResult { - valid: boolean; - errors: string[]; - services?: ServiceDefinition[]; -} - -/** - * Validate JSON and parse services - */ -export function validateJson(jsonText: string): ValidationResult { - if (!jsonText.trim()) { - return { - valid: false, - errors: ['JSON content is empty'], - }; - } - - try { - const parsed = JSON.parse(jsonText); - - // Check if it's a single service or multiple services - let services: ServiceDefinition[]; - - if (Array.isArray(parsed)) { - // Array of services - services = parsed; - } else if (typeof parsed === 'object' && parsed !== null) { - // Could be a single service or mcpServers format - if (parsed.name && parsed.transport) { - // Single service - services = [parsed as ServiceDefinition]; - } else { - // Assume mcpServers format: { "serviceName": { command, args, env, ... }, ... } - services = Object.entries(parsed).map(([name, config]: [string, any]) => { - const service: ServiceDefinition = { - name, - transport: config.transport || 'stdio', - enabled: config.enabled !== false, - tags: config.tags || [], - connectionPool: { - maxConnections: config.connectionPool?.maxConnections || 5, - idleTimeout: config.connectionPool?.idleTimeout || 60000, - connectionTimeout: config.connectionPool?.connectionTimeout || 30000, - }, - }; - - if (service.transport === 'stdio') { - service.command = config.command; - service.args = config.args; - service.env = config.env; - } else { - service.url = config.url; - if (config.headers != null && typeof config.headers === 'object' && !Array.isArray(config.headers)) { - service.headers = config.headers as Record; - } - } - - if (config.toolStates) { - service.toolStates = config.toolStates; - } - - if ( - config.triggerHints && - typeof config.triggerHints === 'object' && - !Array.isArray(config.triggerHints) - ) { - service.triggerHints = config.triggerHints as NonNullable; - } - - return service; - }); - } - } else { - return { - valid: false, - errors: ['JSON must be an object or array'], - }; - } - - // Validate each service - const errors: string[] = []; - - for (let i = 0; i < services.length; i++) { - const service = services[i]; - const prefix = services.length > 1 ? `Service ${i + 1} (${service?.name || 'unnamed'}): ` : ''; - - if (!service) { - errors.push(`${prefix}Service is null or undefined`); - continue; - } - - if (!service.name || typeof service.name !== 'string') { - errors.push(`${prefix}Missing or invalid 'name' field`); - } else if (!/^[a-zA-Z0-9_-]+$/.test(service.name)) { - errors.push(`${prefix}Service name can only contain letters, numbers, hyphens, and underscores`); - } - - if (!service.transport || !['stdio', 'sse', 'http'].includes(service.transport)) { - errors.push(`${prefix}Invalid 'transport' field (must be 'stdio', 'sse', or 'http')`); - } - - if (service.transport === 'stdio') { - if (!service.command || typeof service.command !== 'string') { - errors.push(`${prefix}Missing or invalid 'command' field for stdio transport`); - } - } else { - if (!service.url || typeof service.url !== 'string') { - errors.push(`${prefix}Missing or invalid 'url' field for ${service.transport} transport`); - } else if (!/^https?:\/\/.+/.test(service.url)) { - errors.push(`${prefix}URL must start with http:// or https://`); - } - } - - if (service.connectionPool) { - const pool = service.connectionPool; - if (typeof pool.maxConnections !== 'number' || pool.maxConnections < 1 || pool.maxConnections > 100) { - errors.push(`${prefix}maxConnections must be between 1 and 100`); - } - if (typeof pool.idleTimeout !== 'number' || pool.idleTimeout < 1000) { - errors.push(`${prefix}idleTimeout must be at least 1000ms`); - } - if (typeof pool.connectionTimeout !== 'number' || pool.connectionTimeout < 1000) { - errors.push(`${prefix}connectionTimeout must be at least 1000ms`); - } - } - } - - if (errors.length > 0) { - return { - valid: false, - errors, - }; - } - - // Strip headers from stdio services so they are not persisted - for (const s of services) { - if (s.transport === 'stdio' && s.headers !== undefined) { - delete s.headers; - } - } - - return { - valid: true, - errors: [], - services, - }; - } catch (error) { - const err = error as Error; - return { - valid: false, - errors: [`JSON parse error: ${err.message}`], - }; - } -} - -/** - * Get example JSON template - */ -function getExampleJson(): string { - return JSON.stringify({ - "filesystem": { - "transport": "stdio", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], - "env": { - "NODE_ENV": "production" - }, - "tags": ["local", "storage"], - "enabled": true, - "connectionPool": { - "maxConnections": DEFAULT_CONNECTION_POOL.maxConnections, - "idleTimeout": DEFAULT_CONNECTION_POOL.idleTimeout, - "connectionTimeout": DEFAULT_CONNECTION_POOL.connectionTimeout - } - }, - "github": { - "transport": "stdio", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "env": { - "GITHUB_TOKEN": "your-token-here" - }, - "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); -} - -/** - * Service JSON Editor Component - */ -export const ServiceJsonEditor: React.FC = ({ - initialJson, - onSubmit, - onCancel, -}) => { - const [jsonText, setJsonText] = useState(initialJson || ''); - const [cursorPosition, setCursorPosition] = useState(0); - const [showHelp, setShowHelp] = useState(false); - const [validationResult, setValidationResult] = useState({ valid: true, errors: [] }); - - // Validate JSON on change - useEffect(() => { - if (jsonText.trim()) { - const result = validateJson(jsonText); - setValidationResult(result); - } else { - setValidationResult({ valid: true, errors: [] }); - } - }, [jsonText]); - - // Handle keyboard input - useInput((input, key) => { - if (key.escape) { - onCancel(); - return; - } - - // Toggle help - if (input === '?' || (key as any).f1) { - setShowHelp(!showHelp); - return; - } - - // Load example - if (input === 'e' && key.ctrl) { - setJsonText(getExampleJson()); - return; - } - - // Submit - if (input === 's' && key.ctrl) { - if (validationResult.valid && validationResult.services) { - onSubmit(validationResult.services); - } - return; - } - - // Clear - if (input === 'l' && key.ctrl) { - setJsonText(''); - setCursorPosition(0); - return; - } - - // Handle text input - if (key.backspace || key.delete) { - if (jsonText.length > 0 && cursorPosition > 0) { - const newText = jsonText.slice(0, cursorPosition - 1) + jsonText.slice(cursorPosition); - setJsonText(newText); - setCursorPosition(cursorPosition - 1); - } - } else if (key.return) { - const newText = jsonText.slice(0, cursorPosition) + '\n' + jsonText.slice(cursorPosition); - setJsonText(newText); - setCursorPosition(cursorPosition + 1); - } else if (input && !key.ctrl && !key.meta) { - const newText = jsonText.slice(0, cursorPosition) + input + jsonText.slice(cursorPosition); - setJsonText(newText); - setCursorPosition(cursorPosition + input.length); - } - }); - - // Render help screen - if (showHelp) { - return ( - - - JSON Editor Help - - - - Keyboard Shortcuts: - Ctrl+S - Save and submit - Ctrl+E - Load example template - Ctrl+L - Clear editor - ? or F1 - Toggle this help - Esc - Cancel and return - - - - Supported Formats: - 1. Single service object: - {`{ "name": "myservice", "transport": "stdio", ... }`} - 2. Array of services: - {`[{ "name": "service1", ... }, { "name": "service2", ... }]`} - 3. mcpServers format: - {`{ "service1": { "command": "...", ... }, "service2": { ... } }`} - - - - Required Fields: - • name - Service identifier - • transport - 'stdio', 'sse', or 'http' - • command - Required for stdio transport - • url - Required for sse/http transport - - - - Press ? or F1 to return to editor - - - ); - } - - // Calculate display lines (limit to visible area) - const lines = jsonText.split('\n'); - const maxVisibleLines = 15; - const displayLines = lines.slice(0, maxVisibleLines); - const hasMoreLines = lines.length > maxVisibleLines; - - return ( - - - JSON Configuration Editor - - - {/* Editor area */} - - - - Lines: {lines.length} | Characters: {jsonText.length} - {validationResult.valid && validationResult.services && ( - | ✓ Valid ({validationResult.services.length} service{validationResult.services.length !== 1 ? 's' : ''}) - )} - {!validationResult.valid && ( - | ✗ Invalid - )} - - - - - {displayLines.length === 0 ? ( - Type or paste JSON configuration here... - ) : ( - displayLines.map((line, index) => ( - {line || ' '} - )) - )} - {hasMoreLines && ( - ... ({lines.length - maxVisibleLines} more lines) - )} - - - - {/* Validation errors */} - {!validationResult.valid && validationResult.errors.length > 0 && ( - - Validation Errors: - {validationResult.errors.slice(0, 5).map((error, index) => ( - • {error} - ))} - {validationResult.errors.length > 5 && ( - ... and {validationResult.errors.length - 5} more errors - )} - - )} - - {/* Service preview */} - {validationResult.valid && validationResult.services && validationResult.services.length > 0 && ( - - Services to Import: - {validationResult.services.slice(0, 3).map((service, index) => ( - - • {service.name} ({service.transport}) - {service.enabled === false && [disabled]} - - ))} - {validationResult.services.length > 3 && ( - ... and {validationResult.services.length - 3} more services - )} - - )} - - {/* Keyboard shortcuts */} - - - Ctrl+S: Save | Ctrl+E: Example | Ctrl+L: Clear | ?: Help | Esc: Cancel - - - - ); -}; diff --git a/src/tui/components/ServiceList.tsx b/src/tui/components/ServiceList.tsx index e9d59d8..0950fc0 100644 --- a/src/tui/components/ServiceList.tsx +++ b/src/tui/components/ServiceList.tsx @@ -3,10 +3,16 @@ * * Displays all registered services with their status and details. * Supports navigation and selection with enhanced visual feedback. + * + * Every service renders on EXACTLY one line: the endpoint/tags are truncated to + * a computed cell budget instead of wrapping. Wrapping was what pushed the frame + * past the terminal (rows can be 2-3 lines when an endpoint is long), and a frame + * taller than the viewport makes ink's absolute writes land on the wrong rows. */ import React, { useState, useEffect } from 'react'; import { Box, Text, useInput, useStdout } from 'ink'; +import { truncateDisplay } from '../text-layout.js'; import type { ServiceDefinition } from '../../types/service.js'; import type { DiscoveryStatus } from '../tool-discovery-manager.js'; @@ -15,15 +21,64 @@ export interface ServiceListProps { selectedIndex: number; onSelect: (index: number) => void; terminalHeight?: number; - showDetails?: boolean; - globalToolStats?: { - enabled: number; - total: number; - }; discoveryStatus?: Map; toolCounts?: Map; } +/** Column budgets derived from the terminal width. */ +export interface ListColumnLayout { + nameWidth: number; + transportWidth: number; + endpointWidth: number; + /** null → the tags column is dropped to keep the endpoint readable. */ + tagWidth: number | null; + /** null → the tool-count column is dropped. */ + toolWidth: number | null; +} + +const MARKER_WIDTH = 3; +const TRANSPORT_WIDTH = 8; +const DEFAULT_NAME_WIDTH = 25; +const DEFAULT_TAG_WIDTH = 28; +const DEFAULT_TOOL_WIDTH = 12; +const MIN_NAME_WIDTH = 10; +/** Below this the endpoint stops being readable, so columns get dropped instead. */ +const MIN_ENDPOINT_WIDTH = 10; + +/** + * Split the horizontal budget between columns, dropping the optional ones + * (tags, then tool counts) before letting the endpoint collapse to nothing. + */ +export function computeColumnLayout(terminalWidth: number): ListColumnLayout { + const inner = Math.max(20, terminalWidth - 4); // borders (2) + paddingX (2) + const fixed = MARKER_WIDTH + TRANSPORT_WIDTH; + let nameWidth = DEFAULT_NAME_WIDTH; + let tagWidth: number | null = DEFAULT_TAG_WIDTH; + let toolWidth: number | null = DEFAULT_TOOL_WIDTH; + + const endpointIfKept = (): number => + inner - fixed - nameWidth - (tagWidth ?? 0) - (toolWidth ?? 0); + + if (endpointIfKept() < MIN_ENDPOINT_WIDTH) { + tagWidth = null; + } + if (endpointIfKept() < MIN_ENDPOINT_WIDTH) { + toolWidth = null; + } + if (endpointIfKept() < MIN_ENDPOINT_WIDTH) { + const deficit = MIN_ENDPOINT_WIDTH - endpointIfKept(); + nameWidth -= Math.min(deficit, nameWidth - MIN_NAME_WIDTH); + } + + return { + nameWidth, + transportWidth: TRANSPORT_WIDTH, + endpointWidth: Math.max(4, endpointIfKept()), + tagWidth, + toolWidth, + }; +} + /** * Format transport type with color */ @@ -50,14 +105,15 @@ function formatEnabled(enabled: boolean): { text: string; color: string; symbol: } /** - * Service List Item Component + * Service List Item Component — renders exactly one terminal line. */ const ServiceListItem: React.FC<{ service: ServiceDefinition; isSelected: boolean; + layout: ListColumnLayout; discoveryStatus?: DiscoveryStatus; toolCount?: number; -}> = ({ service, isSelected, discoveryStatus, toolCount }) => { +}> = ({ service, isSelected, layout, discoveryStatus, toolCount }) => { const transport = formatTransport(service.transport); const status = formatEnabled(service.enabled); @@ -68,97 +124,85 @@ const ServiceListItem: React.FC<{ const enabledTools = (toolCount ?? 0) - disabledTools; - const endpoint = service.transport === 'stdio' - ? ((service.command || '') + (service.args?.length ? ' ' + service.args.join(' ') : '')) - : (service.url || ''); + const endpoint = + service.transport === 'stdio' + ? (service.command || '') + (service.args?.length ? ' ' + service.args.join(' ') : '') + : service.url || ''; - const endpointDisplay = endpoint.length > 50 - ? endpoint.substring(0, 47) + '...' - : endpoint; + const tags = service.tags?.slice(0, 3) ?? []; - const renderTags = () => { - const tags = service.tags?.slice(0, 3) ?? []; - - return ( - - {tags.map((tag, index) => ( - - [ - {tag} - ] - {index < tags.length - 1 && } - - ))} - - ); - }; - - // Render tool count indicator based on discovery status - const renderToolIndicator = () => { + const toolLabel = (): string => { if (!service.enabled) { - return ; + return ''; } - switch (discoveryStatus) { case 'in-progress': - return ( - - ⏳ loading - - ); + return '⏳ loading'; case 'failed': - return ( - - ✗ failed - - ); + return '✗ failed'; case 'completed': - if (toolCount !== undefined && toolCount > 0) { - return ( - - {Math.max(0, enabledTools)}/{toolCount} - tools - - ); - } - return ; + return toolCount !== undefined && toolCount > 0 + ? `${Math.max(0, enabledTools)}/${toolCount} tools` + : ''; default: - return ; + return ''; } }; return ( - - - + + + {isSelected ? '▶' : ' '} - + {status.symbol} - - {' '}{service.name} + + {' '} + {truncateDisplay(service.name, Math.max(1, layout.nameWidth - 2))} - - {transport.text} + + + {transport.text} + - - {endpointDisplay} + + + {truncateDisplay(endpoint, layout.endpointWidth)} + - {renderTags()} - {renderToolIndicator()} + {layout.tagWidth !== null && ( + + + {tags.map((tag, index) => ( + + [ + {tag} + ] + {index < tags.length - 1 ? ' ' : ''} + + ))} + + + )} + + {layout.toolWidth !== null && ( + + + + {toolLabel()} + + + + )} ); }; @@ -177,12 +221,17 @@ export const ServiceList: React.FC = ({ const { stdout } = useStdout(); const effectiveTerminalHeight = terminalHeight || 24; const effectiveTerminalWidth = stdout?.columns || 80; - const HEADER_LINES = 2; - const FOOTER_LINES = 2; - const SERVICE_ITEM_LINES = 1; - // Calculate visible services based on terminal height, but cap at 25 per page - const calculatedVisible = Math.floor((effectiveTerminalHeight - HEADER_LINES - FOOTER_LINES) / SERVICE_ITEM_LINES); - const MAX_VISIBLE_SERVICES = Math.min(25, Math.max(3, calculatedVisible)); + + const layout = computeColumnLayout(effectiveTerminalWidth); + + // Chrome the list draws around its rows: header(1) + box borders(2) + + // footer borders(2), plus one row for the pagination hint when it shows. + const LIST_CHROME_LINES = 5; + const rowsForItems = (withPager: boolean): number => + Math.max(3, effectiveTerminalHeight - LIST_CHROME_LINES - (withPager ? 1 : 0)); + + const wouldPaginate = Math.ceil(services.length / Math.min(25, rowsForItems(false))) > 1; + const MAX_VISIBLE_SERVICES = Math.min(25, rowsForItems(wouldPaginate)); const [currentPage, setCurrentPage] = useState(0); const totalPages = Math.ceil(services.length / MAX_VISIBLE_SERVICES); @@ -215,50 +264,58 @@ export const ServiceList: React.FC = ({ if (services.length === 0) { return ( - - 📋 No services registered - Get started by adding your first MCP service + + + 📋 No services registered + + Get started by adding your first MCP service a - : Add service | + : Add service | ? - : Help | + : Help | q - : Quit + : Quit ); } - const enabledCount = services.filter(s => s.enabled).length; + const enabledCount = services.filter((s) => s.enabled).length; const disabledCount = services.length - enabledCount; return ( {/* Header */} - {services.length} Services - : - {enabledCount} enabled + + {services.length} Services + + : + + {enabledCount} enabled + {disabledCount > 0 && ( <> - , - {disabledCount} disabled + , + + {disabledCount} disabled + )} {/* Service list with border */} - + {visibleServices.map((service, index) => { const svcDiscoveryStatus = discoveryStatus?.get(service.name); const svcToolCount = toolCounts?.get(service.name); @@ -267,6 +324,7 @@ export const ServiceList: React.FC = ({ key={service.name} service={service} isSelected={startIndex + index === selectedIndex} + layout={layout} {...(svcDiscoveryStatus !== undefined ? { discoveryStatus: svcDiscoveryStatus } : {})} {...(svcToolCount !== undefined ? { toolCount: svcToolCount } : {})} /> @@ -276,17 +334,19 @@ export const ServiceList: React.FC = ({ {totalPages > 1 && ( - - {currentPage + 1}/{totalPages} + + + {currentPage + 1}/{totalPages} + | ↑/↓ Navigate | ←/→ Page - ({services.length} services) + ({services.length} services) )} {/* Footer with shortcuts */} - - + + ↑/↓ Navigate | Enter Edit | Space Toggle | a Add | d Delete | v Tools | r Refresh | q Quit diff --git a/src/tui/components/ServiceTools.tsx b/src/tui/components/ServiceTools.tsx index d74e264..b93765d 100644 --- a/src/tui/components/ServiceTools.tsx +++ b/src/tui/components/ServiceTools.tsx @@ -29,6 +29,7 @@ import { truncateDisplay, } from '../text-layout.js'; import { copyToClipboard } from '../clipboard.js'; +import { safeStringify } from '../../utils/safe-json.js'; import { bestEffortArgs, buildParamRows, @@ -45,6 +46,7 @@ import type { ServiceDefinition } from '../../types/service.js'; import type { Tool } from '../../types/tool.js'; import type { ToolParam } from '../tool-param-schema.js'; import { SingleLineInput } from './SingleLineInput.js'; +import { isPrintableChunk } from '../input-text.js'; import { JsonTextArea } from './JsonTextArea.js'; export interface ServiceToolsProps { @@ -134,12 +136,14 @@ const isCtrlJ = (input: string, key: { ctrl: boolean }): boolean => const sanitizeFileName = (name: string): string => name.replace(/[^A-Za-z0-9._-]/g, '_'); -function safeStringify(value: unknown): string { - try { - return JSON.stringify(value, null, 2) ?? String(value); - } catch { - return String(value); - } +/** + * Seed for the raw-JSON arguments editor. With nothing to project the buffer + * starts EMPTY rather than as a `{}` stub — a stub is never replaced when the + * cursor is at offset 0, so typing produced `{}{"text":"hi"}` and an obscure + * parse error. + */ +function seedJsonText(args: Record): string { + return Object.keys(args).length > 0 ? JSON.stringify(args, null, 2) : ''; } /** Classify a call failure into the run-view error copy. */ @@ -207,7 +211,7 @@ export const ServiceTools: React.FC = ({ const [fieldIndex, setFieldIndex] = useState(0); const [panelScroll, setPanelScroll] = useState(0); const [formValues, setFormValuesState] = useState>({}); - const [jsonText, setJsonTextState] = useState(() => JSON.stringify({}, null, 2)); + const [jsonText, setJsonTextState] = useState(''); const [jsonError, setJsonError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); const [extraCount, setExtraCount] = useState(0); @@ -313,6 +317,12 @@ export const ServiceTools: React.FC = ({ // Reset per-tool editor/run state when the selected tool changes, but carry // the typed arguments over per tool so switching back restores them. + // + // Deliberately keyed on the tool NAME only: a tools/list refresh produces new + // object identities, so depending on `params` (or the schema) would re-run + // this reset on every refetch and wipe the arguments being typed. The + // trade-off is that a same-named tool whose schema changed mid-session keeps + // stale form values until the tool is re-selected. useEffect(() => { const previous = prevToolNameRef.current; if (previous !== undefined && previous !== currentTool?.name) { @@ -330,8 +340,9 @@ export const ServiceTools: React.FC = ({ setFieldIndex(0); setPanelScroll(0); setDescExpanded(false); - setFormValues(restored?.values ?? seedFormValues(params)); - setJsonText(restored?.jsonText ?? JSON.stringify({}, null, 2)); + const seededValues = restored?.values ?? seedFormValues(params); + setFormValues(seededValues); + setJsonText(restored?.jsonText ?? seedJsonText(bestEffortArgs(params, seededValues))); setJsonError(null); setFieldErrors({}); setExtraCount(restored === undefined ? 0 : Object.keys(restored.extra).length); @@ -492,11 +503,7 @@ export const ServiceTools: React.FC = ({ } if (focus !== 'json') { setJsonText( - JSON.stringify( - { ...bestEffortArgs(params, formValuesRef.current), ...extraArgsRef.current }, - null, - 2 - ) + seedJsonText({ ...bestEffortArgs(params, formValuesRef.current), ...extraArgsRef.current }) ); setJsonError(null); setFocus('json'); @@ -504,7 +511,8 @@ export const ServiceTools: React.FC = ({ } let parsed: unknown; try { - parsed = JSON.parse(jsonTextRef.current); + const raw = jsonTextRef.current.trim(); + parsed = raw === '' ? {} : JSON.parse(raw); } catch (err) { setJsonError(err instanceof Error ? err.message : 'Invalid JSON — fix before switching'); return; @@ -618,6 +626,13 @@ export const ServiceTools: React.FC = ({ const file = join(dir, `${sanitizeFileName(currentTool?.name ?? 'tool')}.json`); writeFileSync(file, outcome.raw, 'utf8'); setDumpPath(file); + // The panel line is width-truncated, so hand the full path to the + // clipboard as well — otherwise the saved file can't be located. + setCopyNotice( + copyToClipboard(file) + ? '✓ Full path copied to the clipboard' + : '✓ Output saved — clipboard unavailable, use the path above' + ); } catch { setDumpPath(null); } @@ -687,8 +702,9 @@ export const ServiceTools: React.FC = ({ setSearchQuery((prev) => prev.slice(0, -1)); return; } - // Printable character (including space) → append to query - if (input && input.length === 1 && input >= ' ' && input !== '/' && !key.ctrl) { + // Printable text (a keystroke or a whole pasted chunk) → append to query. + // A lone '/' still only opens the search box. + if (isPrintableChunk(input) && !(input.length === 1 && input === '/')) { setSearchQuery((prev) => prev + input); return; } @@ -973,13 +989,32 @@ export const ServiceTools: React.FC = ({ body = resultText; } // Every line is kept — rendering is already bounded by the viewport slice, - // so capping here would only make output unreachable. + // so capping here would only make output unreachable. The CHARACTER cap + // above (RESULT_MAX_CHARS) does hide the tail, so say so instead of letting + // the output look complete; Ctrl+O still writes the full result. const lines = wrapText(body, RESULT_INNER); + const sourceLength = + resultView === 'raw' ? (outcome?.raw.length ?? 0) : (outcome?.formatted.length ?? 0); + if (sourceLength > RESULT_MAX_CHARS) { + lines.push( + `… truncated at ${RESULT_MAX_CHARS.toLocaleString()} characters — Ctrl+O saves the full result` + ); + } if (dumpPath !== null) { - lines.push(`Saved full output: ${truncateDisplay(dumpPath, RESULT_INNER - 20)}`); + const shown = dumpPath.replace(tmpdir(), '…'); + lines.push(`Saved full output: ${truncateDisplay(shown, RESULT_INNER - 19)}`); } return lines; - }, [runStatus, errorMessage, resultText, dumpPath, RESULT_INNER, currentTool?.namespacedName]); + }, [ + runStatus, + errorMessage, + resultText, + resultView, + outcome, + dumpPath, + RESULT_INNER, + currentTool?.namespacedName, + ]); /** The result section: section header, then a framed block of output rows. */ const resultRows: DetailRow[] = useMemo(() => { @@ -1121,19 +1156,23 @@ export const ServiceTools: React.FC = ({ // the actual text — never the `│` frame or the row's trailing padding, // which would otherwise turn a blank result line into a solid bar. const line = resultLines[row.resultIndex] ?? ''; - const fitted = truncateDisplay(line, RESULT_INNER); - const padding = ' '.repeat(Math.max(0, RESULT_INNER - displayWidth(fitted))); + // The cursor marker sits INSIDE the frame and eats one content cell, so + // the box keeps both of its borders (a marker drawn over the left frame + // made the box look broken) and the right border stays aligned. + const cursorHere = focus === 'result' && row.resultIndex === resultCursor; + const marker = cursorHere ? '▸' : ''; + const budget = RESULT_INNER - marker.length; + const fitted = truncateDisplay(line, budget); + const padding = ' '.repeat(Math.max(0, budget - displayWidth(fitted))); const highlighted = range !== null && row.resultIndex >= range.from && row.resultIndex <= range.to && fitted.trim() !== ''; - // The cursor replaces the left frame cell (`▸` is single-width, so the - // box stays aligned) — visible only while the region has focus. - const cursorHere = focus === 'result' && row.resultIndex === resultCursor; return ( - {cursorHere ? '▸' : '│'} + {'│'} + {marker} {fitted} {padding} {'│'} diff --git a/src/tui/components/SingleLineInput.tsx b/src/tui/components/SingleLineInput.tsx index 045a69b..981819b 100644 --- a/src/tui/components/SingleLineInput.tsx +++ b/src/tui/components/SingleLineInput.tsx @@ -14,6 +14,8 @@ import React, { useState } from 'react'; import { Box, Text, useInput } from 'ink'; +import { isPrintableChunk } from '../input-text.js'; +import { displayWidth } from '../text-layout.js'; export interface SingleLineInputProps { value: string; @@ -33,7 +35,15 @@ export const SingleLineInput: React.FC = ({ const clampedCursor = Math.min(cursor, value.length); useInput((input, key) => { - if (key.ctrl || key.escape || key.tab || key.return || key.upArrow || key.downArrow) { + if ( + key.ctrl || + key.meta || + key.escape || + key.tab || + key.return || + key.upArrow || + key.downArrow + ) { return; } @@ -53,8 +63,10 @@ export const SingleLineInput: React.FC = ({ return; } - // Printable character - if (input && input.length === 1 && input >= ' ' && input !== '\n') { + // Printable text: one keystroke, or a whole pasted/batched chunk. Ink + // delivers a paste as a single multi-character `input`, so filtering on + // `length === 1` would silently drop it. + if (isPrintableChunk(input)) { onChange(value.slice(0, clampedCursor) + input + value.slice(clampedCursor)); setCursor(clampedCursor + input.length); } @@ -63,19 +75,54 @@ export const SingleLineInput: React.FC = ({ const showPlaceholder = value === '' && placeholder !== undefined && placeholder !== ''; // Cursor-following window so the input always occupies exactly one row. + // + // Measured in display CELLS (a CJK character is 2), never in UTF-16 units: + // slicing `value` by code units produced rows wider than the box for wide + // characters (the cursor could also drift out of the visible window). const windowed = width === undefined ? null : (() => { const w = Math.max(4, width); - const winStart = Math.max(0, Math.min(clampedCursor - (w - 2), value.length)); + const beforeRaw = value.slice(0, clampedCursor); + const atRaw = value.slice(clampedCursor, clampedCursor + 1); + const afterRaw = value.slice(clampedCursor + 1); + // The cursor needs at least one cell; keep a little room for the two + // clip indicators (the box truncates anyway, so being 1-2 cells + // conservative is harmless, wrapping is not). + let budget = Math.max(1, w - Math.max(1, displayWidth(atRaw)) - 2); + + let before = ''; + for (let i = beforeRaw.length - 1; i >= 0; i -= 1) { + const ch = beforeRaw[i]; + if (ch === undefined) { + break; + } + const cellWidth = displayWidth(ch); + if (cellWidth > budget) { + break; + } + budget -= cellWidth; + before = ch + before; + } + const clippedLeft = before.length < beforeRaw.length; + + let after = ''; + for (const ch of afterRaw) { + const cellWidth = displayWidth(ch); + if (cellWidth > budget) { + break; + } + budget -= cellWidth; + after += ch; + } + return { - start: winStart, - before: value.slice(winStart, clampedCursor), - at: value.slice(clampedCursor, clampedCursor + 1), - after: value.slice(clampedCursor + 1, winStart + w - 1), - clippedLeft: winStart > 0, - clippedRight: winStart + w - 1 < value.length, + before, + at: atRaw, + after, + clippedLeft, + clippedRight: after.length < afterRaw.length, }; })(); diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx index c8ab60d..83f88bc 100644 --- a/src/tui/components/StatusBar.tsx +++ b/src/tui/components/StatusBar.tsx @@ -1,6 +1,6 @@ /** * TUI Status Bar Component - * + * * Displays status messages and notifications */ @@ -20,19 +20,27 @@ export interface StatusBarProps { const getStatusIcon = (type: StatusMessage['type']): string => { switch (type) { - case 'success': return '✓'; - case 'error': return '✗'; - case 'warning': return '⚠'; - case 'info': return 'ℹ'; + case 'success': + return '✓'; + case 'error': + return '✗'; + case 'warning': + return '⚠'; + case 'info': + return 'ℹ'; } }; const getStatusColor = (type: StatusMessage['type']): string => { switch (type) { - case 'success': return 'green'; - case 'error': return 'red'; - case 'warning': return 'yellow'; - case 'info': return 'blue'; + case 'success': + return 'green'; + case 'error': + return 'red'; + case 'warning': + return 'yellow'; + case 'info': + return 'blue'; } }; @@ -41,11 +49,11 @@ export const StatusBar: React.FC = ({ message, onClear }) => { useEffect(() => { let timer: NodeJS.Timeout | null = null; - + if (message) { setVisible(true); const duration = message.duration || 3000; - + if (duration > 0) { timer = setTimeout(() => { setVisible(false); @@ -55,7 +63,7 @@ export const StatusBar: React.FC = ({ message, onClear }) => { } else { setVisible(false); } - + return () => { if (timer) { clearTimeout(timer); @@ -71,12 +79,7 @@ export const StatusBar: React.FC = ({ message, onClear }) => { const icon = getStatusIcon(message.type); return ( - + {icon} {message.message} diff --git a/src/tui/discovery-worker.ts b/src/tui/discovery-worker.ts index 65edb60..92c56c5 100644 --- a/src/tui/discovery-worker.ts +++ b/src/tui/discovery-worker.ts @@ -5,7 +5,8 @@ import EventSource from 'eventsource'; import { StdioTransport } from '../transport/stdio.js'; -import { isSessionExpiryError } from '../routing/session-error.js'; +import { isRecoverableConnectionError, isSessionExpiryError } from '../routing/session-error.js'; +import { safeStringify } from '../utils/safe-json.js'; import { getPackageVersion } from '../utils/package-version.js'; import { isRecord } from './tool-param-schema.js'; import type { JsonRpcMessage } from '../types/jsonrpc.js'; @@ -42,6 +43,21 @@ export class DiscoveryError extends Error { } } +/** + * Raised when a one-shot session ended before answering a request. + * + * That is the worker-level shape of "the connection died while we were using + * it" (stdio child exit, SSE drop) — the transport's own error never reaches the + * caller because the receive stream simply ends. Typed rather than inferred from + * the message so the retry predicate can classify it reliably. + */ +export class SessionClosedError extends Error { + constructor(method: string) { + super(`No response for ${method} request`); + this.name = 'SessionClosedError'; + } +} + /** * Parse command string into command and args */ @@ -283,7 +299,7 @@ async function stdioSession( const iter = boundTransport.receive(); const res = await iter.next(); if (res.value === undefined || res.value === null) { - throw new Error(`No response for ${String(msg['method'])} request`); + throw new SessionClosedError(String(msg['method'])); } return res.value as Record; }; @@ -552,6 +568,36 @@ async function sseSession( /** * Discover tools via stdio transport */ +/** + * Map a discovery failure onto the DiscoveryError the UI reports. + * + * The three transports fail identically from the caller's point of view, so the + * classification lives here instead of in three copies of the same catch body. + */ +function toDiscoveryFailure( + err: unknown, + service: ServiceDefinition, + timeout: number +): DiscoveryError { + if (err instanceof DiscoveryError) { + return err; + } + if (isTimeoutError(err)) { + return new DiscoveryError( + DiscoveryErrorType.TIMEOUT, + service.name, + `Discovery timeout after ${timeout}ms`, + err instanceof Error ? err : undefined + ); + } + return new DiscoveryError( + DiscoveryErrorType.CONNECTION_FAILED, + service.name, + err instanceof Error ? err.message : String(err), + err instanceof Error ? err : undefined + ); +} + async function discoverToolsViaStdio(service: ServiceDefinition, timeout: number): Promise { if (service.command === undefined || service.command === null) { return []; @@ -560,20 +606,7 @@ async function discoverToolsViaStdio(service: ServiceDefinition, timeout: number try { return await stdioSession(service, timeout, (send) => discoverToolsList(send, service)); } catch (err) { - if (isTimeoutError(err)) { - throw new DiscoveryError( - DiscoveryErrorType.TIMEOUT, - service.name, - `Discovery timeout after ${timeout}ms`, - err instanceof Error ? err : undefined - ); - } - throw new DiscoveryError( - DiscoveryErrorType.CONNECTION_FAILED, - service.name, - err instanceof Error ? err.message : String(err), - err instanceof Error ? err : undefined - ); + throw toDiscoveryFailure(err, service, timeout); } } @@ -590,23 +623,7 @@ async function discoverToolsViaSse(service: ServiceDefinition, timeout: number): discoverToolsList(send, service) ); } catch (err) { - if (err instanceof DiscoveryError) { - throw err; - } - if (isTimeoutError(err)) { - throw new DiscoveryError( - DiscoveryErrorType.TIMEOUT, - service.name, - `Discovery timeout after ${timeout}ms`, - err instanceof Error ? err : undefined - ); - } - throw new DiscoveryError( - DiscoveryErrorType.CONNECTION_FAILED, - service.name, - err instanceof Error ? err.message : String(err), - err instanceof Error ? err : undefined - ); + throw toDiscoveryFailure(err, service, timeout); } } @@ -625,20 +642,7 @@ async function discoverToolsViaHttp(service: ServiceDefinition, timeout: number) try { return await httpSession(service, timeout, (send) => discoverToolsList(send, service)); } catch (err) { - if (isTimeoutError(err)) { - throw new DiscoveryError( - DiscoveryErrorType.TIMEOUT, - service.name, - `Discovery timeout after ${timeout}ms`, - err instanceof Error ? err : undefined - ); - } - throw new DiscoveryError( - DiscoveryErrorType.CONNECTION_FAILED, - service.name, - err instanceof Error ? err.message : String(err), - err instanceof Error ? err : undefined - ); + throw toDiscoveryFailure(err, service, timeout); } } @@ -681,14 +685,6 @@ export class ToolCallError extends Error { } /** Safely pretty-print a value, degrading on circular refs / BigInt. */ -function safeJsonStringify(value: unknown): string { - try { - return JSON.stringify(value, null, 2) ?? String(value); - } catch { - return String(value); - } -} - /** * Re-format tool output as pretty JSON when it parses as JSON; otherwise * return the text verbatim. Only strings starting with { or [ are considered @@ -735,7 +731,7 @@ export function normalizeToolResult(result: unknown): ToolCallOutcome { } if (textParts.length === 0 && isRecord(result) && result['structuredContent'] !== undefined) { - textParts.push(safeJsonStringify(result['structuredContent'])); + textParts.push(safeStringify(result['structuredContent'])); } const joinedText = textParts.length > 0 ? textParts.join('\n') : '(empty result)'; @@ -744,7 +740,7 @@ export function normalizeToolResult(result: unknown): ToolCallOutcome { text: joinedText, formatted: formatToolOutput(joinedText), nonTextTypes, - raw: safeJsonStringify(result), + raw: safeStringify(result), }; } @@ -897,15 +893,41 @@ async function callServiceToolOnce( /** * Whether a call failure may be transparently retried on a fresh connection. * - * A backend that refused to execute the tool (validation error, unknown tool, - * ...) must NOT be replayed — isSessionExpiryError's message regex could - * misread such an error, so a ToolCallError only retries on a genuine -32001. + * Same two families the ToolRouter retries: an expired backend session + * (re-initialized on a fresh connection) and a dead-but-reconnectable + * transport (stdio respawn, SSE reconnect) — see routing/session-error.ts. + * A backend that REFUSED to execute the tool (validation error, unknown tool, + * ...) must not be replayed, so a ToolCallError only retries on a genuine + * -32001; every other ToolCallError is final. */ function isRetryableToolCallFailure(err: unknown): boolean { if (err instanceof ToolCallError) { return err.code === -32001; } - return isSessionExpiryError(err); + return isRetryableConnectionFailure(err); +} + +/** + * Session expiry or a dead-but-reconnectable transport — as the router + * classifies them. Transport failures arrive here wrapped in a DiscoveryError + * (which keeps the original as `errorCause`), so the wrapper is inspected too; + * without that, a stdio child dying mid-call would never be retried. + */ +function isRetryableConnectionFailure(err: unknown): boolean { + if (isRecoverableFailure(err)) { + return true; + } + const cause = err instanceof DiscoveryError ? err.errorCause : undefined; + return cause !== undefined && cause !== err && isRecoverableFailure(cause); +} + +/** One of the three shapes a dead/renewable connection takes at this layer. */ +function isRecoverableFailure(err: unknown): boolean { + return ( + err instanceof SessionClosedError || + isSessionExpiryError(err) || + isRecoverableConnectionError(err) + ); } /** @@ -938,9 +960,10 @@ export async function callServiceTool( * Used by ServiceTools view to display tool details. * * Each attempt opens a one-shot connection (initialize → tools/list → close), - * so a session-expiry failure (-32001 / HTTP 404) is recovered by simply - * retrying once: the fresh attempt establishes a brand-new backend session - * (lazy rebuild), matching the ToolRouter's recovery semantics. + * so a recoverable failure is handled by simply retrying once: the fresh + * attempt establishes a brand-new backend session (lazy rebuild) and respawns + * a dead stdio child — the same two families the ToolRouter retries + * (session expiry, dead-but-reconnectable transport). */ export async function fetchServiceTools( service: ServiceDefinition, @@ -949,7 +972,7 @@ export async function fetchServiceTools( try { return await fetchServiceToolsOnce(service, timeout); } catch (err) { - if (isSessionExpiryError(err)) { + if (isRetryableConnectionFailure(err)) { return await fetchServiceToolsOnce(service, timeout); } throw err; diff --git a/src/tui/input-text.ts b/src/tui/input-text.ts new file mode 100644 index 0000000..8ef0c05 --- /dev/null +++ b/src/tui/input-text.ts @@ -0,0 +1,36 @@ +/** + * Input-chunk classification for the TUI editors. + * + * Ink delivers a paste as ONE multi-character `input` string and a keystroke as + * a one-character string — they are indistinguishable except by length. Editors + * therefore have to accept chunks of any length; filtering on `length === 1` + * silently discards everything a user pastes. + */ + +/** True when every code point is printable text (no control bytes at all). */ +export function isPrintableChunk(text: string): boolean { + if (text.length === 0) { + return false; + } + for (const char of text) { + const code = char.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) { + return false; + } + } + return true; +} + +/** True when the chunk is editable text; newlines are text (multi-line paste). */ +export function isEditableChunk(text: string): boolean { + if (text.length === 0) { + return false; + } + for (const char of text) { + const code = char.codePointAt(0) ?? 0; + if (code !== 0x0a && (code < 0x20 || code === 0x7f)) { + return false; + } + } + return true; +} diff --git a/src/utils/safe-json.ts b/src/utils/safe-json.ts new file mode 100644 index 0000000..ca299bc --- /dev/null +++ b/src/utils/safe-json.ts @@ -0,0 +1,16 @@ +/** + * Stringify helpers for values of unknown shape (backend payloads, error data). + * + * `JSON.stringify` throws on circular structures and returns `undefined` for + * functions/symbols, so call sites that log or display such values need a + * total function instead of ad-hoc try/catch copies. + */ + +/** JSON.stringify that never throws and never yields `undefined`. */ +export function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } +} From a6e047093895c0003f692db51614c3959f505e87 Mon Sep 17 00:00:00 2001 From: kugouming Date: Sat, 12 Sep 2026 17:36:41 +0800 Subject: [PATCH 4/9] =?UTF-8?q?test(tui):=20tmux=20=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E7=9A=84=20TUI=20=E7=AB=AF=E5=88=B0=E7=AB=AF=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=EF=BC=88T1-T13=EF=BC=89=E4=B8=8E=E9=9B=86=E6=88=90/=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/tui-e2e.mjs(npm run verify:tui):tmux 驱动真实终端跑 T1-T13——列表一屏渲染、 窄终端降级、删除确认、重名覆盖确认、Ctrl+S 不污染、Ctrl+C 退出、参数粘贴运行、工具视图、 结果区操作、大输出分页、CJK 标签与参数值、配置路径与自写提示、结果存档。49 条断言 - 隔离要求:私有 socket、每场景 mkdtemp 配置目录(由 onemcp --init 生成后打补丁,不手写模板)、 不绑端口、不触碰 ~/.onemcp 与运行中的 daemon;缺 tmux 返回 2(不把「跳过」当「通过」) - 驱动细节:按键之间留 settle 间隔(慢 runner 上否则会打到旧焦点)、导航后等状态提交、 为 tmux 会话剥掉 CI 标记(Ink 的 is-in-ci 命中时只在退出时画最后一帧 → 全程空屏) - 新增集成测试:CJK 单行输入开窗/粘贴/组合键、传输死亡恢复(真实子进程首调中途死亡→重连重放)、 JSON 投影与非法 JSON 拒绝、按工具保留参数、存档的两种剪贴板结果 - 合并两个既有测试内联的 ANSI 终端仿真器为共享 helper;新增 flaky/tui mock fixture --- scripts/tui-e2e.mjs | 669 ++++++++++++++++++ .../integration/fixtures/flaky-stdio-mcp.cjs | 87 +++ tests/integration/fixtures/tui-mock-mcp.cjs | 125 ++++ tests/integration/helpers/ansi-terminal.ts | 182 +++++ .../tui-call-dead-transport-recovery.test.ts | 65 ++ .../tui-service-form-input.test.ts | 120 ++++ .../tui-service-list-layout.test.ts | 194 +++++ .../tui-service-tools-json-cache.test.ts | 183 +++++ .../integration/tui-service-tools-run.test.ts | 112 +-- .../tui-service-tools-scroll.test.ts | 106 +-- .../integration/tui-single-line-input.test.ts | 89 +++ tests/integration/tui-trigger-hints.test.ts | 84 +-- tests/unit/tui/input-text.test.ts | 40 ++ 13 files changed, 1757 insertions(+), 299 deletions(-) create mode 100644 scripts/tui-e2e.mjs create mode 100644 tests/integration/fixtures/flaky-stdio-mcp.cjs create mode 100644 tests/integration/fixtures/tui-mock-mcp.cjs create mode 100644 tests/integration/helpers/ansi-terminal.ts create mode 100644 tests/integration/tui-call-dead-transport-recovery.test.ts create mode 100644 tests/integration/tui-service-form-input.test.ts create mode 100644 tests/integration/tui-service-list-layout.test.ts create mode 100644 tests/integration/tui-service-tools-json-cache.test.ts create mode 100644 tests/integration/tui-single-line-input.test.ts create mode 100644 tests/unit/tui/input-text.test.ts diff --git a/scripts/tui-e2e.mjs b/scripts/tui-e2e.mjs new file mode 100644 index 0000000..26e02b5 --- /dev/null +++ b/scripts/tui-e2e.mjs @@ -0,0 +1,669 @@ +#!/usr/bin/env node +/** + * scripts/tui-e2e.mjs — TUI 端到端回归(真实终端) + * + * 为什么单独一个脚本:TUI 需要真实 PTY 才能验证「渲染结果」与「按键行为」, + * 不适用 e2e-local.mjs 的 HTTP/stdio 断言方式(见 CLAUDE.md「E2E 场景回归规则」)。 + * 这里用 tmux 充当真实终端:私有 socket 建会话、send-keys 驱动、capture-pane + * 读屏。tmux 本身就是终端模拟器,读到的画面即 ground truth —— 自研 ANSI 仿真 + * 会因为滚动语义差异产生假象(帧超高时尤其明显)。 + * + * 隔离保证(绝不触碰用户环境): + * - tmux 私有 socket(-L onemcp-tui-e2e),不影响默认 tmux server + * - 每场景独立 mkdtemp 配置目录,不读写 ~/.onemcp + * - 不占用任何端口,与 :5625 运行实例无关 + * + * 用例(T*): + * T1 列表一屏渲染:16 服务 @34 行 —— 每服务恰好一行、无换行续行、无游离字符 + * T2 窄终端降级:60 列 —— 丢 tags 列、端点省略号截断、仍每服务一行 + * T3 删除二次确认:d → 确认框 → n 取消 / y 才删除 + * T4 重名覆盖确认:同名保存 → 确认框 → n 取消且原配置(tags)完好 + * T5 Ctrl+S 不污染:空 Command 连按 3 次 → 字段无 's'、有错误提示、零落库 + * T6 Ctrl+C 退出:有服务配置时也能真正退出进程 + * T7 参数粘贴并运行:整块粘贴 → Ctrl+R → 回显结果 + * T8 工具视图:工具清单(含精确计数)+ 搜索粘贴过滤 + * T9 结果区操作:Ctrl+P 原始输出 / v 选行 + Ctrl+Y 复制选区 / f 全宽 + * T10 结果分页:大输出下 PageDown/PageUp 改变可见行区间 + * T11 CJK:中文服务名占一行 + 中文参数值运行后原样回显 + * T12 配置路径与自身写盘提示:footer 显示真实 configDir;保存后提示是成功而非"外部变更" + * T13 存档:Ctrl+O 生成临时文件并给出路径 + * + * 用法: + * npm run verify:tui [-- --keep] [--verbose] + * (需先 npm run build;脚本会断言 dist 比 src 新) + * + * 注:脚本会为 tmux 会话剥掉 CI / CONTINUOUS_INTEGRATION / CI_* 环境变量 —— Ink 检测到它们时 + * 只在退出时画最后一帧(is-in-ci),驱动会一直看到空屏。 + * + * 退出码:0 = 全部通过;1 = 有断言失败;2 = 环境不具备(未安装 tmux)—— + * 刻意用非 0,避免「跳过」被当成「通过」(CI 上尤其危险)。确需在无 tmux + * 环境跳过时显式传 --allow-skip。 + */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const CLI = path.join(ROOT, 'dist/cli.js'); +const MOCK_BACKEND = path.join(ROOT, 'tests/integration/fixtures/tui-mock-mcp.cjs'); +const SOCKET = 'onemcp-tui-e2e'; + +const args = process.argv.slice(2); +const KEEP = args.includes('--keep'); +const VERBOSE = args.includes('--verbose'); +const ALLOW_SKIP = args.includes('--allow-skip'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Focus markers the forms/panels render: waiting for them keeps typing steps +// deterministic on slow runners (a Tab/Enter that has not been committed yet +// would otherwise send the following text into the previous field). +const FOCUS_NAME = /▶ Service Name/; +const FOCUS_COMMAND = /▶ Command/; +const FOCUS_PARAM = /▶ 1\s/; + +/** + * Ink (via the `is-in-ci` helper) renders ONLY the final frame when it detects a + * CI environment — `CI` / `CONTINUOUS_INTEGRATION` / any `CI_*` variable. Driving + * the TUI then stares at a blank pane until the app exits, which is exactly what + * every scenario here waits on. The app under test is interactive, so the tmux + * server (and therefore its panes) is started with those markers stripped, + * matching a developer's terminal. Runs on CI runners included. + */ +const withoutCiMarkers = (env) => { + const cleaned = { ...env }; + for (const key of Object.keys(cleaned)) { + if (key === 'CI' || key === 'CONTINUOUS_INTEGRATION' || key.startsWith('CI_')) { + delete cleaned[key]; + } + } + return cleaned; +}; +const TMUX_ENV = withoutCiMarkers(process.env); +const CI_UNSET_FLAGS = ['CI', 'CONTINUOUS_INTEGRATION'] + .concat(Object.keys(process.env).filter((key) => key.startsWith('CI_'))) + .map((key) => `-u ${key}`) + .join(' '); + +// ---------------------------------------------------------------- tmux driver + +const tmuxRaw = (tmuxArgs) => + spawnSync('tmux', ['-L', SOCKET, ...tmuxArgs], { encoding: 'utf8', env: TMUX_ENV }); +const tmux = (tmuxArgs) => { + const res = tmuxRaw(tmuxArgs); + if (res.status !== 0 && VERBOSE) { + process.stderr.write(`[tmux] ${tmuxArgs.join(' ')} → ${res.stderr ?? ''}\n`); + } + return res; +}; + +const killServer = () => spawnSync('tmux', ['-L', SOCKET, 'kill-server'], { encoding: 'utf8', env: TMUX_ENV }); +const capture = () => tmuxRaw(['capture-pane', '-p', '-t', 'tui']).stdout ?? ''; +const sessionAlive = () => tmuxRaw(['has-session', '-t', 'tui']).status === 0; +/** + * Keys are sent one at a time with a settle delay: a slow runner needs the app + * to re-render between keystrokes, otherwise the next key lands on stale focus + * (e.g. Down/Down for navigation collapsing into one, or text going into the + * previously focused field). Override with TUI_E2E_KEY_DELAY (ms). + */ +const KEY_DELAY = Number(process.env['TUI_E2E_KEY_DELAY'] ?? 140); +const sendText = async (text) => { + tmux(['send-keys', '-t', 'tui', '-l', text]); + await sleep(KEY_DELAY); +}; +const sendKey = async (key) => { + tmux(['send-keys', '-t', 'tui', key]); + await sleep(KEY_DELAY); +}; + +async function waitFor(predicate, timeoutMs = 8000, intervalMs = 120) { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (predicate()) return true; + if (Date.now() > deadline) return false; + await sleep(intervalMs); + } +} + +/** Screen lines with trailing blank rows removed. */ +const screenLines = (text) => { + const lines = text.split('\n').map((l) => l.replace(/\s+$/, '')); + let last = lines.length - 1; + while (last >= 0 && lines[last] === '') last -= 1; + return lines.slice(0, last + 1); +}; + +/** The service-name cell of a rendered list row (status symbol + name). */ +const nameInRow = (line) => { + const match = /^\s*[+-]\s+(\S+)/.exec(line.slice(4, 29)); + return match?.[1] ?? ''; +}; + +// --------------------------------------------------------------- config files + +/** + * Config for one scenario. Delegates the shape to `onemcp --init` instead of + * duplicating the schema here — a hand-written template silently rots the day a + * new required field appears (which is exactly how `security.dataMasking` broke + * an earlier revision), and the app exits on a validation failure. + */ +function seedConfigDir(configDir, services) { + const init = spawnSync('node', [CLI, '--init', '--config-dir', configDir], { encoding: 'utf8' }); + if (init.status !== 0) { + throw new Error(`onemcp --init 失败:${init.stderr || init.stdout}`); + } + const configPath = path.join(configDir, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.mode = 'tui'; + config.logLevel = 'ERROR'; + config.mcpServers = services; + // Keep the run quiet and independent of the host: no health polling, no audit. + config.healthCheck = { ...config.healthCheck, enabled: false }; + config.audit = { ...config.audit, enabled: false }; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + return config; +} + +const readConfig = (configDir) => + JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'), 'utf8')); + +const stdioService = (name, extra = {}) => ({ + transport: 'stdio', + enabled: true, + tags: ['e2e', 'mock'], + command: `node ${MOCK_BACKEND}`, + connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, + ...extra, +}); + +/** Long http endpoints so the list actually has to truncate. */ +const httpService = (index) => ({ + transport: 'http', + enabled: false, + tags: ['tag' + index], + url: `https://svc-${index}.example.com/very/long/path/to/mcp/endpoint`, + connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, +}); + +// ------------------------------------------------------------------ harness + +let checks = 0; +let failures = 0; +const failedLabels = []; + +function check(label, ok, detail = '') { + checks += 1; + if (ok) { + process.stdout.write(` ✓ ${label}\n`); + } else { + failures += 1; + failedLabels.push(label); + process.stdout.write(` ✗ ${label}${detail ? ` — ${detail}` : ''}\n`); + } +} + +function dumpScreen(label, cols, rows) { + process.stdout.write(` --- 失败画面 (${label}, ${cols}x${rows}) ---\n`); + for (const line of capture().split('\n').slice(0, rows)) { + process.stdout.write(` |${line}|\n`); + } +} + +const tmpDirs = []; +function makeConfigDir(services) { + // Short base path on purpose: the TUI renders the config directory in its + // footer, and a long /var/folders path gets truncated before an exact-match + // assertion can see it. + const base = fs.existsSync('/tmp') ? '/tmp' : os.tmpdir(); + const dir = fs.mkdtempSync(path.join(base, 'onemcp-tui-e2e-')); + tmpDirs.push(dir); + seedConfigDir(dir, services); + return dir; +} + +async function startSession({ configDir, cols = 100, rows = 34, ready = /Enter Edit/ }) { + killServer(); + tmux([ + 'new-session', + '-d', + '-x', + String(cols), + '-y', + String(rows), + '-s', + 'tui', + '-c', + ROOT, + '-e', + `ONEMCP_CONFIG_DIR=${configDir}`, + '-e', + 'TERM=tmux-256color', + // Strip the CI markers in the command itself as well: filtering only the tmux + // client's env relies on how a given tmux version seeds its server/global + // environment (tmux 3.4 on CI did not honour it), while `env -u` is explicit. + `env ${CI_UNSET_FLAGS} node ${CLI} --mode tui`, + ]); + const ok = await waitFor(() => ready.test(capture()), 25_000); + if (!ok) { + dumpScreen('启动', cols, rows); + throw new Error('TUI 未在超时内就绪'); + } +} + +// ----------------------------------------------------------------- scenarios + +/** T1 — 一屏渲染不多不少:每个服务恰好一行(帧溢出会表现为续行/游离字符)。 */ +async function t1ListLayout() { + process.stdout.write('\n[T1] 列表一屏渲染(16 服务 @34 行 / @50 行)\n'); + const services = Object.fromEntries( + Array.from({ length: 16 }, (_, i) => [`svc-${i}`, httpService(i)]) + ); + const configDir = makeConfigDir(services); + const names = Object.keys(services); + + await startSession({ configDir, cols: 100, rows: 34 }); + const lines34 = screenLines(capture()); + check('34 行下 16 个服务全部渲染', names.every((n) => lines34.some((l) => nameInRow(l) === n))); + check( + '每个服务恰好占一行(无换行续行)', + names.every((n) => lines34.filter((l) => nameInRow(l) === n).length === 1) + ); + check('每行不超过终端宽度', lines34.every((l) => l.length <= 100)); + check('长端点被省略号截断而非折行', capture().includes('…')); + check( + '无游离字符残留(原帧溢出特征)', + !capture().includes('-http://') && !capture().includes('/https://'), + '出现 -http:// 或 /https://' + ); + check('画面不超出终端高度(写入行数 ≤ 行数上限)', lines34.length <= 34, `写入 ${lines34.length} 行`); + + // 高度变化不应该改变列表本身的渲染(终端余量只影响底部留白)。 + killServer(); + await startSession({ configDir, cols: 100, rows: 50 }); + const rows34 = lines34.filter((l) => nameInRow(l) !== ''); + const rows50 = screenLines(capture()).filter((l) => nameInRow(l) !== ''); + check( + '34 行与 50 行的服务行完全一致(渲染不再受终端高度影响)', + rows34.length === names.length && JSON.stringify(rows34) === JSON.stringify(rows50), + `34 行 ${rows34.length} 条 / 50 行 ${rows50.length} 条` + ); +} + +/** T2 — 窄终端降级:丢可选列,端点保持可读,仍每服务一行。 */ +async function t2NarrowTerminal() { + process.stdout.write('\n[T2] 窄终端降级(60 列)\n'); + const services = Object.fromEntries( + Array.from({ length: 8 }, (_, i) => [`svc-${i}`, httpService(i)]) + ); + const configDir = makeConfigDir(services); + const names = Object.keys(services); + + await startSession({ configDir, cols: 60, rows: 30 }); + const text = capture(); + const lines = screenLines(text); + check('仍每服务一行', names.every((n) => lines.filter((l) => nameInRow(l) === n).length === 1)); + check('丢弃 tags 列以换取端点宽度', !text.includes('[tag0]')); + check('端点仍可读(省略号截断)', text.includes('https://svc-0.example.c…') || text.includes('…')); + check('每行不超过 60 列', lines.every((l) => l.length <= 60)); +} + +/** T3 — 删除必须二次确认。 */ +async function t3DeleteConfirm() { + process.stdout.write('\n[T3] 删除二次确认\n'); + const configDir = makeConfigDir({ + 'keep-me': stdioService('keep-me'), + 'delete-me': stdioService('delete-me'), + }); + + await startSession({ configDir, cols: 100, rows: 40 }); + await sendKey('Down'); // 选中 delete-me + await waitFor(() => /▶\s+- delete-me/.test(capture())); + await sendText('d'); + const promptAppeared = await waitFor(() => capture().includes("Delete service 'delete-me'?")); + check('d 弹出确认框', promptAppeared); + check('确认前不删除(服务数不变)', capture().includes('2 Services')); + + await sendText('n'); + check('n 取消并保留服务', await waitFor(() => capture().includes('Cancelled'))); + check('取消后配置未变', readConfig(configDir).mcpServers['delete-me'] !== undefined); + + await sendText('d'); + await waitFor(() => capture().includes("Delete service 'delete-me'?")); + await sendText('y'); + const deleted = await waitFor(() => capture().includes("Service 'delete-me' deleted")); + check('y 才真正删除', deleted); + check( + '删除已写入配置', + readConfig(configDir).mcpServers['delete-me'] === undefined && + readConfig(configDir).mcpServers['keep-me'] !== undefined + ); +} + +/** T4 — 重名保存必须确认,且不静默覆盖既有配置。 */ +async function t4OverwriteConfirm() { + process.stdout.write('\n[T4] 重名覆盖确认\n'); + const configDir = makeConfigDir({ + existing: stdioService('existing', { tags: ['keep', 'this'] }), + }); + + await startSession({ configDir, cols: 100, rows: 40 }); + await sendText('a'); + await waitFor(() => FOCUS_NAME.test(capture())); + await sendText('existing'); // 整块输入 = 粘贴路径 + await sendKey('Tab'); + await sendKey('Enter'); + await waitFor(() => FOCUS_COMMAND.test(capture())); // 等焦点落到 Command 再输入 + await sendText(`node ${MOCK_BACKEND}`); + await sendKey('C-s'); + + const promptAppeared = await waitFor(() => capture().includes('already exists — overwrite it?')); + check('同名保存弹出覆盖确认', promptAppeared); + check('确认前不覆盖(tags 完好)', readConfig(configDir).mcpServers['existing'].tags.join() === 'keep,this'); + + await sendText('n'); + await waitFor(() => capture().includes('Cancelled')); + check('n 取消后配置未变', readConfig(configDir).mcpServers['existing'].tags.join() === 'keep,this'); +} + +/** T5 — Ctrl+S 不得把 's' 敲进字段,更不得据此落库。 */ +async function t5CtrlSDoesNotPollute() { + process.stdout.write('\n[T5] Ctrl+S 不污染输入 / 不误落库\n'); + const configDir = makeConfigDir({ demo: stdioService('demo') }); + + await startSession({ configDir, cols: 100, rows: 40 }); + await sendText('a'); + await waitFor(() => FOCUS_NAME.test(capture())); + await sendText('e2e-leak'); + await sendKey('Tab'); + await sendKey('Enter'); // stdio → Command 字段(留空) + await waitFor(() => FOCUS_COMMAND.test(capture())); + + for (let i = 0; i < 3; i += 1) { + await sendKey('C-s'); + await sleep(500); + } + const text = capture(); + check('字段未被追加 s', text.includes('e2e-leak') && !text.includes('e2e-leaks')); + check('校验失败有可见提示', text.includes('required')); + check('连按 Ctrl+S 不产生服务', readConfig(configDir).mcpServers['e2e-leak'] === undefined); + check('服务总数不变', Object.keys(readConfig(configDir).mcpServers).length === 1); +} + +/** T6 — Ctrl+C 必须真正结束进程(曾有:仅 unmount,进程被后台句柄挂住)。 */ +async function t6CtrlCExits() { + process.stdout.write('\n[T6] Ctrl+C 退出\n'); + const configDir = makeConfigDir({ demo: stdioService('demo') }); + + await startSession({ configDir, cols: 100, rows: 40 }); + check('会话已就绪', sessionAlive()); + await sendKey('C-c'); + const exited = await waitFor(() => !sessionAlive(), 6000); + check('Ctrl+C 结束 TUI 进程(会话消失)', exited); +} + +/** T7 — 粘贴(一次多字符输入事件)可用,并能运行工具拿到结果。 */ +async function t7PasteAndRun() { + process.stdout.write('\n[T7] 参数粘贴并运行\n'); + const configDir = makeConfigDir({ mock: stdioService('mock') }); + + await startSession({ configDir, cols: 100, rows: 45 }); + await sendText('v'); + const toolsLoaded = await waitFor(() => capture().includes('PARAMETERS')); + check('工具视图加载出参数区', toolsLoaded); + + await sendKey('Tab'); // → 参数区 + await waitFor(() => FOCUS_PARAM.test(capture())); + await sendText('hello'); // 整块写入,等价于粘贴 + await waitFor(() => capture().includes('hello')); + await sendKey('C-r'); + const ran = await waitFor(() => capture().includes('echo: "hello"'), 10_000); + check('整块粘贴的参数生效并运行成功', ran, capture().includes('required') ? '参数被丢弃' : ''); +} + +/** T8 — 工具清单与搜索(含整块粘贴过滤)。 */ +async function t8ToolsView() { + process.stdout.write('\n[T8] 工具视图与搜索\n'); + const configDir = makeConfigDir({ mock: stdioService('mock') }); + + await startSession({ configDir, cols: 100, rows: 45 }); + await sendText('v'); + const listed = await waitFor(() => capture().includes('echo') && capture().includes('fail')); + const text = capture(); + check('工具清单渲染出全部 4 个工具', listed); + check('显示发现到的工具总数(精确表头)', text.includes('4✓/0✗ of 4'), '未出现 4✓/0✗ of 4'); + + await sendText('/'); + await waitFor(() => capture().includes('Search:')); + await sendText('big'); // 整块写入 = 粘贴 + const filtered = await waitFor(() => capture().includes('Search: big') && capture().includes('1/4')); + check('搜索框接受粘贴并过滤', filtered); +} + +/** + * T9 — 结果区操作:原始输出、按行选择复制、全宽。 + */ +async function t9ResultActions() { + process.stdout.write('\n[T9] 结果区操作(Ctrl+P / v 选行复制 / f 全宽)\n'); + const configDir = makeConfigDir({ mock: stdioService('mock') }); + + await startSession({ configDir, cols: 100, rows: 45 }); + await sendText('v'); + await waitFor(() => capture().includes('PARAMETERS')); + await sendKey('Tab'); + await waitFor(() => FOCUS_PARAM.test(capture())); + await sendText('hi'); + await waitFor(() => capture().includes('hi')); + await sendKey('C-r'); + check('运行成功', await waitFor(() => capture().includes('echo: "hi"'), 10_000)); + + await sendKey('C-p'); // 原始输出 + check('Ctrl+P 切到原始 JSON 输出', await waitFor(() => capture().includes('"content"'), 4000)); + + await sendText('v'); // 从光标起选行 + await sendKey('Down'); + check('v + ↓ 进入选区(提示切到 Copy selection)', await waitFor(() => capture().includes('Copy selection'), 4000)); + await sendKey('C-y'); + check( + 'Ctrl+Y 复制选区(或明确提示无剪贴板工具)', + await waitFor(() => capture().includes('Copied') || capture().includes('No clipboard utility'), 5000) + ); + + await sendText('f'); + check('f 切到全宽(工具列表隐藏)', await waitFor(() => !capture().includes('mock__') || capture().includes('OUTPUT'), 4000)); +} + +/** + * T10 — 大输出的分页:PageDown/PageUp 必须改变可见行区间。 + */ +async function t10ResultPaging() { + process.stdout.write('\n[T10] 结果分页(大输出)\n'); + const configDir = makeConfigDir({ mock: stdioService('mock') }); + + await startSession({ configDir, cols: 100, rows: 45 }); + await sendText('v'); + await waitFor(() => capture().includes('PARAMETERS')); + await sendKey('Down'); + await sendKey('Down'); // → big_output + await waitFor(() => /▶\s+✓\s+big_output/.test(capture())); // 等选中项真的落到 big_output + await sendKey('C-r'); + check('大输出首行可见', await waitFor(() => capture().includes('[001]'), 10_000)); + + await sendKey('PageDown'); + await sendKey('PageDown'); + const paged = await waitFor(() => capture().includes('[02') || capture().includes('[03'), 4000); + check('PageDown 前进到后续行', paged); + await sendKey('PageUp'); + check('PageUp 回到前段', await waitFor(() => capture().includes('[0'), 4000)); +} + +/** + * T11 — CJK:中文标签在列表里仍占一行;中文参数值运行后原样回显。 + * + * 服务名按校验规则必须含 ASCII 字母数字(它要作为工具命名空间前缀),所以 + * 中文只出现在标签/参数值这类自由文本上 —— 这正是宽字符最容易出问题的地方。 + */ +async function t11Cjk() { + process.stdout.write('\n[T11] CJK 标签与参数值\n'); + const configDir = makeConfigDir({ + 'cjk-one': stdioService('cjk-one', { tags: ['中文标签', '宽字符'] }), + 'cjk-two': stdioService('cjk-two', { tags: ['中文标签'] }), + }); + + await startSession({ configDir, cols: 100, rows: 40 }); + const lines = screenLines(capture()); + // '[中文标签]' 挂在两个服务上,'[宽字符]' 只挂一个:宽字符标签必须完整落在 + // 同一行里(每个服务一行),不能因为宽度算错而折行或截半。 + const tagLines = (tag) => lines.filter((l) => l.includes(tag)).length; + check('[中文标签] 出现在两个服务所在的行', tagLines('[中文标签]') === 2, `出现在 ${tagLines('[中文标签]')} 行`); + check('[宽字符] 出现在一个服务所在的行', tagLines('[宽字符]') === 1, `出现在 ${tagLines('[宽字符]')} 行`); + check( + '每个服务恰好一行(宽字符未导致折行)', + lines.filter((l) => l.includes('cjk-one')).length === 1 && + lines.filter((l) => l.includes('cjk-two')).length === 1 && + lines.length <= 40, + `渲染 ${lines.length} 行` + ); + + await sendText('v'); + await waitFor(() => capture().includes('PARAMETERS')); + await sendKey('Tab'); + await waitFor(() => FOCUS_PARAM.test(capture())); + await sendText('你好世界'); // 整块写入(中文按显示宽度开窗) + await waitFor(() => capture().includes('你好世界')); + await sendKey('C-r'); + check('中文参数值运行后原样回显', await waitFor(() => capture().includes('echo: "你好世界"'), 10_000)); +} + +/** + * T12 — footer 显示真实 configDir;自身写盘的提示是成功而不是"外部变更"。 + */ +async function t12ConfigPathAndSelfWriteNotice() { + process.stdout.write('\n[T12] 配置路径与自身写盘提示\n'); + const configDir = makeConfigDir({ demo: stdioService('demo') }); + + await startSession({ configDir, cols: 100, rows: 45 }); + check('footer 显示真实配置目录', capture().includes(`Config: ${configDir}`)); + + await sendText('a'); + await waitFor(() => FOCUS_NAME.test(capture())); + await sendText('e2e-selfwrite'); + await sendKey('Tab'); + await sendKey('Enter'); + await waitFor(() => FOCUS_COMMAND.test(capture())); + await sendText(`node ${MOCK_BACKEND}`); + await sendKey('C-s'); + + const created = await waitFor(() => capture().includes("Service 'e2e-selfwrite' created successfully"), 6000); + check('保存后提示是创建成功', created); + check('不再出现误导性的「外部变更」提示', !capture().includes('external changes')); + check('新服务已写入配置', readConfig(configDir).mcpServers['e2e-selfwrite'] !== undefined); +} + +/** + * T13 — Ctrl+O 存档:生成临时文件并给出路径。 + */ +async function t13SaveOutput() { + process.stdout.write('\n[T13] 结果存档(Ctrl+O)\n'); + const configDir = makeConfigDir({ mock: stdioService('mock') }); + + await startSession({ configDir, cols: 100, rows: 45 }); + await sendText('v'); + await waitFor(() => capture().includes('PARAMETERS')); + await sendKey('Tab'); + await waitFor(() => FOCUS_PARAM.test(capture())); + await sendText('save-me'); + await waitFor(() => capture().includes('save-me')); + await sendKey('C-r'); + await waitFor(() => capture().includes('echo: "save-me"'), 10_000); + + await sendKey('C-o'); + check('Ctrl+O 提示已保存并给出路径', await waitFor(() => capture().includes('Saved full output:'), 5000)); + check( + '同时把完整路径交给剪贴板(无工具时明确说明)', + capture().includes('path copied to the clipboard') || capture().includes('clipboard unavailable') + ); +} + +// --------------------------------------------------------------------- main + +function assertFreshBuild() { + if (!fs.existsSync(CLI)) { + process.stderr.write('未找到 dist/cli.js —— 请先运行 npm run build\n'); + process.exit(1); + } + const stale = spawnSync('find', [path.join(ROOT, 'src'), '-newer', CLI, '-name', '*.ts*'], { + encoding: 'utf8', + }) + .stdout.trim() + .split('\n') + .filter(Boolean); + if (stale.length > 0) { + process.stderr.write( + `dist 比源码旧(例如 ${stale[0]})—— 请先运行 npm run build 再验证 TUI\n` + ); + process.exit(1); + } +} + +async function main() { + const tmuxVersion = spawnSync('tmux', ['-V'], { encoding: 'utf8' }); + if (tmuxVersion.status !== 0) { + process.stdout.write( + '跳过 TUI E2E:未安装 tmux(TUI 需要真实终端,无法在无 tmux 环境验证)' + + (ALLOW_SKIP ? '[--allow-skip,按通过处理]' : '[返回码 2,不视为通过]') + + '\n' + ); + return ALLOW_SKIP ? 0 : 2; + } + assertFreshBuild(); + process.stdout.write(`TUI E2E:${tmuxVersion.stdout.trim()} · 私有 socket ${SOCKET}\n`); + + const scenarios = [ + t1ListLayout, + t2NarrowTerminal, + t3DeleteConfirm, + t4OverwriteConfirm, + t5CtrlSDoesNotPollute, + t6CtrlCExits, + t7PasteAndRun, + t8ToolsView, + t9ResultActions, + t10ResultPaging, + t11Cjk, + t12ConfigPathAndSelfWriteNotice, + t13SaveOutput, + ]; + + for (const scenario of scenarios) { + try { + await scenario(); + } catch (error) { + check(`${scenario.name} 未抛异常`, false, error instanceof Error ? error.message : String(error)); + } finally { + if (!KEEP) killServer(); + } + } + + process.stdout.write( + `\n${failures === 0 ? 'TUI E2E PASSED' : 'TUI E2E FAILED'}(${checks - failures}/${checks} 项断言通过)\n` + ); + if (failures > 0) { + process.stdout.write(`失败项:${failedLabels.join(' / ')}\n`); + } + return failures === 0 ? 0 : 1; +} + +let exitCode = 1; +try { + exitCode = await main(); +} catch (error) { + process.stderr.write(`TUI E2E 异常终止:${error instanceof Error ? error.stack : String(error)}\n`); +} finally { + if (!KEEP) killServer(); + for (const dir of tmpDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} +process.exit(exitCode); diff --git a/tests/integration/fixtures/flaky-stdio-mcp.cjs b/tests/integration/fixtures/flaky-stdio-mcp.cjs new file mode 100644 index 0000000..f7745c1 --- /dev/null +++ b/tests/integration/fixtures/flaky-stdio-mcp.cjs @@ -0,0 +1,87 @@ +/** + * stdio MCP fixture whose FIRST run dies in the middle of `tools/call`. + * + * Liveness is tracked with a marker file (path in FLAKY_MARKER): absent → this + * process is the "first attempt", so it answers `initialize` and then exits + * without answering `tools/call` (a backend that died mid-call). Present → the + * process behaves normally. + * + * Used by tests/integration/tui-call-dead-transport-recovery.test.ts to prove + * the TUI call path retries a dead-but-reconnectable transport, the way the + * ToolRouter does. The delay before exiting lets the request reach the child. + */ +'use strict'; + +const fs = require('node:fs'); +const readline = require('node:readline'); + +const marker = process.env.FLAKY_MARKER; +const dieOnCall = marker !== undefined && !fs.existsSync(marker); +if (dieOnCall && marker !== undefined) { + fs.writeFileSync(marker, String(process.pid)); +} + +const send = (message) => process.stdout.write(JSON.stringify(message) + '\n'); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on('line', (line) => { + let request; + try { + request = JSON.parse(line); + } catch { + return; + } + if (!request || !request.method) return; + + if (request.method === 'initialize') { + send({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: '2024-11-05', + capabilities: {}, + serverInfo: { name: 'flaky-stdio-mcp', version: '1.0.0' }, + }, + }); + return; + } + if (request.method === 'notifications/initialized') return; + if (request.method === 'tools/list') { + send({ + jsonrpc: '2.0', + id: request.id, + result: { + tools: [ + { + name: 'echo', + description: 'Echo back the input text.', + inputSchema: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + }, + ], + }, + }); + return; + } + if (request.method === 'tools/call') { + if (dieOnCall) { + // Die mid-call: the caller must see a dead transport, not a refusal. + setTimeout(() => process.exit(0), 20); + return; + } + send({ + jsonrpc: '2.0', + id: request.id, + result: { content: [{ type: 'text', text: 'echo: ' + JSON.stringify(request.params?.arguments?.text) }] }, + }); + return; + } + if (request.id !== undefined && request.id !== null) { + send({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }); + } +}); + +process.stderr.on('error', () => {}); diff --git a/tests/integration/fixtures/tui-mock-mcp.cjs b/tests/integration/fixtures/tui-mock-mcp.cjs new file mode 100644 index 0000000..6a09089 --- /dev/null +++ b/tests/integration/fixtures/tui-mock-mcp.cjs @@ -0,0 +1,125 @@ +/** + * Mock stdio MCP backend for the TUI end-to-end script (scripts/tui-e2e.mjs). + * + * Intentionally separate from tests/integration/fixtures/mock-stdio-mcp.cjs: + * that fixture is asserted on by scripts/e2e-local.mjs (exact tool list), so it + * must stay as-is. This one is richer — parameters of every kind, a large + * payload, JSON output and a failing tool — so the TUI's tools view (parameter + * form, run, result panel, paging) can be exercised without a real backend. + * + * Speaks NDJSON over stdin/stdout (OneMCP's stdio transport framing): + * initialize / notifications/initialized / tools/list / tools/call + */ +'use strict'; + +const readline = require('readline'); + +const TOOLS = [ + { + name: 'echo', + description: 'Echo back the input text.', + inputSchema: { + type: 'object', + properties: { text: { type: 'string', description: 'Text to echo' } }, + required: ['text'], + }, + }, + { + name: 'add', + description: 'Add two numbers together.', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First addend' }, + b: { type: 'number', description: 'Second addend' }, + }, + required: ['a', 'b'], + }, + }, + { + name: 'big_output', + description: 'Returns a large text payload for paging and copy checks.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'fail', + description: 'Always returns an error result.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, +]; + +const text = (content) => ({ content: [{ type: 'text', text: content }] }); +const send = (message) => process.stdout.write(JSON.stringify(message) + '\n'); + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on('line', (line) => { + let request; + try { + request = JSON.parse(line); + } catch { + return; + } + if (!request || !request.method) return; + + switch (request.method) { + case 'initialize': + send({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: '2024-11-05', + capabilities: {}, + serverInfo: { name: 'tui-mock-mcp', version: '1.0.0' }, + }, + }); + break; + case 'notifications/initialized': + break; + case 'tools/list': + send({ jsonrpc: '2.0', id: request.id, result: { tools: TOOLS } }); + break; + case 'tools/call': { + const name = request.params && request.params.name; + const args = (request.params && request.params.arguments) || {}; + if (name === 'echo') { + send({ jsonrpc: '2.0', id: request.id, result: text('echo: ' + JSON.stringify(args.text)) }); + return; + } + if (name === 'add') { + const sum = Number(args.a) + Number(args.b); + send({ jsonrpc: '2.0', id: request.id, result: text('sum: ' + String(sum)) }); + return; + } + if (name === 'big_output') { + const lines = []; + for (let i = 1; i <= 120; i += 1) { + lines.push(`[${String(i).padStart(3, '0')}] line ${i} — the quick brown fox jumps over the lazy dog`); + } + send({ jsonrpc: '2.0', id: request.id, result: text(lines.join('\n')) }); + return; + } + if (name === 'fail') { + send({ + jsonrpc: '2.0', + id: request.id, + result: { content: [{ type: 'text', text: 'boom: simulated backend failure' }], isError: true }, + }); + return; + } + send({ + jsonrpc: '2.0', + id: request.id, + error: { code: -32602, message: `Unknown tool: ${String(name)}` }, + }); + break; + } + default: + if (request.id !== undefined && request.id !== null) { + send({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }); + } + break; + } +}); + +process.stderr.on('error', () => {}); diff --git a/tests/integration/helpers/ansi-terminal.ts b/tests/integration/helpers/ansi-terminal.ts new file mode 100644 index 0000000..0ea3f55 --- /dev/null +++ b/tests/integration/helpers/ansi-terminal.ts @@ -0,0 +1,182 @@ +/** + * Shared ANSI-terminal test harness for TUI integration tests. + * + * Ink writes escape sequences to its stdout; these helpers keep a small grid + * that mirrors what a real terminal would show, so tests can assert on the + * RENDERED screen instead of on raw escape codes. `maxRowWritten` is tracked + * separately: a frame that writes past the last row is exactly the overflow + * that corrupts a real terminal (it scrolls mid-frame and every later absolute + * cursor move lands on the wrong line). + */ + +import React from 'react'; +import { Readable } from 'stream'; +import { render } from 'ink'; + +export class Terminal { + grid: string[][]; + rows: number; + cols: number; + /** Highest 0-based row the renderer has written to. */ + maxRowWritten = 0; + 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): void { + 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.maxRowWritten = Math.max(this.maxRowWritten, 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.maxRowWritten = Math.max(this.maxRowWritten, this.r); + this.c++; + i++; + } else { + i++; + } + } + } + + text(): string { + return this.grid.map((row) => row.join('').replace(/\s+$/, '')).join('\n'); + } + + /** Rendered lines with trailing blank rows removed. */ + lines(): string[] { + const all = this.text().split('\n'); + let last = all.length - 1; + while (last >= 0 && all[last]!.trim() === '') last--; + return all.slice(0, last + 1); + } +} + +export const createStdin = (): any => { + const stdin: any = new Readable({ read() {} }); + stdin.isTTY = true; + stdin.setRawMode = () => {}; + stdin.ref = () => {}; + stdin.unref = () => {}; + return stdin; +}; + +export interface RenderOptions { + rows: number; + cols: number; +} + +/** Render an element against a fake TTY of the given size. */ +export function renderWithTerminal( + element: React.ReactElement, + { rows, cols }: RenderOptions +): { instance: ReturnType; term: Terminal; stdin: any } { + 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(element, { stdout, stdin, exitOnCtrlC: false }); + return { instance, term, stdin }; +} + +export const sleep = (ms: number): Promise => new Promise((res) => setTimeout(res, ms)); + +export 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(); +}; + +/** Type text one character at a time (how a human types). */ +export const typeKeys = async (stdin: any, chars: string, perKey = 40): Promise => { + for (const ch of chars) { + stdin.push(Buffer.from(ch, 'utf8')); + await new Promise((r) => setImmediate(r)); + await sleep(perKey); + } +}; + +/** Send a raw key sequence (escape codes, control chords). */ +export const pressKey = async (stdin: any, bytes: string): Promise => { + stdin.push(Buffer.from(bytes, 'utf8')); + await new Promise((r) => setImmediate(r)); + await sleep(80); +}; + +/** Push a chunk in one write — what a paste looks like to the app. */ +export const paste = async (stdin: any, text: string): Promise => { + stdin.push(Buffer.from(text, 'utf8')); + await new Promise((r) => setImmediate(r)); + await sleep(120); +}; diff --git a/tests/integration/tui-call-dead-transport-recovery.test.ts b/tests/integration/tui-call-dead-transport-recovery.test.ts new file mode 100644 index 0000000..4fbc4f6 --- /dev/null +++ b/tests/integration/tui-call-dead-transport-recovery.test.ts @@ -0,0 +1,65 @@ +/** + * Integration test: the TUI's tool-call path recovers from a backend that died + * mid-call. + * + * The ToolRouter retries two failure families on a fresh connection — an expired + * backend session and a dead-but-reconnectable transport (stdio child exit, SSE + * drop). The TUI worker used to retry only the session family, so running a tool + * against a crashed stdio backend failed in the tools view while the router + * would have respawned and replayed. This pins the aligned behaviour with a real + * subprocess: the fixture dies on its first `tools/call` and answers normally on + * the next attempt. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { callServiceTool } from '../../src/tui/discovery-worker.js'; +import type { ServiceDefinition } from '../../src/types/service.js'; + +const FIXTURE = fileURLToPath(new URL('./fixtures/flaky-stdio-mcp.cjs', import.meta.url)); + +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('callServiceTool transport recovery', () => { + it('retries on a fresh connection when the stdio backend dies mid-call', async () => { + const dir = mkdtempSync(join(tmpdir(), 'onemcp-flaky-')); + tempDirs.push(dir); + const marker = join(dir, 'first-attempt-done'); + + const service: ServiceDefinition = { + name: 'flaky', + transport: 'stdio', + enabled: true, + tags: [], + command: `node ${FIXTURE}`, + env: { FLAKY_MARKER: marker }, + connectionPool: { maxConnections: 1, idleTimeout: 60000, connectionTimeout: 10000 }, + }; + + const outcome = await callServiceTool(service, 'echo', { text: 'hi' }, 10_000); + + expect(outcome.isError).toBe(false); + expect(outcome.text).toContain('echo: "hi"'); + }, 20_000); + + it('still surfaces a non-recoverable failure instead of looping', async () => { + const service: ServiceDefinition = { + name: 'missing-command', + transport: 'stdio', + enabled: true, + tags: [], + command: 'node /nonexistent/definitely-not-here.cjs', + connectionPool: { maxConnections: 1, idleTimeout: 60000, connectionTimeout: 5000 }, + }; + + await expect(callServiceTool(service, 'echo', { text: 'hi' }, 5_000)).rejects.toThrow(); + }, 20_000); +}); diff --git a/tests/integration/tui-service-form-input.test.ts b/tests/integration/tui-service-form-input.test.ts new file mode 100644 index 0000000..d7f0c3f --- /dev/null +++ b/tests/integration/tui-service-form-input.test.ts @@ -0,0 +1,120 @@ +/** + * Integration tests for the unified service form's input handling. + * + * Regression guards: + * - Ctrl+S must not leak an 's' into the focused text field. It used to: the + * form handled the chord while ink-text-input also received the character, + * so repeated save attempts corrupted the field — a second Ctrl+S could + * then persist that stray character as the service command. + * - A refused save has to SAY why. Silently jumping focus looks like a dead + * save button when the invalid field is already focused. + * - A pasted chunk must land in the field (multi-character input events). + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { Box, useStdout } from 'ink'; +import { ServiceFormUnified } from '../../src/tui/components/ServiceFormUnified.js'; +import { renderWithTerminal, waitFor, typeKeys, pressKey, paste } from './helpers/ansi-terminal.js'; + +/** Mirrors app-optimized: a fixed-height column around the form. */ +const MiniForm: React.FC<{ onSubmit: (s: unknown) => void; terminalHeight: number }> = ({ + onSubmit, + terminalHeight, +}) => { + const { stdout } = useStdout(); + return React.createElement( + Box, + { flexDirection: 'column', height: stdout?.rows || 24 }, + React.createElement(ServiceFormUnified, { + onSubmit: onSubmit as never, + onCancel: () => {}, + terminalHeight, + }) + ); +}; + +describe('ServiceFormUnified input handling', () => { + it('does not type the Ctrl+S chord into the focused field', async () => { + const onSubmit = vi.fn(); + const { instance, term, stdin } = renderWithTerminal( + React.createElement(MiniForm, { onSubmit, terminalHeight: 29 }), + { rows: 34, cols: 100 } + ); + + await waitFor(() => term.text().includes('Service Name')); + await typeKeys(stdin, 'abc'); + await waitFor(() => term.text().includes('abc')); + + await pressKey(stdin, '\x13'); // Ctrl+S + // The save is refused because the (stdio) command is empty, and it says so + // instead of silently doing nothing. + await waitFor(() => term.text().includes('✗')); + expect(term.text()).toContain('required'); + expect(onSubmit).not.toHaveBeenCalled(); + + // Focus jumped to the offending field (name → transport → command), so + // step back twice and confirm the chord never inserted anything. + await pressKey(stdin, '\x1b[Z'); // Shift+Tab → transport + await pressKey(stdin, '\x1b[Z'); // Shift+Tab → name + await waitFor(() => term.text().includes('abc')); + expect(term.text()).toContain('abc'); + expect(term.text()).not.toContain('abcs'); + + instance.unmount(); + }); + + it('does not type the Ctrl+A chord into the focused field', async () => { + const { instance, term, stdin } = renderWithTerminal( + React.createElement(MiniForm, { onSubmit: vi.fn(), terminalHeight: 29 }), + { rows: 34, cols: 100 } + ); + + await waitFor(() => term.text().includes('Service Name')); + await typeKeys(stdin, 'abc'); + await waitFor(() => term.text().includes('abc')); + + await pressKey(stdin, '\x01'); // Ctrl+A → advanced options + await waitFor(() => term.text().includes('Max Connections')); + + expect(term.text()).not.toContain('abca'); + expect(term.text()).toContain('abc'); + + instance.unmount(); + }); + + it('accepts a pasted chunk in the focused field', async () => { + const { instance, term, stdin } = renderWithTerminal( + React.createElement(MiniForm, { onSubmit: vi.fn(), terminalHeight: 29 }), + { rows: 34, cols: 100 } + ); + + await waitFor(() => term.text().includes('Service Name')); + await paste(stdin, 'pasted-service-name'); + + await waitFor(() => term.text().includes('pasted-service-name')); + expect(term.text()).toContain('pasted-service-name'); + + instance.unmount(); + }); + + it('keeps the form inside the terminal when every field is visible', async () => { + const { instance, term, stdin } = renderWithTerminal( + React.createElement(MiniForm, { onSubmit: vi.fn(), terminalHeight: 29 }), + { rows: 34, cols: 100 } + ); + + await waitFor(() => term.text().includes('Service Name')); + await pressKey(stdin, '\x01'); // Ctrl+A → all advanced fields on + await waitFor(() => term.text().includes('Max Connections')); + // Walk every field so the render window follows the focus. + for (let i = 0; i < 14; i++) { + await pressKey(stdin, '\t'); + } + await waitFor(() => term.text().includes('Trigger')); + + expect(term.maxRowWritten).toBeLessThanOrEqual(33); + + instance.unmount(); + }); +}); diff --git a/tests/integration/tui-service-list-layout.test.ts b/tests/integration/tui-service-list-layout.test.ts new file mode 100644 index 0000000..4893005 --- /dev/null +++ b/tests/integration/tui-service-list-layout.test.ts @@ -0,0 +1,194 @@ +/** + * Integration tests for the service list layout. + * + * Regression guard for the frame-overflow bug: long endpoints used to wrap rows + * onto two or three lines, which pushed the rendered frame past the terminal + * height. A frame taller than the viewport makes a real terminal scroll + * mid-render, so every later absolute cursor move lands on the wrong row — + * visible as stray characters and dropped continuation lines. + * + * Invariants asserted here: + * - every service renders on exactly ONE line (no wrapping), + * - the frame never writes past the last terminal row, + * - narrow terminals degrade (tags/tool counts dropped, then the name column + * shrinks) instead of collapsing the endpoint to one character per line. + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { Box, useStdout } from 'ink'; +import { ServiceList, computeColumnLayout } from '../../src/tui/components/ServiceList.js'; +import type { ServiceDefinition } from '../../src/types/service.js'; +import { renderWithTerminal, waitFor } from './helpers/ansi-terminal.js'; + +const mkService = (name: string, url: string, tags: string[] = []): ServiceDefinition => ({ + name, + transport: 'http', + url, + enabled: true, + tags, + connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, +}); + +/** Sixteen services, most with endpoints far wider than their column. */ +const manyServices: ServiceDefinition[] = [ + mkService( + 'mock-stdio', + 'node /Users/someone/code/Github/BeCrafter/skill-mcp/dist/index.js serve', + ['audit', 'mock'] + ), + mkService('mock-broken', 'node /tmp/tui-audit/does-not-exist.cjs', ['audit']), + mkService('mock-http-remote', 'http://127.0.0.1:5999/mcp', ['audit', 'http']), + mkService('jymcp', 'https://app2.example.com/jyskills/jymcp', ['tal', 'jiaoyan']), + mkService('toolbox', 'npx -y @scope/npx-package-name@latest'), + mkService('kv-store', 'npx -y @scope/kv-store-package', ['kv', 'store']), + mkService('catalog', 'https://mcp.example.com/catalog/mcp'), + mkService('browser-bridge', 'npx -y bridge-package@latest'), + mkService('repo-docs', 'npx remote-helper https://docs.example.io/docs', ['docs', 'docsx']), + mkService('alpha', 'https://a.example.com/a/very/long/path/to/mcp/endpoint'), + mkService('beta', 'https://b.example.com/b/very/long/path/to/mcp/endpoint'), + mkService('gamma', 'https://c.example.com/c/very/long/path/to/mcp/endpoint'), + mkService('delta', 'https://d.example.com/d/very/long/path/to/mcp/endpoint'), + mkService('epsilon', 'https://e.example.com/e/very/long/path/to/mcp/endpoint'), + mkService('zeta', 'https://f.example.com/f/very/long/path/to/mcp/endpoint'), + mkService('eta', 'https://g.example.com/g/very/long/path/to/mcp/endpoint'), +]; + +/** Mirrors how app-optimized mounts the list: a fixed-height column. */ +const MiniList: React.FC<{ services: ServiceDefinition[]; height: number }> = ({ + services, + height, +}) => { + const { stdout } = useStdout(); + return React.createElement( + Box, + { flexDirection: 'column', height: stdout?.rows || 24 }, + React.createElement(ServiceList, { + services, + selectedIndex: 0, + onSelect: () => {}, + terminalHeight: height, + }) + ); +}; + +/** + * The service name cell (status symbol + name) of a rendered row, or '' when + * the line has no name there. Matching the CELL avoids false hits from names + * that are substrings of each other (eta ⊂ beta/zeta) or of an endpoint. + */ +const nameInRow = (line: string): string => { + const cell = line.slice(4, 29); + const match = /^\s*[+-]\s+(\S+)/.exec(cell); + return match?.[1] ?? ''; +}; + +describe('ServiceList layout', () => { + it('gives the endpoint column a sane budget at decreasing widths', () => { + const wide = computeColumnLayout(100); + expect(wide).toEqual({ + nameWidth: 25, + transportWidth: 8, + endpointWidth: 20, + tagWidth: 28, + toolWidth: 12, + }); + + // Too narrow for tags → the tag column is dropped, not squeezed to nothing. + const medium = computeColumnLayout(80); + expect(medium.tagWidth).toBeNull(); + expect(medium.toolWidth).toBe(12); + expect(medium.endpointWidth).toBe(28); + expect(medium.endpointWidth).toBeGreaterThanOrEqual(10); + + // Even narrower → tool counts go too. + const narrow = computeColumnLayout(60); + expect(narrow.tagWidth).toBeNull(); + expect(narrow.toolWidth).toBeNull(); + expect(narrow.endpointWidth).toBeGreaterThanOrEqual(10); + + // Very narrow → the name column shrinks before the endpoint is starved. + const tiny = computeColumnLayout(40); + expect(tiny.nameWidth).toBeLessThan(25); + expect(tiny.endpointWidth).toBeGreaterThanOrEqual(10); + }); + + it('renders one line per service and never overflows the terminal height', async () => { + const { instance, term } = renderWithTerminal( + React.createElement(MiniList, { services: manyServices, height: 29 }), + { rows: 34, cols: 100 } + ); + + await waitFor(() => term.text().includes('mock-stdio')); + + // header (1) + list box borders (2) + one line per service (16) + + // footer box (3) = 22. A wrapped row would make this count grow. + const lines = term.lines(); + expect(lines.length).toBe(22); + + // No service row may be followed by a wrapped continuation of itself. + for (const service of manyServices) { + const hits = lines.filter((l) => nameInRow(l) === service.name); + expect(hits, `service ${service.name} should occupy exactly one line`).toHaveLength(1); + } + + // Every line stays inside the terminal width. + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(100); + } + + // Long endpoints are elided rather than wrapped. + expect(term.text()).toContain('…'); + + // The frame must not write past the last row (that is what corrupts a real + // terminal: it scrolls and every later absolute write lands one row off). + expect(term.maxRowWritten).toBeLessThanOrEqual(33); + + instance.unmount(); + }); + + it('degrades at 60 columns instead of collapsing to one character per line', async () => { + const { instance, term } = renderWithTerminal( + React.createElement(MiniList, { services: manyServices, height: 29 }), + { rows: 34, cols: 60 } + ); + + await waitFor(() => term.text().includes('mock-stdio')); + + const lines = term.lines(); + // Still one line per service (22 lines) — the old layout wrapped every + // endpoint character onto its own row at this width. + expect(lines.length).toBe(22); + for (const service of manyServices) { + expect(lines.filter((l) => nameInRow(l) === service.name)).toHaveLength(1); + } + + // Tags are dropped to buy room for the endpoint, which stays readable. + expect(term.text()).not.toContain('[audit]'); + expect(term.text()).toContain('http'); + expect(term.maxRowWritten).toBeLessThanOrEqual(33); + + instance.unmount(); + }); + + it('paginates instead of overflowing when the list is taller than the view', async () => { + const services = Array.from({ length: 40 }, (_, i) => + mkService(`svc-${i}`, `http://127.0.0.1:${9000 + i}/mcp`) + ); + const { instance, term } = renderWithTerminal( + React.createElement(MiniList, { services, height: 14 }), + { rows: 20, cols: 100 } + ); + + await waitFor(() => term.text().includes('svc-0')); + + // height 14 - 5 chrome - 1 pager = 8 items per page → 40/8 = 5 pages. + const lines = term.lines(); + expect(lines.length).toBeLessThanOrEqual(20); + expect(lines.filter((l) => l.includes('svc-')).length).toBe(8); + expect(term.text()).toContain('1/5'); + expect(term.maxRowWritten).toBeLessThanOrEqual(19); + + instance.unmount(); + }); +}); diff --git a/tests/integration/tui-service-tools-json-cache.test.ts b/tests/integration/tui-service-tools-json-cache.test.ts new file mode 100644 index 0000000..7351380 --- /dev/null +++ b/tests/integration/tui-service-tools-json-cache.test.ts @@ -0,0 +1,183 @@ +/** + * Integration tests for the tools view's JSON editor and per-tool state. + * + * Guards behaviour the E2E scenarios do not reach: + * - Ctrl+J projects the typed parameters into the raw-JSON editor, and editing + * that JSON projects back into the form (plus extra-key count); + * - invalid JSON is refused with a visible error and no backend call; + * - each tool keeps its own typed arguments when you switch away and back. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { Box, useStdout } from 'ink'; +import { ServiceTools } from '../../src/tui/components/ServiceTools.js'; +import type { Tool } from '../../src/types/tool.js'; +import type { ServiceDefinition } from '../../src/types/service.js'; +import { renderWithTerminal, waitFor, pressKey, typeKeys } from './helpers/ansi-terminal.js'; + +const { fetchServiceToolsMock, callServiceToolMock } = vi.hoisted(() => { + const mk = (name: string): Tool => ({ + name, + namespacedName: `demo__${name}`, + serviceName: 'demo', + description: `mock tool ${name}`, + inputSchema: { + type: 'object' as const, + properties: { + q: { type: 'string', description: 'query text' }, + verbose: { type: 'boolean', description: 'chatty output' }, + }, + required: ['q'], + }, + enabled: true, + }); + return { + fetchServiceToolsMock: vi.fn(() => Promise.resolve([mk('alpha'), mk('bravo')])), + callServiceToolMock: vi.fn(() => + Promise.resolve({ + isError: false, + text: '{"ok":true}', + formatted: '{\n "ok": true\n}', + nonTextTypes: [], + raw: '{\n "content": []\n}', + }) + ), + }; +}); + +vi.mock('../../src/tui/discovery-worker.js', () => ({ + __esModule: true, + fetchServiceTools: fetchServiceToolsMock, + callServiceTool: callServiceToolMock, + ToolCallError: class ToolCallError extends Error {}, + DiscoveryError: class DiscoveryError extends Error {}, + DiscoveryErrorType: { TIMEOUT: 'timeout', CONNECTION_FAILED: 'connection_failed' }, + default: fetchServiceToolsMock, +})); + +const { copyToClipboardMock } = vi.hoisted(() => ({ + copyToClipboardMock: vi.fn((_text: string) => true), +})); +vi.mock('../../src/tui/clipboard.js', () => ({ + __esModule: true, + copyToClipboard: copyToClipboardMock, +})); + +const MiniApp: React.FC = () => { + const { stdout } = useStdout(); + const service: ServiceDefinition = { + name: 'demo', + transport: 'http', + url: 'http://127.0.0.1:1/mcp', + enabled: true, + tags: [], + connectionPool: { maxConnections: 1, idleTimeout: 60000, connectionTimeout: 10000 }, + }; + return React.createElement( + Box, + { flexDirection: 'column', height: stdout?.rows || 40 }, + React.createElement(ServiceTools, { + service, + onBack: () => {}, + onToggleTool: () => {}, + toolStates: {}, + terminalHeight: stdout?.rows || 40, + }) + ); +}; + +const renderTools = () => renderWithTerminal(React.createElement(MiniApp), { rows: 40, cols: 110 }); + +describe('ServiceTools JSON editor and per-tool state', () => { + beforeEach(() => { + fetchServiceToolsMock.mockClear(); + callServiceToolMock.mockClear(); + }); + + it('projects typed parameters into the JSON editor and back', async () => { + const { instance, term, stdin } = renderTools(); + await waitFor(() => term.text().includes('PARAMETERS (2)')); + + await pressKey(stdin, '\t'); // list → params + await typeKeys(stdin, 'hello'); + await waitFor(() => term.text().includes('hello')); + + await pressKey(stdin, '\n'); // Ctrl+J → raw JSON + await waitFor(() => term.text().includes('Arguments (raw JSON):')); + expect(term.text()).toContain('"q": "hello"'); + expect(term.text()).not.toContain('"verbose"'); // unset optionals stay out + + await pressKey(stdin, '\n'); // Ctrl+J → back to the form + await waitFor(() => term.text().includes('PARAMETERS (2)')); + expect(term.text()).toContain('hello'); + + instance.unmount(); + }); + + it('refuses to run on invalid JSON and says why', async () => { + const { instance, term, stdin } = renderTools(); + await waitFor(() => term.text().includes('PARAMETERS (2)')); + + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\n'); // Ctrl+J → raw JSON + await waitFor(() => term.text().includes('Arguments (raw JSON):')); + + for (let i = 0; i < 30; i += 1) { + await pressKey(stdin, '\x7f'); // backspace the projected JSON away + } + await typeKeys(stdin, '{not json'); + await pressKey(stdin, '\x12'); // Ctrl+R + + await waitFor(() => term.text().includes('✗ JSON:')); + expect(callServiceToolMock).not.toHaveBeenCalled(); + + instance.unmount(); + }); + + it('archives the raw result and says whether the path reached the clipboard', async () => { + const { instance, term, stdin } = renderTools(); + await waitFor(() => term.text().includes('PARAMETERS (2)')); + + await pressKey(stdin, '\t'); + await typeKeys(stdin, 'x'); + await pressKey(stdin, '\x12'); // Ctrl+R + await waitFor(() => term.text().includes('Result: ✓')); + + copyToClipboardMock.mockImplementation(() => true); + await pressKey(stdin, '\x0f'); // Ctrl+O + await waitFor(() => term.text().includes('Saved full output:')); + expect(term.text()).toContain('path copied to the clipboard'); + + // Without a clipboard utility the file is still saved — and that has to be + // said, instead of a bare "saved" that hides the failed copy. + copyToClipboardMock.mockImplementation(() => false); + await pressKey(stdin, '\x0f'); + await waitFor(() => term.text().includes('clipboard unavailable')); + + instance.unmount(); + }); + + it('keeps each tool’s typed arguments when switching away and back', async () => { + const { instance, term, stdin } = renderTools(); + await waitFor(() => term.text().includes('alpha')); + + await pressKey(stdin, '\t'); // list → params + await typeKeys(stdin, 'kept-value'); + await waitFor(() => term.text().includes('kept-value')); + + await pressKey(stdin, '\x1b'); // Esc → back to the list region + await pressKey(stdin, '\x1b[B'); // ↓ → bravo + await waitFor(() => term.text().includes('mock tool bravo')); + await pressKey(stdin, '\x1b[A'); // ↑ → alpha + await waitFor(() => term.text().includes('mock tool alpha')); + + await pressKey(stdin, '\t'); // params again + await pressKey(stdin, '\n'); // Ctrl+J: the JSON projection shows what was kept + await waitFor(() => term.text().includes('Arguments (raw JSON):')); + expect(term.text()).toContain('"q": "kept-value"'); + + instance.unmount(); + }); +}); diff --git a/tests/integration/tui-service-tools-run.test.ts b/tests/integration/tui-service-tools-run.test.ts index 9e44880..907668f 100644 --- a/tests/integration/tui-service-tools-run.test.ts +++ b/tests/integration/tui-service-tools-run.test.ts @@ -7,12 +7,12 @@ */ import { describe, it, expect, vi, beforeEach } 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 type { ToolCallOutcome } from '../../src/tui/discovery-worker.js'; import type { Tool } from '../../src/types/tool.js'; import type { ServiceDefinition } from '../../src/types/service.js'; +import { Terminal, createStdin, waitFor, typeKeys, pressKey } from './helpers/ansi-terminal.js'; const { tools, fetchServiceToolsMock, callServiceToolMock, mockOutcome } = vi.hoisted(() => { const tools: Tool[] = [ @@ -75,93 +75,6 @@ vi.mock('../../src/tui/clipboard.js', () => ({ copyToClipboard: copyToClipboardMock, })); -// Minimal ANSI terminal emulator (same harness as tui-service-tools-scroll) -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; -}; - const MiniApp: React.FC<{ rows: number; onBack?: (() => void) | undefined }> = ({ rows, onBack, @@ -218,29 +131,6 @@ function renderApp(rows: number, cols: number, onBack?: () => void) { return { instance, term, stdin, stdout }; } -const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); -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(); -}; -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); - } -}; -const pressKey = async (stdin: any, bytes: string) => { - stdin.push(Buffer.from(bytes, 'utf8')); - await new Promise((r) => setImmediate(r)); - await sleep(80); -}; - describe('ServiceTools flattened detail panel', () => { beforeEach(() => { fetchServiceToolsMock.mockReset(); diff --git a/tests/integration/tui-service-tools-scroll.test.ts b/tests/integration/tui-service-tools-scroll.test.ts index be5d770..e51981e 100644 --- a/tests/integration/tui-service-tools-scroll.test.ts +++ b/tests/integration/tui-service-tools-scroll.test.ts @@ -7,11 +7,11 @@ */ 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'; +import { Terminal, createStdin, sleep, waitFor, typeKeys } from './helpers/ansi-terminal.js'; // Hoisted so the mock factory can reference it and tests can assert that the // mock (not a real connection attempt) drove the render. @@ -38,92 +38,6 @@ vi.mock('../../src/tui/discovery-worker.js', () => ({ })); // 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(); @@ -198,28 +112,10 @@ function renderApp(rows: number, cols: number) { 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); diff --git a/tests/integration/tui-single-line-input.test.ts b/tests/integration/tui-single-line-input.test.ts new file mode 100644 index 0000000..c993c15 --- /dev/null +++ b/tests/integration/tui-single-line-input.test.ts @@ -0,0 +1,89 @@ +/** + * Integration tests for the single-line TUI editor. + * + * Regression guards: + * - the cursor window is measured in display CELLS, so a CJK value cannot + * overflow the one row the component promises (it used to slice the value + * by UTF-16 units, which mis-windowed wide characters); + * - a pasted chunk lands whole; + * - control chords never insert their letter. + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { Box } from 'ink'; +import { SingleLineInput } from '../../src/tui/components/SingleLineInput.js'; +import { displayWidth } from '../../src/tui/text-layout.js'; +import { renderWithTerminal, waitFor, paste, pressKey, typeKeys } from './helpers/ansi-terminal.js'; + +const renderInput = (value: string, width: number | undefined, onChange = vi.fn()) => { + const element = React.createElement( + Box, + { flexDirection: 'column' }, + React.createElement(SingleLineInput, { + value, + onChange, + ...(width === undefined ? {} : { width }), + }) + ); + return { ...renderWithTerminal(element, { rows: 6, cols: 40 }), onChange }; +}; + +describe('SingleLineInput windowing', () => { + it('keeps a CJK value inside its box on a single row', async () => { + const { instance, term } = renderInput('中文测试值很长了', 10); + + await waitFor(() => term.text().trim() !== ''); + const lines = term.lines(); + + expect(lines).toHaveLength(1); + expect(displayWidth(lines[0] ?? '')).toBeLessThanOrEqual(10); + // The tail is what a cursor-at-end window shows, with the clipped side marked. + expect(term.text()).toContain('…'); + + instance.unmount(); + }); + + it('does not wrap an ASCII value either', async () => { + const { instance, term } = renderInput('abcdefghijklmnop', 8); + + await waitFor(() => term.text().trim() !== ''); + const lines = term.lines(); + expect(lines).toHaveLength(1); + expect(displayWidth(lines[0] ?? '')).toBeLessThanOrEqual(8); + + instance.unmount(); + }); + + it('accepts a pasted chunk whole', async () => { + const onChange = vi.fn(); + const { instance, term, stdin } = renderInput('', 20, onChange); + + await waitFor(() => term.text().trim() !== ''); + await paste(stdin, '粘贴进来的中文'); + + await waitFor(() => onChange.mock.calls.length > 0); + expect(onChange).toHaveBeenLastCalledWith('粘贴进来的中文'); + + instance.unmount(); + }); + + it('ignores control chords instead of typing their letter', async () => { + const onChange = vi.fn(); + const { instance, stdin } = renderInput('ab', 20, onChange); + + await pressKey(stdin, '\x13'); // Ctrl+S + await pressKey(stdin, '\x01'); // Ctrl+A + await typeKeys(stdin, 'c'); + + await waitFor(() => onChange.mock.calls.length > 0); + const values = onChange.mock.calls.map((c) => c[0] as string); + // Ctrl+S / Ctrl+A must leave no trace: 'ab' + a leaked letter would show up + // as 'abs' / 'aba' before the real 'c' keystroke. + expect(values).not.toContain('abs'); + expect(values).not.toContain('aba'); + expect(values[values.length - 1]).toBe('abc'); + + instance.unmount(); + }); +}); diff --git a/tests/integration/tui-trigger-hints.test.ts b/tests/integration/tui-trigger-hints.test.ts index f17a04a..d7d5f9c 100644 --- a/tests/integration/tui-trigger-hints.test.ts +++ b/tests/integration/tui-trigger-hints.test.ts @@ -1,6 +1,5 @@ /** - * Integration tests for triggerHints support across the three TUI input paths: - * - ServiceJsonEditor (validateJson) — mcpServers map and array branches + * Integration tests for triggerHints support across the TUI form input paths: * - ServiceFormUnified (formDataToService) * - ServiceForm (formDataToService) * @@ -10,7 +9,6 @@ */ import { describe, it, expect } from 'vitest'; -import { validateJson, type ValidationResult } from '../../src/tui/components/ServiceJsonEditor.js'; import { formDataToService as unifiedFormDataToService, type FormData as UnifiedFormData, @@ -20,13 +18,6 @@ import { type FormData as LegacyFormData, } from '../../src/tui/components/ServiceForm.js'; -function expectValid(result: ValidationResult): NonNullable { - if (!result.valid || !result.services) { - throw new Error(`expected valid result, got: ${JSON.stringify(result)}`); - } - return result.services; -} - const baseUnifiedForm: UnifiedFormData = { name: 'svc', transport: 'stdio', @@ -63,79 +54,6 @@ const baseLegacyForm: LegacyFormData = { triggerHintsPhrases: '', }; -describe('ServiceJsonEditor.validateJson — triggerHints passthrough', () => { - it('preserves triggerHints in mcpServers map format', () => { - const json = JSON.stringify({ - prompx: { - transport: 'http', - url: 'http://127.0.0.1:5203/mcp', - triggerHints: { - onSessionStart: 'recall role memory', - onSessionEnd: 'persist new memory', - phrases: ['我是X', 'switch role'], - }, - }, - }); - - const services = expectValid(validateJson(json)); - expect(services).toHaveLength(1); - expect(services[0]?.triggerHints).toEqual({ - onSessionStart: 'recall role memory', - onSessionEnd: 'persist new memory', - phrases: ['我是X', 'switch role'], - }); - }); - - it('omits triggerHints when not present (no empty object injected)', () => { - const json = JSON.stringify({ - plain: { transport: 'stdio', command: 'node' }, - }); - const services = expectValid(validateJson(json)); - expect(services[0]?.triggerHints).toBeUndefined(); - }); - - it('rejects malformed triggerHints (array) by ignoring it, not crashing', () => { - const json = JSON.stringify({ - bad: { transport: 'stdio', command: 'node', triggerHints: ['not', 'an', 'object'] }, - }); - const services = expectValid(validateJson(json)); - expect(services[0]?.triggerHints).toBeUndefined(); - }); - - it('preserves triggerHints in single-service object format', () => { - const json = JSON.stringify({ - name: 'prompx', - transport: 'http', - url: 'http://127.0.0.1:5203/mcp', - enabled: true, - tags: [], - connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, - triggerHints: { onSessionStart: 'recall', phrases: ['我是X'] }, - }); - const services = expectValid(validateJson(json)); - expect(services[0]?.triggerHints).toEqual({ - onSessionStart: 'recall', - phrases: ['我是X'], - }); - }); - - it('preserves triggerHints inside an array of services', () => { - const json = JSON.stringify([ - { - name: 'prompx', - transport: 'http', - url: 'http://127.0.0.1:5203/mcp', - enabled: true, - tags: [], - connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, - triggerHints: { onSessionEnd: 'remember' }, - }, - ]); - const services = expectValid(validateJson(json)); - expect(services[0]?.triggerHints).toEqual({ onSessionEnd: 'remember' }); - }); -}); - describe('ServiceFormUnified.formDataToService — triggerHints assembly', () => { it('assembles full triggerHints when all three fields are filled', () => { const svc = unifiedFormDataToService({ diff --git a/tests/unit/tui/input-text.test.ts b/tests/unit/tui/input-text.test.ts new file mode 100644 index 0000000..ccf542b --- /dev/null +++ b/tests/unit/tui/input-text.test.ts @@ -0,0 +1,40 @@ +/** + * Unit tests for the input-chunk classification shared by TUI editors. + * + * Ink hands a paste over as ONE multi-character `input` string, so editors that + * filter on `length === 1` silently drop pasted text. + */ + +import { describe, it, expect } from 'vitest'; +import { isPrintableChunk, isEditableChunk } from '../../../src/tui/input-text.js'; + +describe('isPrintableChunk', () => { + it('accepts single keystrokes and pasted chunks', () => { + expect(isPrintableChunk('a')).toBe(true); + expect(isPrintableChunk('hello world')).toBe(true); + expect(isPrintableChunk('{"text":"hi"}')).toBe(true); + expect(isPrintableChunk('中文-ok')).toBe(true); + }); + + it('rejects key actions and empty input', () => { + expect(isPrintableChunk('')).toBe(false); + expect(isPrintableChunk('\n')).toBe(false); + expect(isPrintableChunk('\r\n')).toBe(false); + expect(isPrintableChunk('\t')).toBe(false); + expect(isPrintableChunk('\x7f')).toBe(false); + expect(isPrintableChunk('ok\nmore')).toBe(false); + }); +}); + +describe('isEditableChunk', () => { + it('accepts multi-line text (a pasted JSON document)', () => { + expect(isEditableChunk('{\n "a": 1\n}')).toBe(true); + expect(isEditableChunk('plain')).toBe(true); + }); + + it('still rejects non-text control bytes', () => { + expect(isEditableChunk('')).toBe(false); + expect(isEditableChunk('\x03')).toBe(false); + expect(isEditableChunk('ok\x7f')).toBe(false); + }); +}); From 50d0a179ec4a1a6df1bcc61f60eec78125437eab Mon Sep 17 00:00:00 2001 From: kugouming Date: Sat, 12 Sep 2026 17:37:40 +0800 Subject: [PATCH 5/9] =?UTF-8?q?chore(ci):=20.tsx=20=E7=BA=B3=E5=85=A5=20li?= =?UTF-8?q?nt/format=EF=BC=8C=E6=96=B0=E5=A2=9E=20TUI=20E2E=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=EF=BC=8C=E5=B9=B6=E5=90=8C=E6=AD=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lint/lint:fix 改为 --ext .ts,.tsx,format/format:check 覆盖 src/**/*.tsx:TUI 全是 .tsx, 此前完全不在 lint 范围(已积累 76 个错误,其中 38 个在死组件里),新代码可以带 any、 漏处理 promise 而无人发现 - CI 新增 tui-e2e 任务(ubuntu + tmux + build + verify:tui);此前 CLAUDE.md 声称 「场景退出码供 CI 使用」,但 CI 从未跑过任何 E2E - 文档:CLAUDE.md 新增「TUI 场景回归规则」与场景清单(T1-T13)、验证链与 CI 门禁说明、 本机 launchd 守护注记、目录结构;README 补充 TUI 端到端小节与部署托管说明; docs/TUI_JSON_MODE.md 标注其描述的独立 JSON 视图已不再挂载(当前为工具详情页 Ctrl+J) --- .github/workflows/ci.yml | 28 ++++++++++++++++++ CLAUDE.md | 63 ++++++++++++++++++++++++++++++++++++---- README.md | 36 ++++++++++++++++++++++- docs/TUI_JSON_MODE.md | 7 +++++ package.json | 9 +++--- 5 files changed, 133 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc7c367..b5df2b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,34 @@ jobs: path: dist/ retention-days: 1 + # ── TUI end-to-end (needs a real terminal: tmux drives a private pty) ── + tui-e2e: + name: TUI E2E + needs: build + # Runs headless: scripts/tui-e2e.mjs strips CI / CONTINUOUS_INTEGRATION / CI_* + # from the tmux session env, because Ink (is-in-ci) paints only the FINAL + # frame in CI mode — without that, every scenario would watch a blank pane. + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Install tmux + run: sudo apt-get update && sudo apt-get install -y tmux + + - name: Build + run: npm run build + + - name: Run TUI end-to-end scenarios + run: npm run verify:tui + env: + # CI runners are slower to re-render between keystrokes than a local + # machine; scripts/tui-e2e.mjs reads this settle delay. + TUI_E2E_KEY_DELAY: '250' + # ── Cross-platform unit & property tests ───────────────────────────── test: name: Test (${{ matrix.os }}, Node ${{ matrix.node }}) diff --git a/CLAUDE.md b/CLAUDE.md index 499292b..68d876d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,14 +15,21 @@ | `npm run test:watch` | Watch mode tests | | `npm run test:coverage` | Coverage report (thresholds: 80% lines/fn/stmt, 75% branches) | | `npm run test:property` | Property-based tests (fast-check) | -| `npm run deploy:local` | 编译 → npm 打包真实 tarball → 全局安装(完整替代旧 onemcp 命令)→ 重启 ~/.onemcp daemon → 冒烟 | -| `npm run verify:local` | 端到端回归:重新编译安装后,以独立实例(随机端口)跑全部场景 case | +| `npm run deploy:local` | 编译 → npm 打包真实 tarball → 全局安装(完整替代旧 onemcp 命令)→ 重启 daemon(launchd 守护时自动走 `launchctl kickstart`)→ 就绪冒烟 | +| `npm run verify:local` | 端到端回归:重新编译安装后,以独立实例(随机端口)跑全部场景 case(N*/F*) | +| `npm run verify:tui` | TUI 端到端回归:tmux 驱动真实终端跑交互场景(T*),不动 daemon/用户配置 | | `npm run lint` / `lint:fix` | ESLint | | `npm run format` / `format:check` | Prettier | | `npm run typecheck` | TypeScript check only | | `npx vitest run ` | Single test file | | `npx vitest run -t ""` | Single test by name | +> **本机注记(daemon 守护方式)**::5625 由 launchd 守护 `site.iskill.onemcp` +> (plist `~/Library/LaunchAgents/site.iskill.onemcp.plist`,`RunAtLoad` + `KeepAlive`)直接运行**全局安装的 `onemcp`**, +> 因此 `deploy:local` 装的本地构建只要重启守护即可生效(脚本会自动检测并用 `launchctl kickstart -k` 重启)。 +> 这类守护进程**不写** `~/.onemcp/server.pid`,手动切版用 +> `launchctl kickstart -k gui/$(id -u)/site.iskill.onemcp`;plist 备份见同目录 `*.bak-*`。 + --- ## E2E 场景回归规则(必须遵守) @@ -37,7 +44,14 @@ 修复类问题放入 `F*`(故障恢复),新功能/正常操作放入 `N*`(正常场景),编号顺延。 确保每个问题都能在端到端层面复现与验证,迭代过程始终可用全局 case 回归。 2. **每次修改代码后的标准验证链**(全绿才算完成): - `npm test` → `npm run deploy:local` → `npm run verify:local` + `npm test` → `npm run build && npm run verify:tui`(改动涉及 TUI 时)→ + `npm run deploy:local` → `npm run verify:local` + - CI(`.github/workflows/ci.yml`)跑 `lint` / `typecheck` / `build` / 单测 / 集成测试 / coverage + 以及 **TUI E2E**(tui-e2e 任务,`verify:tui`); + `verify:local` 需要全局安装,仍是本地门禁,CI 不跑。 + - 驱动脚本会为 tmux 会话**剥掉 `CI` / `CONTINUOUS_INTEGRATION` / `CI_*`**(`withoutCiMarkers`): + Ink 用 `is-in-ci` 判断 CI 环境,命中时只在**退出时**绘制最后一帧,驱动会全程看到空屏 + (这是排查「CI 里 TUI 未就绪」类问题的第一嫌疑人,而不是应用代码)。 3. **场景编写约定**: - 随机空闲端口 + `mkdtemp` 独立临时配置,绝不触碰 :5625 运行实例 - mock 后端自带请求级日志与 `/__stats`、`/__expire`(HTTP 过期触发)控制端点; @@ -60,8 +74,46 @@ - **F2** HTTP 后端规范型会话过期(HTTP 404)→ 透明重建 - **F3** stdio 后端进程崩溃 → 自动 respawn 重放 - **F4** 前端客户端会话句柄失效 → 重启实例后旧 Mcp-Session-Id 透明重建 -- TUI:交互式界面需 PTY,不纳入脚本;其恢复逻辑由 - `tests/integration/discovery-worker-session-expiry.test.ts` 覆盖 +- TUI:交互式界面需 PTY,由 **`scripts/tui-e2e.mjs`**(`npm run verify:tui`)覆盖, + 见下「TUI 场景回归规则」;组件级行为另有 `tests/integration/tui-*.test.ts` + +--- + +## TUI 场景回归规则(必须遵守) + +TUI 交互场景统一维护在 **`scripts/tui-e2e.mjs`**(`npm run verify:tui`),用 tmux 充当真实终端: +私有 socket 建会话、`send-keys` 驱动按键、`capture-pane` 读屏。**必须用真实终端驱动**—— +自研 ANSI 仿真在帧高溢出等场景下的滚动语义与真实终端不一致,会产生假象。 + +### 规则 + +1. **改动 TUI 行为(按键、布局、渲染)时,必须同步在 `scripts/tui-e2e.mjs` 增加/更新 T* 场景**, + 编号顺延;纯组件逻辑另加 `tests/integration/tui-*.test.ts`。 +2. **隔离是硬性要求**: + - tmux 私有 socket(`-L onemcp-tui-e2e`),不触碰用户默认 tmux server + - 每场景 `mkdtemp` 独立配置目录,绝不读写 `~/.onemcp`;配置由 `onemcp --init` 生成后打补丁 + (不要手写配置模板:schema 新增必填字段时会静默失效,应用会因校验失败直接退出) + - 不绑定端口,与运行中的 daemon 无关 +3. **断言要锁行为而不是锁像素**:相对式判断(如「每个服务恰好占一行」「无 `-http://` 游离字符」 + 「确认前服务数不变」),失败时转储当前画面。退出码:0 通过 / 1 断言失败 / 2 环境不具备(未装 tmux, + 需显式 `--allow-skip` 才会按通过处理)——「跳过」不允许伪装成「通过」。 +4. **文档同步**:新增/调整场景后同步更新 README「本地部署与端到端验证」的 TUI 场景清单。 + +### 当前 TUI 场景清单(以 scripts/tui-e2e.mjs 为准) + +- **T1** 列表一屏渲染(16 服务 @34 行 / @50 行):每服务恰好一行、无续行、无游离字符、不超帧高 +- **T2** 窄终端降级(60 列):丢弃 tags 列、端点省略号截断、仍每服务一行 +- **T3** 删除二次确认:`d` 弹确认 → `n` 取消(配置未变)→ `d`+`y` 才删除并落盘 +- **T4** 重名覆盖确认:同名保存弹确认,`n` 取消且原 tags 完好(不静默覆盖) +- **T5** Ctrl+S 不污染:空 Command 连按 3 次 → 字段无 `s`、有错误提示、零落库 +- **T6** Ctrl+C 退出:有服务配置时进程也能真正结束 +- **T7** 参数粘贴并运行:整块粘贴(一次多字符输入)生效并运行成功 +- **T8** 工具视图:工具清单(精确计数)+ 搜索框粘贴过滤 +- **T9** 结果区操作:Ctrl+P 原始输出 / `v` 选行 + Ctrl+Y 复制选区 / `f` 全宽 +- **T10** 结果分页:大输出下 PageDown/PageUp 改变可见行区间 +- **T11** CJK:中文标签在列表里每服务一行;中文参数值运行后原样回显 +- **T12** 配置路径与自身写盘提示:footer 显示真实 configDir;保存后提示是成功而非「外部变更」 +- **T13** 结果存档:Ctrl+O 生成临时文件、给出路径并复制完整路径 --- @@ -270,6 +322,7 @@ src/ scripts/ ├── deploy-local.mjs # npm run deploy:local ├── e2e-local.mjs # npm run verify:local(E2E 场景 case 维护在此) +├── tui-e2e.mjs # npm run verify:tui(TUI 场景 case 维护在此,tmux 驱动) └── lib/install-local.mjs # 共享"编译→打包→安装"管道 ``` diff --git a/README.md b/README.md index 7d3aba6..ef79c43 100644 --- a/README.md +++ b/README.md @@ -620,6 +620,10 @@ npm run clean npm run deploy:local [-- --port 5625 --log-level INFO] ``` +重启步骤会自动识别 daemon 的托管方式:若端口由 **launchd 等外部守护**托管(这类 daemon 不写 pidfile), +脚本改用守护自身的重启命令(macOS 上为 `launchctl kickstart -k gui// - ↑/↓ - In a region: move the param / result cursor + ↑/↓ - Navigate tools; with a description expanded, scroll it ←/→ - Page the panel (also PgUp/PgDn, Ctrl+U/D) @@ -149,7 +149,8 @@ export const HelpDialog: React.FC = ({ onClose }) => { Ctrl+R - Run the selected tool - Ctrl+E - Expand / collapse the full tool description + Ctrl+E - Expand / collapse the tool description (then ↑/↓ + scroll it) Ctrl+J - Toggle form / raw JSON arguments diff --git a/src/tui/components/ServiceTools.tsx b/src/tui/components/ServiceTools.tsx index b93765d..7f463ab 100644 --- a/src/tui/components/ServiceTools.tsx +++ b/src/tui/components/ServiceTools.tsx @@ -95,15 +95,15 @@ const DESCRIPTION_INDENT = ' '; * align with content — a lone dim glyph at the panel's left edge reads as a * rendering artifact rather than a control. */ -const scrollHint = (offset: number, max: number): string => { - const arrows: string[] = []; +const scrollHint = (offset: number, max: number, pair: 'vertical' | 'horizontal'): string => { + const marks: string[] = []; if (offset > 0) { - arrows.push('↑'); + marks.push(pair === 'vertical' ? '↑' : '←'); } if (offset < max) { - arrows.push('↓'); + marks.push(pair === 'vertical' ? '↓' : '→'); } - return arrows.length > 0 ? ` ${arrows.join('|')} more` : ' '; + return marks.length > 0 ? ` ${marks.join('|')} more` : ' '; }; /** Map a segment tone to ink Text props (colors are a pure enhancement; the @@ -404,6 +404,14 @@ export const ServiceTools: React.FC = ({ setFieldIndex(next); }; + /** Scroll the panel by a single line, clamped to the scrollable range. */ + const scrollPanel = (direction: 1 | -1): void => { + setPanelScroll((prev) => { + const clamped = Math.min(prev, maxPanelScroll); + return Math.max(0, Math.min(maxPanelScroll, clamped + direction)); + }); + }; + /** Page the panel by one viewport, clamped to the scrollable range. */ const pagePanel = (direction: 1 | -1): void => { setPanelScroll((prev) => { @@ -649,7 +657,13 @@ export const ServiceTools: React.FC = ({ return; } if (input === 'e' && key.ctrl) { - setDescExpanded((prev) => !prev); + const expanding = !descExpanded; + setDescExpanded(expanding); + if (expanding) { + // Expanded in order to READ it: put the beginning of the description on + // screen, and let ↑/↓ scroll from there. + setPanelScroll(0); + } return; } if (input === 'p' && key.ctrl && runStatus === 'done' && outcome !== null) { @@ -820,9 +834,17 @@ export const ServiceTools: React.FC = ({ return; } if (key.upArrow) { - setSelectedIndex((prev) => Math.max(0, prev - 1)); + if (arrowKeysScrollPanel && Math.min(panelScroll, maxPanelScroll) > 0) { + scrollPanel(-1); + } else { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } } else if (key.downArrow) { - setSelectedIndex((prev) => Math.max(0, Math.min(filteredTools.length - 1, prev + 1))); + if (arrowKeysScrollPanel && Math.min(panelScroll, maxPanelScroll) < maxPanelScroll) { + scrollPanel(1); + } else { + setSelectedIndex((prev) => Math.max(0, Math.min(filteredTools.length - 1, prev + 1))); + } } else if (key.leftArrow) { pagePanel(-1); } else if (key.rightArrow) { @@ -1046,6 +1068,19 @@ export const ServiceTools: React.FC = ({ const FLOW_VISIBLE = Math.max(1, PANEL_LINES - 1); // reserve the indicator row const maxPanelScroll = Math.max(0, flowRows.length - FLOW_VISIBLE); + + /** + * With a description expanded (and actually overflowing) the arrow keys scroll + * the panel instead of moving the tool selection: the user pressed Ctrl+E to + * read, and the panel's own `↑|↓ more` hint promises exactly that. Reaching + * either end lets the next press fall through to tool navigation, and picking + * another tool collapses the description (per-tool reset), so this never + * becomes a sticky mode. + */ + const arrowKeysScrollPanel = focus === 'list' && descExpanded && maxPanelScroll > 0; + /** Which arrow pair the panel's scroll hint should name for the current focus. */ + const scrollHintPair: 'vertical' | 'horizontal' = + focus === 'list' && !arrowKeysScrollPanel ? 'horizontal' : 'vertical'; // Clamp at render time — PANEL_LINES shrinks while a status message is // visible, so an effect-based clamp would leave a blank panel for ~2s. const clampedPanelScroll = Math.min(panelScroll, maxPanelScroll); @@ -1388,7 +1423,9 @@ export const ServiceTools: React.FC = ({ .map((row, i) => renderDetailRow(row, `row-${clampedPanelScroll + i}-${row.type}`) )} - {scrollHint(clampedPanelScroll, maxPanelScroll)} + + {scrollHint(clampedPanelScroll, maxPanelScroll, scrollHintPair)} + )} @@ -1414,7 +1451,9 @@ export const ServiceTools: React.FC = ({ } • f Full width` : focus === 'json' ? 'Edit raw JSON arguments' - : '↑/↓ Navigate • Space Toggle • a/A All on/off • / Search'} + : arrowKeysScrollPanel + ? '↑/↓ Scroll description • Space Toggle • / Search' + : '↑/↓ Navigate • Space Toggle • a/A All on/off • / Search'} {' '} @@ -1427,9 +1466,11 @@ export const ServiceTools: React.FC = ({ ? selectAnchor !== null ? 'Ctrl+Y Copy selection • Esc Cancel' : 'Ctrl+Y Copy result • Ctrl+P Raw • Ctrl+O Save' - : `←/→ Page • Tab Region • Ctrl+R Run • f Full width • Ctrl+E ${ - descExpanded ? 'Collapse' : 'Expand' - } desc`)} + : arrowKeysScrollPanel + ? '↑/↓ Scroll • ←/→ Page • Ctrl+E Collapse • Esc Done' + : `←/→ Page • Tab Region • Ctrl+R Run • f Full width • Ctrl+E ${ + descExpanded ? 'Collapse' : 'Expand' + } desc`)} diff --git a/tests/integration/fixtures/tui-mock-mcp.cjs b/tests/integration/fixtures/tui-mock-mcp.cjs index 6a09089..60a0505 100644 --- a/tests/integration/fixtures/tui-mock-mcp.cjs +++ b/tests/integration/fixtures/tui-mock-mcp.cjs @@ -38,7 +38,15 @@ const TOOLS = [ }, { name: 'big_output', - description: 'Returns a large text payload for paging and copy checks.', + // Deliberately long: the TUI expands it with Ctrl+E and then scrolls it with + // the arrow keys (scenario T14), which needs more lines than the panel shows. + description: [ + 'Returns a large text payload for paging and copy checks.', + ...Array.from( + { length: 24 }, + (_, i) => `desc-line-${String(i).padStart(2, '0')} — filler line for the description-scroll scenario` + ), + ].join('\n'), inputSchema: { type: 'object', properties: {}, required: [] }, }, { diff --git a/tests/integration/tui-service-tools-scroll.test.ts b/tests/integration/tui-service-tools-scroll.test.ts index e51981e..1b0d843 100644 --- a/tests/integration/tui-service-tools-scroll.test.ts +++ b/tests/integration/tui-service-tools-scroll.test.ts @@ -11,7 +11,14 @@ 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'; -import { Terminal, createStdin, sleep, waitFor, typeKeys } from './helpers/ansi-terminal.js'; +import { + Terminal, + createStdin, + sleep, + waitFor, + typeKeys, + pressKey, +} from './helpers/ansi-terminal.js'; // Hoisted so the mock factory can reference it and tests can assert that the // mock (not a real connection attempt) drove the render. @@ -286,4 +293,47 @@ describe('ServiceTools scroll indicator (real components, optimized chrome)', () instance.unmount(); }); + + it('scrolls an expanded description with ↑/↓, falls through to the next tool at the end', async () => { + const longDesc = Array.from( + { length: 80 }, + (_, i) => `desc-line-${String(i).padStart(2, '0')}` + ).join('\n'); + fetchServiceToolsMock.mockImplementation(() => + Promise.resolve([ + { name: 'alpha', description: longDesc, inputSchema: { type: 'object', properties: {} } }, + { + name: 'bravo', + description: 'bravo short description', + inputSchema: { type: 'object', properties: {} }, + }, + ]) + ); + + const { instance, term, stdin } = renderApp(24, 80); + await waitFor(() => term.text().includes('desc-line-00')); + + // 展开:立刻回到描述开头(展开是为了从头读) + await pressKey(stdin, '\x05'); // Ctrl+E + await waitFor(() => !term.text().includes('Ctrl+E expands')); + expect(term.text()).toContain('desc-line-00'); + + // 展开期间 ↑/↓ 逐行滚动,而不再切换工具(关键判别:选中项必须没变) + await pressKey(stdin, '\x1b[B'); // ↓ + await waitFor(() => !term.text().includes('desc-line-00')); + expect(term.text()).not.toContain('bravo short description'); + + // 折叠后再展开,仍回到顶部 + await pressKey(stdin, '\x05'); // Ctrl+E → 收起 + await pressKey(stdin, '\x05'); // Ctrl+E → 再展开 + await waitFor(() => term.text().includes('desc-line-00')); + + // 一直按到低:滚到尽头后继续 ↓ 才把选中项交给下一个工具 + for (let i = 0; i < 120 && !term.text().includes('bravo short description'); i += 1) { + await pressKey(stdin, '\x1b[B'); + } + await waitFor(() => term.text().includes('bravo short description')); + + instance.unmount(); + }); }); From 9bc6e775c2a7c422b1f896681acbabb038a92914 Mon Sep 17 00:00:00 2001 From: kugouming Date: Sun, 13 Sep 2026 00:14:55 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat(tui):=20=E5=8C=BA=E5=9F=9F=E7=84=A6?= =?UTF-8?q?=E7=82=B9=E5=8F=AF=E8=A7=86=E5=8C=96=20+=20=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E4=B8=A5=E6=A0=BC=E6=8C=89=E5=8C=BA=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 描述区成为固定焦点区:Tab 循环为 列表 → 描述 → 参数(跑出结果后加入结果区), Ctrl+E 展开且内容放不下时焦点自动进入,Esc 从该区只回工具列表; 工具列表的 ↑/↓ 恢复「切换工具」本职,切工具不必先折叠描述 - 区域焦点只改颜色、不加字形:聚焦时整行标题(▌ + 标题 + 横线)cyan+bold, 未聚焦为灰竖线 + 默认色文字;工具列表用面板标题 Tools for: … 的亮青/灰表示焦点。 原实现只把单个 ▌ 格子染成「终端默认色」,与旁边永远默认色的标题同色 = 没有高亮 - 底部提示严格按区域(Quick Actions — <区域>),只宣传此刻真能用的键; 瞬时通知改占标题行,不再顶掉提示(页脚预算 3 行不变) - 顺带修:JSON 区 Tab 被吞(提示却写 Tab Next region)、Tab 从 JSON 跳错区域、 描述被截断时提示「Nothing to scroll」自相矛盾 --- CLAUDE.md | 5 +- README.md | 3 +- scripts/tui-e2e.mjs | 284 +++++++++++++++--- src/tui/components/HelpDialog.tsx | 12 +- src/tui/components/ServiceTools.tsx | 251 +++++++++++----- .../tui-service-tools-focus.test.ts | 245 +++++++++++++++ .../tui-service-tools-json-cache.test.ts | 19 +- .../integration/tui-service-tools-run.test.ts | 53 ++-- .../tui-service-tools-scroll.test.ts | 24 +- 9 files changed, 733 insertions(+), 163 deletions(-) create mode 100644 tests/integration/tui-service-tools-focus.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index cd070f1..0056554 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,8 @@ TUI 交互场景统一维护在 **`scripts/tui-e2e.mjs`**(`npm run verify:tui` - **T11** CJK:中文标签在列表里每服务一行;中文参数值运行后原样回显 - **T12** 配置路径与自身写盘提示:footer 显示真实 configDir;保存后提示是成功而非「外部变更」 - **T13** 结果存档:Ctrl+O 生成临时文件、给出路径并复制完整路径 -- **T14** 长描述滚动:Ctrl+E 展开后 ↑/↓ 逐行滚动描述(不再切换工具),滚到尽头后继续 ↓ 才切换到下一个工具 +- **T14** 长描述滚动:Ctrl+E 展开后焦点进入描述区,↑/↓ 逐行滚动(选中项不变);Esc 回到工具列表后 ↑/↓ 立刻切换工具(无需先折叠描述) +- **T15** 区域焦点与提示:标题字形恒定(无焦点箭头),焦点靠颜色 —— `-e` 抓屏断言聚焦区标题文字与竖线同色、且与未聚焦区不同;底部提示只讲当前区域;运行后结果区加入循环;瞬时通知不顶掉提示行 --- @@ -164,6 +165,8 @@ TUI 交互场景统一维护在 **`scripts/tui-e2e.mjs`**(`npm run verify:tui` **Configuration Hot-Reload**: Config file changes are detected and services are reloaded without restarting the entire system. +**TUI Region Focus**: Focus is carried by colour, and the colour must change the WHOLE heading — bar, label and rule together go cyan+bold, idle headings keep the grey bar and plain label. Changing only the bar cell is invisible: the "focused" colour was the terminal default, i.e. exactly the colour of the always-default label beside it. Keep heading rows glyph-identical across focus states (no marker arrows), and keep the panel title (`Tools for: …`) as the tool list's cue (cyan focused / grey idle). Neither test harness sees colour, so focus is asserted through `capture-pane -e` SGR comparison in T15 plus the per-region footer label. Each region also owns its bottom hints (`Quick Actions — `); a hint that names another region's key, or a key that cannot work in the current state, is a bug. The footer budget is 3 lines, so a transient notice (copy/save feedback) takes the heading slot instead of adding a line. + ## Configuration Structure Config files live in `~/.onemcp/` (or custom `--config-dir`): diff --git a/README.md b/README.md index 4b6db8e..6920544 100644 --- a/README.md +++ b/README.md @@ -674,7 +674,8 @@ CI 的 `tui-e2e` 任务也在 CI 上运行同一套场景。注意脚本会为 t - **T11 CJK**:中文标签在列表里每服务一行;中文参数值运行后原样回显 - **T12 配置路径与自身写盘提示**:footer 显示真实配置目录;保存后提示是成功而非「外部变更」 - **T13 结果存档**:Ctrl+O 生成临时文件、给出路径并复制完整路径 -- **T14 长描述滚动**:Ctrl+E 展开后 `↑/↓` 逐行滚动描述(不再切换工具),滚到尽头后继续按才切换工具 +- **T14 长描述滚动**:Ctrl+E 展开后焦点进入描述区,`↑/↓` 逐行滚动(选中项不变);`Esc` 回到工具列表后 `↑/↓` 立刻切换工具(无需先折叠描述) +- **T15 区域焦点与提示**:标题字形恒定(无焦点箭头),焦点靠颜色——`-e` 抓屏断言聚焦区**标题文字**与竖线同色、且与未聚焦区不同;底部提示只讲当前区域;运行后结果区加入循环;瞬时通知不顶掉提示行 组件级 TUI 行为(渲染细节、按键分发)另有 `tests/integration/tui-*.test.ts`(伪终端 harness)覆盖。 diff --git a/scripts/tui-e2e.mjs b/scripts/tui-e2e.mjs index a50132d..f045584 100644 --- a/scripts/tui-e2e.mjs +++ b/scripts/tui-e2e.mjs @@ -27,7 +27,11 @@ * T11 CJK:中文服务名占一行 + 中文参数值运行后原样回显 * T12 配置路径与自身写盘提示:footer 显示真实 configDir;保存后提示是成功而非"外部变更" * T13 存档:Ctrl+O 生成临时文件并给出路径 - * T14 长描述滚动:Ctrl+E 展开后 ↑/↓ 逐行滚动(不再切换工具),滚到尽头才切换到下一个工具 + * T14 长描述滚动:Ctrl+E 展开后焦点进入描述区,↑/↓ 逐行滚动(选中项不变); + * Esc 回到工具列表后 ↑/↓ 立刻切换工具(无需先折叠描述) + * T15 区域焦点可见 + 提示按区域:标题字形恒定(无焦点箭头),焦点靠颜色 —— + * `-e` 抓屏断言聚焦区的**标题文字**与竖线同色、且与未聚焦区不同; + * 提示只讲当前区域;通知不顶掉提示 * * 用法: * npm run verify:tui [-- --keep] [--verbose] @@ -65,6 +69,18 @@ const FOCUS_NAME = /▶ Service Name/; const FOCUS_COMMAND = /▶ Command/; const FOCUS_PARAM = /▶ 1\s/; +/** + * 工具视图里把焦点挪到参数区。Tab 循环是「列表 → 描述 → 参数」,从列表出发是两站、 + * 从描述区出发是一站,所以按「按到参数区出现为止」处理,而不是固定按几次 —— + * 按多了会绕回列表,随后的输入会被列表吃掉(`t` 甚至会切换工具启用状态)。 + */ +const tabToFirstParam = async () => { + for (let i = 0; i < 3 && !FOCUS_PARAM.test(capture()); i += 1) { + await sendKey('Tab'); + } + await waitFor(() => FOCUS_PARAM.test(capture())); +}; + /** * Ink (via the `is-in-ci` helper) renders ONLY the final frame when it detects a * CI environment — `CI` / `CONTINUOUS_INTEGRATION` / any `CI_*` variable. Driving @@ -100,8 +116,24 @@ const tmux = (tmuxArgs) => { return res; }; -const killServer = () => spawnSync('tmux', ['-L', SOCKET, 'kill-server'], { encoding: 'utf8', env: TMUX_ENV }); +const killServer = () => + spawnSync('tmux', ['-L', SOCKET, 'kill-server'], { encoding: 'utf8', env: TMUX_ENV }); const capture = () => tmuxRaw(['capture-pane', '-p', '-t', 'tui']).stdout ?? ''; +/** Same frame, but keeping SGR colour codes (`-e`) — the only way to see colour. */ +const captureColored = () => tmuxRaw(['capture-pane', '-e', '-p', '-t', 'tui']).stdout ?? ''; +/** + * The SGR code that applies to the text immediately before `needle`, on the + * screen line containing `label`. Returns 'none' when that text inherits the + * terminal default colour, or null when label/needle are not on screen. + */ +const sgrBefore = (colored, label, needle) => { + const line = colored.split('\n').find((l) => l.includes(label)); + if (line === undefined) return null; + const at = line.indexOf(needle); + if (at < 0) return null; + const codes = line.slice(0, at).match(/\x1b\[[0-9;]*m/g); + return codes === null ? 'none' : codes[codes.length - 1]; +}; const sessionAlive = () => tmuxRaw(['has-session', '-t', 'tui']).status === 0; /** * Keys are sent one at a time with a settle delay: a slow runner needs the app @@ -266,19 +298,29 @@ async function t1ListLayout() { await startSession({ configDir, cols: 100, rows: 34 }); const lines34 = screenLines(capture()); - check('34 行下 16 个服务全部渲染', names.every((n) => lines34.some((l) => nameInRow(l) === n))); + check( + '34 行下 16 个服务全部渲染', + names.every((n) => lines34.some((l) => nameInRow(l) === n)) + ); check( '每个服务恰好占一行(无换行续行)', names.every((n) => lines34.filter((l) => nameInRow(l) === n).length === 1) ); - check('每行不超过终端宽度', lines34.every((l) => l.length <= 100)); + check( + '每行不超过终端宽度', + lines34.every((l) => l.length <= 100) + ); check('长端点被省略号截断而非折行', capture().includes('…')); check( '无游离字符残留(原帧溢出特征)', !capture().includes('-http://') && !capture().includes('/https://'), '出现 -http:// 或 /https://' ); - check('画面不超出终端高度(写入行数 ≤ 行数上限)', lines34.length <= 34, `写入 ${lines34.length} 行`); + check( + '画面不超出终端高度(写入行数 ≤ 行数上限)', + lines34.length <= 34, + `写入 ${lines34.length} 行` + ); // 高度变化不应该改变列表本身的渲染(终端余量只影响底部留白)。 killServer(); @@ -304,10 +346,19 @@ async function t2NarrowTerminal() { await startSession({ configDir, cols: 60, rows: 30 }); const text = capture(); const lines = screenLines(text); - check('仍每服务一行', names.every((n) => lines.filter((l) => nameInRow(l) === n).length === 1)); + check( + '仍每服务一行', + names.every((n) => lines.filter((l) => nameInRow(l) === n).length === 1) + ); check('丢弃 tags 列以换取端点宽度', !text.includes('[tag0]')); - check('端点仍可读(省略号截断)', text.includes('https://svc-0.example.c…') || text.includes('…')); - check('每行不超过 60 列', lines.every((l) => l.length <= 60)); + check( + '端点仍可读(省略号截断)', + text.includes('https://svc-0.example.c…') || text.includes('…') + ); + check( + '每行不超过 60 列', + lines.every((l) => l.length <= 60) + ); } /** T3 — 删除必须二次确认。 */ @@ -361,11 +412,17 @@ async function t4OverwriteConfirm() { const promptAppeared = await waitFor(() => capture().includes('already exists — overwrite it?')); check('同名保存弹出覆盖确认', promptAppeared); - check('确认前不覆盖(tags 完好)', readConfig(configDir).mcpServers['existing'].tags.join() === 'keep,this'); + check( + '确认前不覆盖(tags 完好)', + readConfig(configDir).mcpServers['existing'].tags.join() === 'keep,this' + ); await sendText('n'); await waitFor(() => capture().includes('Cancelled')); - check('n 取消后配置未变', readConfig(configDir).mcpServers['existing'].tags.join() === 'keep,this'); + check( + 'n 取消后配置未变', + readConfig(configDir).mcpServers['existing'].tags.join() === 'keep,this' + ); } /** T5 — Ctrl+S 不得把 's' 敲进字段,更不得据此落库。 */ @@ -414,8 +471,7 @@ async function t7PasteAndRun() { const toolsLoaded = await waitFor(() => capture().includes('PARAMETERS')); check('工具视图加载出参数区', toolsLoaded); - await sendKey('Tab'); // → 参数区 - await waitFor(() => FOCUS_PARAM.test(capture())); + await tabToFirstParam(); await sendText('hello'); // 整块写入,等价于粘贴 await waitFor(() => capture().includes('hello')); await sendKey('C-r'); @@ -438,7 +494,9 @@ async function t8ToolsView() { await sendText('/'); await waitFor(() => capture().includes('Search:')); await sendText('big'); // 整块写入 = 粘贴 - const filtered = await waitFor(() => capture().includes('Search: big') && capture().includes('1/4')); + const filtered = await waitFor( + () => capture().includes('Search: big') && capture().includes('1/4') + ); check('搜索框接受粘贴并过滤', filtered); } @@ -452,8 +510,7 @@ async function t9ResultActions() { await startSession({ configDir, cols: 100, rows: 45 }); await sendText('v'); await waitFor(() => capture().includes('PARAMETERS')); - await sendKey('Tab'); - await waitFor(() => FOCUS_PARAM.test(capture())); + await tabToFirstParam(); await sendText('hi'); await waitFor(() => capture().includes('hi')); await sendKey('C-r'); @@ -464,15 +521,24 @@ async function t9ResultActions() { await sendText('v'); // 从光标起选行 await sendKey('Down'); - check('v + ↓ 进入选区(提示切到 Copy selection)', await waitFor(() => capture().includes('Copy selection'), 4000)); + check( + 'v + ↓ 进入选区(提示切到 Copy selection)', + await waitFor(() => capture().includes('Copy selection'), 4000) + ); await sendKey('C-y'); check( 'Ctrl+Y 复制选区(或明确提示无剪贴板工具)', - await waitFor(() => capture().includes('Copied') || capture().includes('No clipboard utility'), 5000) + await waitFor( + () => capture().includes('Copied') || capture().includes('No clipboard utility'), + 5000 + ) ); await sendText('f'); - check('f 切到全宽(工具列表隐藏)', await waitFor(() => !capture().includes('mock__') || capture().includes('OUTPUT'), 4000)); + check( + 'f 切到全宽(工具列表隐藏)', + await waitFor(() => !capture().includes('mock__') || capture().includes('OUTPUT'), 4000) + ); } /** @@ -517,8 +583,16 @@ async function t11Cjk() { // '[中文标签]' 挂在两个服务上,'[宽字符]' 只挂一个:宽字符标签必须完整落在 // 同一行里(每个服务一行),不能因为宽度算错而折行或截半。 const tagLines = (tag) => lines.filter((l) => l.includes(tag)).length; - check('[中文标签] 出现在两个服务所在的行', tagLines('[中文标签]') === 2, `出现在 ${tagLines('[中文标签]')} 行`); - check('[宽字符] 出现在一个服务所在的行', tagLines('[宽字符]') === 1, `出现在 ${tagLines('[宽字符]')} 行`); + check( + '[中文标签] 出现在两个服务所在的行', + tagLines('[中文标签]') === 2, + `出现在 ${tagLines('[中文标签]')} 行` + ); + check( + '[宽字符] 出现在一个服务所在的行', + tagLines('[宽字符]') === 1, + `出现在 ${tagLines('[宽字符]')} 行` + ); check( '每个服务恰好一行(宽字符未导致折行)', lines.filter((l) => l.includes('cjk-one')).length === 1 && @@ -529,12 +603,14 @@ async function t11Cjk() { await sendText('v'); await waitFor(() => capture().includes('PARAMETERS')); - await sendKey('Tab'); - await waitFor(() => FOCUS_PARAM.test(capture())); + await tabToFirstParam(); await sendText('你好世界'); // 整块写入(中文按显示宽度开窗) await waitFor(() => capture().includes('你好世界')); await sendKey('C-r'); - check('中文参数值运行后原样回显', await waitFor(() => capture().includes('echo: "你好世界"'), 10_000)); + check( + '中文参数值运行后原样回显', + await waitFor(() => capture().includes('echo: "你好世界"'), 10_000) + ); } /** @@ -556,7 +632,10 @@ async function t12ConfigPathAndSelfWriteNotice() { await sendText(`node ${MOCK_BACKEND}`); await sendKey('C-s'); - const created = await waitFor(() => capture().includes("Service 'e2e-selfwrite' created successfully"), 6000); + const created = await waitFor( + () => capture().includes("Service 'e2e-selfwrite' created successfully"), + 6000 + ); check('保存后提示是创建成功', created); check('不再出现误导性的「外部变更」提示', !capture().includes('external changes')); check('新服务已写入配置', readConfig(configDir).mcpServers['e2e-selfwrite'] !== undefined); @@ -572,29 +651,33 @@ async function t13SaveOutput() { await startSession({ configDir, cols: 100, rows: 45 }); await sendText('v'); await waitFor(() => capture().includes('PARAMETERS')); - await sendKey('Tab'); - await waitFor(() => FOCUS_PARAM.test(capture())); + await tabToFirstParam(); await sendText('save-me'); await waitFor(() => capture().includes('save-me')); await sendKey('C-r'); await waitFor(() => capture().includes('echo: "save-me"'), 10_000); await sendKey('C-o'); - check('Ctrl+O 提示已保存并给出路径', await waitFor(() => capture().includes('Saved full output:'), 5000)); + check( + 'Ctrl+O 提示已保存并给出路径', + await waitFor(() => capture().includes('Saved full output:'), 5000) + ); check( '同时把完整路径交给剪贴板(无工具时明确说明)', - capture().includes('path copied to the clipboard') || capture().includes('clipboard unavailable') + capture().includes('path copied to the clipboard') || + capture().includes('clipboard unavailable') ); } /** - * T14 — 长描述:Ctrl+E 展开后 ↑/↓ 逐行滚动描述,滚到尽头才切换到下一个工具。 + * T14 — 长描述:Ctrl+E 展开后焦点进描述区,↑/↓ 逐行滚动;Esc 回工具列表后 + * ↑/↓ 立刻切换工具(不需要先折叠描述)。 * - * 这条对应一个真实交互缺陷:展开长描述后按 ↓ 原本会直接跳到下一个工具, - * 面板 gutter 里的 `↑|↓ more` 提示因此不可兑现。 + * 对应两个真实交互缺陷:①展开长描述后 ↑/↓ 被描述滚动独占,切不了工具; + * ②footer 写着 "Esc Done",但当时按 Esc 会直接退回服务列表。 */ async function t14DescriptionScroll() { - process.stdout.write('\n[T14] 长描述展开滚动(Ctrl+E → ↑/↓)\n'); + process.stdout.write('\n[T14] 长描述展开滚动(Ctrl+E → ↑/↓ 滚动 → Esc 回列表)\n'); const configDir = makeConfigDir({ mock: stdioService('mock') }); await startSession({ configDir, cols: 100, rows: 34 }); @@ -605,8 +688,17 @@ async function t14DescriptionScroll() { await waitFor(() => /▶\s+✓\s+big_output/.test(capture())); await sendKey('C-e'); // 展开 - check('展开后回到描述开头', await waitFor(() => capture().includes('desc-line-00'), 4000)); - check('footer 提示改为 ↑/↓ Scroll', capture().includes('↑/↓ Scroll')); + // 判据必须同时等「焦点进了描述区」(footer 换成描述区按键)与「描述回到开头」: + // 折叠态本来就能看到 desc-line-00,只等它的话这一帧可能还没重绘,断言会假通过。 + const inDescRegion = await waitFor( + () => capture().includes('Esc Back to tool list') && capture().includes('desc-line-00'), + 4000 + ); + check( + '展开后焦点进描述区、描述回到开头', + inDescRegion, + `当前选中=${/▶\s+✓\s+(\S+)/.exec(capture())?.[1] ?? '?'}` + ); // 两个注意点:①描述每行会按面板宽度折成 2 个终端行,判据用"描述首个终端行移出视口" // 而不是某个 desc-line-NN 消失;②同一读取块内的连续按键可能被合并成一次, @@ -621,16 +713,113 @@ async function t14DescriptionScroll() { const stillThere = capture().includes('Returns a large text payload for paging and copy'); const selected = /▶\s+✓\s+(\S+)/.exec(capture())?.[1] ?? '?'; check( - '↓ 逐行滚动描述(选中项不变)', + '↓ 在描述区逐行滚动(选中项不变)', scrolled && /▶\s+✓\s+big_output/.test(capture()), `首句仍在=${stillThere} 当前选中=${selected}` ); - // 一直按到低:滚到尽头后继续 ↓ 才把选中项交给下一个工具(fail) - for (let i = 0; i < 60 && !/▶\s+✓\s+fail/.test(capture()); i += 1) { - await sendKey('Down'); - } - check('滚到尽头后继续 ↓ 才切换工具', /▶\s+✓\s+fail/.test(capture())); + // Esc 把箭头交还列表:留在工具视图(不退回服务列表),↑/↓ 立刻换工具 + await sendKey('Escape'); + const backToList = await waitFor(() => capture().includes('↑/↓ Navigate'), 4000); + check('Esc 回到工具列表而不是服务列表', backToList && capture().includes('Tools for: mock')); + await sendKey('Down'); + check( + '列表里 ↑/↓ 立即切换工具(无需先折叠描述)', + await waitFor(() => /▶\s+✓\s+fail/.test(capture()), 4000), + `当前选中=${/▶\s+✓\s+(\S+)/.exec(capture())?.[1] ?? '?'}` + ); + check( + '切换到新工具后描述回到折叠态', + await waitFor(() => capture().includes('Ctrl+E Expand desc'), 4000) + ); +} + +/** + * T15 — 区域焦点可见 + 底部提示严格按区域。 + * + * 对应反馈:①切区「看不出来」——聚焦的标题条与未聚焦同色,唯一的线索被抹掉; + * ②底部提示「有时联动、有时不展示」,跨区泄漏且会被瞬时通知顶掉。 + * + * 断言:焦点标记跟着 Tab 走、同一帧只有一个区域带标记、带颜色的抓屏里聚焦区 + * 竖线颜色与未聚焦区不同;提示只讲当前区域;运行后结果区加入循环;通知不顶提示。 + */ +async function t15FocusAndHints() { + process.stdout.write('\n[T15] 区域焦点可见 + 提示按区域\n'); + const configDir = makeConfigDir({ mock: stdioService('mock') }); + + await startSession({ configDir, cols: 100, rows: 34 }); + await sendText('v'); + await waitFor(() => capture().includes('PARAMETERS')); + + check('列表焦点:标题与提示都指向工具区', capture().includes('Quick Actions — Tools')); + check('标题里不出现焦点箭头标记', !/▶ ▌/.test(capture())); + + await sendKey('Tab'); // → 描述区 + check( + '焦点随 Tab 走到描述区(提示行给出区域名)', + await waitFor(() => capture().includes('Quick Actions — Description'), 4000) + ); + check( + '提示只讲当前区域(不再跨区泄漏)', + !capture().includes('Space Toggle'), + `footer=${capture().split('\n').slice(-3).join(' | ')}` + ); + check('切换焦点不添加任何标记字形', !/▶ ▌/.test(capture())); + + // 颜色对拍(不带 -e 的抓屏没有 SGR,这是唯一能看到焦点的办法): + // ①聚焦区的**标题文字**必须变色 —— 旧实现只把单个 `▌` 格子改成「终端默认色」, + // 与旁边永远默认色的标题文字同色,等于没有高亮; + // ②聚焦时竖线与标题文字取同一个颜色(整块标题区一起变),未聚焦则不是。 + const colored = captureColored(); + const focusedLabel = sgrBefore(colored, 'DESCRIPTION', 'DESCRIPTION'); + const focusedBar = sgrBefore(colored, 'DESCRIPTION', '▌'); + const idleLabel = sgrBefore(colored, 'PARAMETERS', 'PARAMETERS'); + const idleBar = sgrBefore(colored, 'PARAMETERS', '▌'); + check( + '聚焦区标题文字变色(不只是那个竖线格子)', + focusedLabel !== null && idleLabel !== null && focusedLabel !== idleLabel, + `聚焦标题=${focusedLabel} 未聚焦标题=${idleLabel}` + ); + check( + '聚焦时整块标题区同色,未聚焦时不是', + focusedBar === focusedLabel && idleBar !== idleLabel, + `聚焦 竖线=${focusedBar} 标题=${focusedLabel} / 未聚焦 竖线=${idleBar} 标题=${idleLabel}` + ); + + // 运行:结果区加入循环,提示换成结果区自己的键 + await tabToFirstParam(); + await sendText('t15'); + await sendKey('C-r'); + const ran = await waitFor(() => capture().includes('Result: ✓'), 10_000); + check('结果区加入循环(提示切到结果区)', ran && capture().includes('Quick Actions — Result')); + check( + '结果区提示只讲结果区', + capture().includes('Ctrl+Y Copy result') && !capture().includes('Space Toggle') + ); + + // 瞬时通知占「标题行」位置:提示两行必须原样保留(旧实现会把整行顶掉 4 秒) + await sendKey('C-y'); + const notice = await waitFor(() => /Copied|No clipboard utility/.test(capture()), 4000); + check('复制通知出现', notice); + check( + '通知顶掉的是标题行,区域提示仍在', + capture().includes('Ctrl+Y Copy result') && !capture().includes('Quick Actions —'), + `footer=${capture().split('\n').slice(-3).join(' | ')}` + ); + + // 循环回到起点:结果 → 列表 → 描述 → 参数 → 结果 + await sendKey('Tab'); + check( + '结果区 Tab 回到列表', + await waitFor(() => capture().includes('Quick Actions — Tools'), 4000) + ); + await sendKey('Tab'); + await sendKey('Tab'); + await sendKey('Tab'); + check( + 'Tab 循环含结果区并可回到它', + await waitFor(() => capture().includes('Quick Actions — Result'), 4000) + ); } // --------------------------------------------------------------------- main @@ -647,9 +836,7 @@ function assertFreshBuild() { .split('\n') .filter(Boolean); if (stale.length > 0) { - process.stderr.write( - `dist 比源码旧(例如 ${stale[0]})—— 请先运行 npm run build 再验证 TUI\n` - ); + process.stderr.write(`dist 比源码旧(例如 ${stale[0]})—— 请先运行 npm run build 再验证 TUI\n`); process.exit(1); } } @@ -682,13 +869,18 @@ async function main() { t12ConfigPathAndSelfWriteNotice, t13SaveOutput, t14DescriptionScroll, + t15FocusAndHints, ]; for (const scenario of scenarios) { try { await scenario(); } catch (error) { - check(`${scenario.name} 未抛异常`, false, error instanceof Error ? error.message : String(error)); + check( + `${scenario.name} 未抛异常`, + false, + error instanceof Error ? error.message : String(error) + ); } finally { if (!KEEP) killServer(); } @@ -707,7 +899,9 @@ let exitCode = 1; try { exitCode = await main(); } catch (error) { - process.stderr.write(`TUI E2E 异常终止:${error instanceof Error ? error.stack : String(error)}\n`); + process.stderr.write( + `TUI E2E 异常终止:${error instanceof Error ? error.stack : String(error)}\n` + ); } finally { if (!KEEP) killServer(); for (const dir of tmpDirs) { diff --git a/src/tui/components/HelpDialog.tsx b/src/tui/components/HelpDialog.tsx index d11edea..469be24 100644 --- a/src/tui/components/HelpDialog.tsx +++ b/src/tui/components/HelpDialog.tsx @@ -137,10 +137,11 @@ export const HelpDialog: React.FC = ({ onClose }) => { / - Search tools - Tab - Switch region (list → params → result) + Tab - Switch region (list → description → params → result, + once a run has output) - ↑/↓ - Navigate tools; with a description expanded, scroll it + ↑/↓ - Navigate tools; in the description region, scroll it ←/→ - Page the panel (also PgUp/PgDn, Ctrl+U/D) @@ -149,8 +150,8 @@ export const HelpDialog: React.FC = ({ onClose }) => { Ctrl+R - Run the selected tool - Ctrl+E - Expand / collapse the tool description (then ↑/↓ - scroll it) + Ctrl+E - Expand / collapse the tool description; when it + overflows, expanding focuses it so ↑/↓ scroll it Ctrl+J - Toggle form / raw JSON arguments @@ -172,7 +173,8 @@ export const HelpDialog: React.FC = ({ onClose }) => { Ctrl+O - Save full output to a temp file - Esc - Leave region / back to service list + Esc - Leave the region; from the tool list, back to the + service list diff --git a/src/tui/components/ServiceTools.tsx b/src/tui/components/ServiceTools.tsx index 7f463ab..f6f4621 100644 --- a/src/tui/components/ServiceTools.tsx +++ b/src/tui/components/ServiceTools.tsx @@ -64,7 +64,7 @@ export interface ServiceToolsProps { } /** Where keyboard focus lives inside the tools view. */ -type PanelFocus = 'list' | 'params' | 'result' | 'json'; +type PanelFocus = 'list' | 'desc' | 'params' | 'result' | 'json'; type RunStatus = 'editing' | 'running' | 'done'; type ResultView = 'formatted' | 'raw'; @@ -173,10 +173,14 @@ function cycleSelectValue(param: ToolParam, current: string): string { } /** - * Section header row. The bar glyph is the same whether or not the section has - * focus — only its color differs: the focused bar reads as plain text, the idle - * one is grey. Grey rather than `dimColor` on purpose: terminals that render - * faint cells by dimming them would paint a darker block behind the bar. + * Section header row: section bar + label + rule. + * + * Focus shows up as the colour of the WHOLE header — bar, label and rule go + * cyan+bold together, idle headers stay grey-bar/plain-label. That is the point: + * recolouring only the single `▌` cell was invisible, because the "focused" + * colour was the terminal default, i.e. exactly the colour of the label and rule + * sitting next to it. Grey rather than `dimColor` on purpose — terminals that + * render faint cells by dimming them would paint a darker block behind the bar. */ const sectionRow = (label: string, width: number, focused: boolean): DetailRow => { const text = sectionTitle(label, width); @@ -187,8 +191,8 @@ const sectionRow = (label: string, width: number, focused: boolean): DetailRow = type: 'text', text, segments: [ - { text: SECTION_BAR, tone: focused ? 'value' : 'muted' }, - { text: text.slice(SECTION_BAR.length), tone: 'value' }, + { text: text.slice(0, SECTION_BAR.length), tone: focused ? 'accent' : 'muted' }, + { text: text.slice(SECTION_BAR.length), tone: focused ? 'accent' : 'value' }, ], }; }; @@ -270,7 +274,10 @@ export const ServiceTools: React.FC = ({ const terminalHeight = terminalHeightProp ?? (stdout?.rows || 24); const terminalWidth = stdout?.columns || 80; const HEADER_LINES = 4; - // Contextual hint footer; the "Quick Actions:" title is dropped on tiny terminals. + // Contextual hint footer: the two hint lines plus a heading. The heading is + // dropped on tiny terminals, and a transient notice takes its place — the + // notice must never displace a hint, and the footer must never grow past its + // budget or it would squeeze the panels below their measured height. const FOOTER_LINES = terminalHeight < 10 ? 2 : 3; const ERROR_LINES = error !== null ? 1 : 0; const BODY_LINES = Math.max(1, terminalHeight - HEADER_LINES - FOOTER_LINES - ERROR_LINES); @@ -424,15 +431,24 @@ export const ServiceTools: React.FC = ({ * Tab order across the panel regions. Unavailable regions are skipped, so * Tab only ever lands somewhere meaningful (no result → no result region). */ + /** + * Tab order across the panel regions. The description is always in the loop + * (it is a region you can focus and read, not only a thing that scrolls), and + * the result joins it once a run has produced one. Unavailable regions are + * skipped, so Tab only ever lands somewhere meaningful. + */ const cycleRegion = (direction: 1 | -1): void => { - const available: PanelFocus[] = ['list']; + const available: PanelFocus[] = ['list', 'desc']; if (params.length > 0) { available.push('params'); } if (runStatus !== 'editing') { available.push('result'); } - const current = Math.max(0, available.indexOf(focus)); + // Ctrl+J replaces the parameters region with the raw-JSON editor, so Tab + // continues from where the form sits rather than restarting at the list. + const cursor = focus === 'json' ? 'params' : focus; + const current = Math.max(0, available.indexOf(cursor)); const next = available[(current + direction + available.length) % available.length]; if (next === undefined) { return; @@ -660,9 +676,18 @@ export const ServiceTools: React.FC = ({ const expanding = !descExpanded; setDescExpanded(expanding); if (expanding) { - // Expanded in order to READ it: put the beginning of the description on - // screen, and let ↑/↓ scroll from there. + // Expanded in order to READ it: start at the top, and hand the arrow + // keys over to the description (`desc` focus) when the full text will + // not fit — otherwise Ctrl+E would be a key that spends the arrows on + // a region with nothing to scroll. setPanelScroll(0); + if (focus === 'list' && maxPanelScrollExpanded > 0) { + setFocus('desc'); + } + } else if (focus === 'desc') { + // Collapsing from inside the description: the region is gone, so land + // somewhere that still exists. + setFocus('list'); } return; } @@ -749,8 +774,13 @@ export const ServiceTools: React.FC = ({ return; } - // --- JSON focus: editing keys belong to JsonTextArea --- + // --- JSON focus: editing keys belong to JsonTextArea. Tab still cycles + // regions — the editor ignores Tab, so the key is free, and the hint + // line promises it. --- if (focus === 'json') { + if (key.tab) { + cycleRegion(key.shift ? -1 : 1); + } return; } @@ -824,6 +854,24 @@ export const ServiceTools: React.FC = ({ return; } + // --- Description focus: the arrows read the expanded description. The tool + // list is one Esc away, so switching tools never requires collapsing + // the text first — and ↑/↓ in the list keeps its original job. --- + if (focus === 'desc') { + if (key.tab) { + cycleRegion(key.shift ? -1 : 1); + } else if (key.upArrow) { + scrollPanel(-1); + } else if (key.downArrow) { + scrollPanel(1); + } else if (key.leftArrow) { + pagePanel(-1); + } else if (key.rightArrow) { + pagePanel(1); + } + return; + } + // --- List focus --- if (input === '/') { setSearchMode(true); @@ -834,17 +882,9 @@ export const ServiceTools: React.FC = ({ return; } if (key.upArrow) { - if (arrowKeysScrollPanel && Math.min(panelScroll, maxPanelScroll) > 0) { - scrollPanel(-1); - } else { - setSelectedIndex((prev) => Math.max(0, prev - 1)); - } + setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - if (arrowKeysScrollPanel && Math.min(panelScroll, maxPanelScroll) < maxPanelScroll) { - scrollPanel(1); - } else { - setSelectedIndex((prev) => Math.max(0, Math.min(filteredTools.length - 1, prev + 1))); - } + setSelectedIndex((prev) => Math.max(0, Math.min(filteredTools.length - 1, prev + 1))); } else if (key.leftArrow) { pagePanel(-1); } else if (key.rightArrow) { @@ -900,29 +940,31 @@ export const ServiceTools: React.FC = ({ // --- Flattened detail rows (description → parameters; result is pinned) --- const descCap = Math.min(12, Math.max(3, Math.floor(PANEL_LINES * 0.35))); + const descWrapped = useMemo( + () => wrapText(currentTool?.description ?? '', PANEL_WIDTH - DESCRIPTION_INDENT.length), + [currentTool?.description, PANEL_WIDTH] + ); + /** Rows the description occupies once expanded — the collapsed form is capped. */ + const descRowCountExpanded = Math.max(1, descWrapped.length); const descRows: DetailRow[] = useMemo(() => { const rows: DetailRow[] = []; - const wrapped = wrapText( - currentTool?.description ?? '', - PANEL_WIDTH - DESCRIPTION_INDENT.length - ); - if (wrapped.length === 0 || (wrapped.length === 1 && wrapped[0] === '')) { + if (descWrapped.length === 0 || (descWrapped.length === 1 && descWrapped[0] === '')) { rows.push({ type: 'text', text: `${DESCRIPTION_INDENT}(no description)` }); - } else if (!descExpanded && wrapped.length > descCap) { - for (const line of wrapped.slice(0, descCap)) { + } else if (!descExpanded && descWrapped.length > descCap) { + for (const line of descWrapped.slice(0, descCap)) { rows.push({ type: 'text', text: `${DESCRIPTION_INDENT}${line}` }); } rows.push({ type: 'text', - text: `${DESCRIPTION_INDENT}… ${wrapped.length - descCap} more line(s) — Ctrl+E expands`, + text: `${DESCRIPTION_INDENT}… ${descWrapped.length - descCap} more line(s) — Ctrl+E expands`, }); } else { - for (const line of wrapped) { + for (const line of descWrapped) { rows.push({ type: 'text', text: `${DESCRIPTION_INDENT}${line}` }); } } return rows; - }, [currentTool?.description, PANEL_WIDTH, descCap, descExpanded]); + }, [descWrapped, descCap, descExpanded]); // Only the parameter region expands a field; browsing (list focus) keeps the // compact one-line-per-parameter overview. @@ -961,7 +1003,7 @@ export const ServiceTools: React.FC = ({ /** Description + parameters + validation rows (the scrollable flow). */ const allRowsBase: DetailRow[] = useMemo( () => [ - sectionRow('Description', PANEL_WIDTH, focus === 'list'), + sectionRow('Description', PANEL_WIDTH, focus === 'desc'), { type: 'text' as const, text: ' ' }, ...descRows, { type: 'text' as const, text: ' ' }, @@ -1070,21 +1112,101 @@ export const ServiceTools: React.FC = ({ const maxPanelScroll = Math.max(0, flowRows.length - FLOW_VISIBLE); /** - * With a description expanded (and actually overflowing) the arrow keys scroll - * the panel instead of moving the tool selection: the user pressed Ctrl+E to - * read, and the panel's own `↑|↓ more` hint promises exactly that. Reaching - * either end lets the next press fall through to tool navigation, and picking - * another tool collapses the description (per-tool reset), so this never - * becomes a sticky mode. + * Scroll range the panel WILL have once the description is expanded. Needed + * while deciding whether Ctrl+E should hand the arrows to the description: + * the collapsed layout is what is on screen at that moment, and a collapsed + * description is capped at `descCap` rows, so its own range says nothing + * about whether the full text overflows. */ - const arrowKeysScrollPanel = focus === 'list' && descExpanded && maxPanelScroll > 0; - /** Which arrow pair the panel's scroll hint should name for the current focus. */ - const scrollHintPair: 'vertical' | 'horizontal' = - focus === 'list' && !arrowKeysScrollPanel ? 'horizontal' : 'vertical'; + const maxPanelScrollExpanded = Math.max( + 0, + flowRows.length - descRows.length + descRowCountExpanded - FLOW_VISIBLE + ); + /** + * Which arrow pair the panel's scroll hint should name. With the tool list + * focused the arrows move the selection, so ←/→ (and PageUp/PageDown) are + * what page it; every other region scrolls with ↑/↓. + */ + const scrollHintPair: 'vertical' | 'horizontal' = focus === 'list' ? 'horizontal' : 'vertical'; // Clamp at render time — PANEL_LINES shrinks while a status message is // visible, so an effect-based clamp would leave a blank panel for ~2s. const clampedPanelScroll = Math.min(panelScroll, maxPanelScroll); + /** + * The bottom hints describe the focused region and nothing else: both lines + * belong to it, and every key named here works right now in that region. A + * hint that only applies elsewhere (or to a key that would no-op — `v` before + * a result exists, scroll keys in a region with nothing to scroll) is a bug, + * not a convenience. Transient notices render on their own line so they can + * never displace these. + */ + const regionHints = ((): { label: string; keys: string; actions: string } => { + if (searchMode) { + return { + label: 'Search', + keys: 'Type to search • ↑/↓ Navigate matches • Enter Confirm', + actions: 'Esc Leave search', + }; + } + if (focus === 'desc') { + // Three states, three honest hints: scrollable, clipped-but-collapsed + // (there IS more text, it just needs Ctrl+E), or nothing to read beyond + // what is already on screen. + const clipped = !descExpanded && descWrapped.length > descCap; + return { + label: 'Description', + keys: + maxPanelScroll > 0 + ? '↑/↓ Scroll line • ←/→ Page' + : clipped + ? 'Description is clipped — Ctrl+E expands it' + : 'Description fits — nothing to scroll', + actions: `Ctrl+E ${descExpanded ? 'Collapse' : 'Expand'} desc • Ctrl+R Run • Tab Next region • Esc Back to tool list`, + }; + } + if (focus === 'params') { + return { + label: 'Parameters', + keys: '↑/↓ Param • ←/→ Cursor • Space Cycle option • Enter Next', + actions: 'Ctrl+R Run • Ctrl+J JSON • Tab Next region • Esc Back to tool list', + }; + } + if (focus === 'result') { + if (runStatus === 'running') { + return { label: 'Result', keys: 'Running…', actions: 'Ctrl+C Quit TUI' }; + } + if (runStatus !== 'done' || outcome === null) { + return { + label: 'Result', + keys: 'No result yet — Ctrl+R runs the tool', + actions: 'Tab Next region • Esc Back to tool list', + }; + } + const selecting = selectAnchor !== null; + return { + label: 'Result', + keys: `↑/↓ Cursor • ←/→ Page • v ${selecting ? 'Cancel select' : 'Select lines'} • f Full width`, + actions: selecting + ? 'Ctrl+Y Copy selection • Esc Cancel select' + : 'Ctrl+Y Copy result • Ctrl+P Raw • Ctrl+O Save • Tab Next region • Esc Back to tool list', + }; + } + if (focus === 'json') { + return { + label: 'JSON', + keys: 'Edit raw JSON arguments', + actions: 'Ctrl+J Back to form • Ctrl+R Run • Tab Next region • Esc Back to tool list', + }; + } + return { + label: 'Tools', + keys: '↑/↓ Navigate • Space Toggle • a/A All on/off • / Search', + actions: `←/→ Page • Ctrl+R Run • Ctrl+E ${ + descExpanded ? 'Collapse' : 'Expand' + } desc • Tab Next region`, + }; + })(); + /** * Keep the expanded parameter visible while navigating it. * @@ -1274,7 +1396,9 @@ export const ServiceTools: React.FC = ({ - + {/* Panel title doubles as the tool list's focus cue: cyan while the + list owns the keyboard, grey while another region does. */} + Tools for: {service.name} @@ -1434,43 +1558,20 @@ export const ServiceTools: React.FC = ({ )} - {terminalHeight >= 10 && ( - - Quick Actions: + {(terminalHeight >= 10 || copyNotice !== null) && ( + + {copyNotice ?? `Quick Actions — ${regionHints.label}`} )} {' '} - {searchMode - ? 'Type to search • ↑/↓ Navigate matches • Enter Confirm' - : focus === 'params' - ? '↑/↓ Param • ←/→ Cursor • Space Cycle option • Enter Next' - : focus === 'result' - ? `↑/↓ Cursor • ←/→ Page • v ${ - selectAnchor !== null ? 'Cancel select' : 'Select lines' - } • f Full width` - : focus === 'json' - ? 'Edit raw JSON arguments' - : arrowKeysScrollPanel - ? '↑/↓ Scroll description • Space Toggle • / Search' - : '↑/↓ Navigate • Space Toggle • a/A All on/off • / Search'} + {regionHints.keys} {' '} - {copyNotice ?? - (searchMode - ? 'Ctrl+R Run • Esc Leave search' - : focus === 'params' || focus === 'json' - ? 'Tab Next region • Ctrl+R Run • Ctrl+J JSON • Esc Done' - : focus === 'result' - ? selectAnchor !== null - ? 'Ctrl+Y Copy selection • Esc Cancel' - : 'Ctrl+Y Copy result • Ctrl+P Raw • Ctrl+O Save' - : arrowKeysScrollPanel - ? '↑/↓ Scroll • ←/→ Page • Ctrl+E Collapse • Esc Done' - : `←/→ Page • Tab Region • Ctrl+R Run • f Full width • Ctrl+E ${ - descExpanded ? 'Collapse' : 'Expand' - } desc`)} + {regionHints.actions} diff --git a/tests/integration/tui-service-tools-focus.test.ts b/tests/integration/tui-service-tools-focus.test.ts new file mode 100644 index 0000000..5a4e821 --- /dev/null +++ b/tests/integration/tui-service-tools-focus.test.ts @@ -0,0 +1,245 @@ +/** + * Focus visibility in the flattened ServiceTools panel. + * + * A focused region used to be signalled only by the colour of a single `▌` cell + * — and the "focused" colour was the terminal default, i.e. exactly the colour + * of the label and rule sitting next to it. Focus was therefore invisible, and + * (because both test harnesses drop SGR) untestable. These tests pin the glyph + * that replaced it: exactly one region carries `▶ `, the mark moves with Tab, + * and the bottom hints describe the marked region and nothing else. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { Box, useStdout, render } from 'ink'; +import { ServiceTools } from '../../src/tui/components/ServiceTools.js'; +import type { ToolCallOutcome } from '../../src/tui/discovery-worker.js'; +import type { Tool } from '../../src/types/tool.js'; +import type { ServiceDefinition } from '../../src/types/service.js'; +import { Terminal, createStdin, waitFor, typeKeys, pressKey } from './helpers/ansi-terminal.js'; + +const { fetchServiceToolsMock, callServiceToolMock } = vi.hoisted(() => { + // Long enough that the collapsed description is clipped by `descCap`, which is + // what drives the description region's "clipped" hint. + const longDescription = [ + 'mock tool with parameters', + ...Array.from({ length: 40 }, (_, i) => `desc-line-${String(i).padStart(2, '0')}`), + ].join('\n'); + const tool: Tool = { + name: 'alpha', + namespacedName: 'demo__alpha', + serviceName: 'demo', + description: longDescription, + inputSchema: { + type: 'object' as const, + properties: { + q: { type: 'string', description: 'query text' }, + limit: { type: 'integer', description: 'max results' }, + }, + required: ['q'], + }, + enabled: true, + }; + const outcome: ToolCallOutcome = { + isError: false, + text: '{"ok":true}', + formatted: '{\n "ok": true\n}', + nonTextTypes: [], + raw: '{\n "content": []\n}', + }; + return { + fetchServiceToolsMock: vi.fn(() => Promise.resolve([tool])), + callServiceToolMock: vi.fn(() => Promise.resolve(outcome)), + }; +}); + +vi.mock('../../src/tui/discovery-worker.js', () => ({ + __esModule: true, + fetchServiceTools: fetchServiceToolsMock, + callServiceTool: callServiceToolMock, + ToolCallError: class ToolCallError extends Error {}, + DiscoveryError: class DiscoveryError extends Error {}, + DiscoveryErrorType: { TIMEOUT: 'timeout', CONNECTION_FAILED: 'connection_failed' }, + default: fetchServiceToolsMock, +})); + +vi.mock('../../src/tui/clipboard.js', () => ({ + __esModule: true, + copyToClipboard: vi.fn(() => true), +})); + +const MiniApp: React.FC<{ rows: number }> = ({ rows }) => { + const { stdout } = useStdout(); + const terminalHeight = stdout?.rows || rows; + const service: ServiceDefinition = { + name: 'demo', + transport: 'http', + url: 'http://127.0.0.1:1/mcp', + enabled: true, + tags: [], + connectionPool: { maxConnections: 1, idleTimeout: 60000, connectionTimeout: 10000 }, + }; + return React.createElement( + Box, + { flexDirection: 'column', height: terminalHeight }, + React.createElement(ServiceTools, { + service, + onBack: () => {}, + onToggleTool: () => {}, + toolStates: {}, + terminalHeight, + }) + ); +}; + +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 }; +} + +describe('ServiceTools region focus (real components)', () => { + it('keeps every heading glyph constant and moves the focus cue with Tab', async () => { + const { instance, term, stdin } = renderApp(30, 100); + await waitFor(() => term.text().includes('mock tool with parameters')); + + // Focus is carried by colour alone, and this harness drops SGR — so the + // heading rows must stay *literally* identical between focus states: no + // marker glyph appears or disappears. The footer names the focused region, + // which is the part this harness can observe. + expect(term.text()).toContain('Tools for: demo'); + expect(term.text()).toContain('▌ DESCRIPTION'); + expect(term.text()).not.toMatch(/▶ ▌/); + + await typeKeys(stdin, '\t'); + await waitFor(() => term.text().includes('Quick Actions — Description')); + expect(term.text()).toContain('Tools for: demo'); + expect(term.text()).toContain('▌ DESCRIPTION'); + expect(term.text()).not.toMatch(/▶ ▌/); + + await typeKeys(stdin, '\t'); + await waitFor(() => term.text().includes('Quick Actions — Parameters')); + expect(term.text()).toContain('▌ PARAMETERS (2)'); + expect(term.text()).not.toMatch(/▶ ▌/); + + // A third Tab wraps back to the tool list. + await typeKeys(stdin, '\t'); + await waitFor(() => term.text().includes('Quick Actions — Tools')); + + instance.unmount(); + }); + + it("shows only the focused region's keys, and names the region", async () => { + const { instance, term, stdin } = renderApp(30, 100); + await waitFor(() => term.text().includes('mock tool with parameters')); + + expect(term.text()).toContain('Quick Actions — Tools'); + expect(term.text()).toContain('Space Toggle'); + expect(term.text()).not.toContain('↑/↓ Param'); + + await typeKeys(stdin, '\t'); + await waitFor(() => term.text().includes('Quick Actions — Description')); + expect(term.text()).toContain('Description is clipped — Ctrl+E expands it'); + expect(term.text()).not.toContain('Space Toggle'); + expect(term.text()).not.toContain('↑/↓ Param'); + + // Expanding makes the description scrollable, and the hint says so. + await pressKey(stdin, '\x05'); // Ctrl+E → expand + focus the description + await waitFor(() => term.text().includes('↑/↓ Scroll line')); + expect(term.text()).not.toContain('Ctrl+E expands'); + await pressKey(stdin, '\x05'); // Ctrl+E → collapse; focus returns to the list + + // Collapsing handed focus back to the tool list, so params is two Tabs away. + await typeKeys(stdin, '\t'); + await typeKeys(stdin, '\t'); + await waitFor(() => term.text().includes('Quick Actions — Parameters')); + expect(term.text()).toContain('↑/↓ Param'); + expect(term.text()).not.toContain('Space Toggle'); + // The description's own key must not leak into the parameters region. + expect(term.text()).not.toContain('Scroll line'); + + instance.unmount(); + }); + + it('keeps a transient notice off the hint lines', async () => { + const { instance, term, stdin } = renderApp(30, 100); + await waitFor(() => term.text().includes('mock tool with parameters')); + + // Run the tool from the parameters region: Tab, fill `q`, Ctrl+R. + await typeKeys(stdin, '\t'); + await typeKeys(stdin, '\t'); + await typeKeys(stdin, 'hello'); + await pressKey(stdin, '\x12'); // Ctrl+R + await waitFor(() => term.text().includes('Result: ✓')); + + await pressKey(stdin, '\x19'); // Ctrl+Y → copy notice + await waitFor(() => term.text().includes('Copied')); + + const lines = term.text().split('\n'); + const hintLine = lines.findIndex((line) => line.includes('↑/↓ Cursor')); + const actionLine = lines.findIndex((line) => line.includes('Ctrl+Y Copy result')); + const noticeLine = lines.findIndex((line) => line.includes('Copied')); + + // Both hint lines survive untouched, and the notice took the footer's + // heading slot — so the footer never grows past its measured line budget. + expect(hintLine).toBeGreaterThan(-1); + expect(actionLine).toBeGreaterThan(hintLine); + expect(noticeLine).toBeGreaterThan(-1); + expect(noticeLine).toBeLessThan(hintLine); + expect(term.text()).not.toContain('Quick Actions —'); + + instance.unmount(); + }); + + it('lets the result region join the Tab cycle only once it has output', async () => { + const { instance, term, stdin } = renderApp(30, 100); + await waitFor(() => term.text().includes('mock tool with parameters')); + + // No run yet: three Tabs from the list come back to the list. + await typeKeys(stdin, '\t'); + await typeKeys(stdin, '\t'); + await typeKeys(stdin, '\t'); + await waitFor(() => term.text().includes('Quick Actions — Tools')); + expect(term.text()).not.toContain('Quick Actions — Result'); + + // Run from the parameters region (list → desc → params, then type + Ctrl+R). + await typeKeys(stdin, '\t'); + await typeKeys(stdin, '\t'); + await waitFor(() => term.text().includes('Quick Actions — Parameters')); + await typeKeys(stdin, 'hello'); + await pressKey(stdin, '\x12'); // Ctrl+R + await waitFor(() => term.text().includes('Result: ✓')); + expect(term.text()).toContain('Quick Actions — Result'); + + // Still in the cycle: result → list → desc → params → result. + await typeKeys(stdin, '\t'); + await waitFor(() => term.text().includes('Quick Actions — Tools')); + await typeKeys(stdin, '\t'); + await typeKeys(stdin, '\t'); + await typeKeys(stdin, '\t'); + await waitFor(() => term.text().includes('Quick Actions — Result')); + expect(term.text()).toContain('Ctrl+Y Copy result'); + + instance.unmount(); + }); +}); diff --git a/tests/integration/tui-service-tools-json-cache.test.ts b/tests/integration/tui-service-tools-json-cache.test.ts index 7351380..0d903db 100644 --- a/tests/integration/tui-service-tools-json-cache.test.ts +++ b/tests/integration/tui-service-tools-json-cache.test.ts @@ -64,6 +64,15 @@ vi.mock('../../src/tui/clipboard.js', () => ({ copyToClipboard: copyToClipboardMock, })); +/** + * Tab from the tool list into the parameters region. Tab cycles + * list → description → params, so the fields are two Tabs away. + */ +const tabToParams = async (stdin: Parameters[0]): Promise => { + await pressKey(stdin, '\t'); + await pressKey(stdin, '\t'); +}; + const MiniApp: React.FC = () => { const { stdout } = useStdout(); const service: ServiceDefinition = { @@ -99,7 +108,7 @@ describe('ServiceTools JSON editor and per-tool state', () => { const { instance, term, stdin } = renderTools(); await waitFor(() => term.text().includes('PARAMETERS (2)')); - await pressKey(stdin, '\t'); // list → params + await tabToParams(stdin); // list → description → params await typeKeys(stdin, 'hello'); await waitFor(() => term.text().includes('hello')); @@ -119,7 +128,7 @@ describe('ServiceTools JSON editor and per-tool state', () => { const { instance, term, stdin } = renderTools(); await waitFor(() => term.text().includes('PARAMETERS (2)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\n'); // Ctrl+J → raw JSON await waitFor(() => term.text().includes('Arguments (raw JSON):')); @@ -140,7 +149,7 @@ describe('ServiceTools JSON editor and per-tool state', () => { const { instance, term, stdin } = renderTools(); await waitFor(() => term.text().includes('PARAMETERS (2)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); // Ctrl+R await waitFor(() => term.text().includes('Result: ✓')); @@ -163,7 +172,7 @@ describe('ServiceTools JSON editor and per-tool state', () => { const { instance, term, stdin } = renderTools(); await waitFor(() => term.text().includes('alpha')); - await pressKey(stdin, '\t'); // list → params + await tabToParams(stdin); // list → description → params await typeKeys(stdin, 'kept-value'); await waitFor(() => term.text().includes('kept-value')); @@ -173,7 +182,7 @@ describe('ServiceTools JSON editor and per-tool state', () => { await pressKey(stdin, '\x1b[A'); // ↑ → alpha await waitFor(() => term.text().includes('mock tool alpha')); - await pressKey(stdin, '\t'); // params again + await tabToParams(stdin); // → description → params again await pressKey(stdin, '\n'); // Ctrl+J: the JSON projection shows what was kept await waitFor(() => term.text().includes('Arguments (raw JSON):')); expect(term.text()).toContain('"q": "kept-value"'); diff --git a/tests/integration/tui-service-tools-run.test.ts b/tests/integration/tui-service-tools-run.test.ts index 907668f..876267b 100644 --- a/tests/integration/tui-service-tools-run.test.ts +++ b/tests/integration/tui-service-tools-run.test.ts @@ -131,6 +131,14 @@ function renderApp(rows: number, cols: number, onBack?: () => void) { return { instance, term, stdin, stdout }; } +/** + * Tab from the tool list into the parameters region. The cycle is + * list → description → params, so the fields are two Tabs away. + */ +const tabToParams = async (stdin: Parameters[0]): Promise => { + await pressKey(stdin, '\t'); + await pressKey(stdin, '\t'); +}; describe('ServiceTools flattened detail panel', () => { beforeEach(() => { fetchServiceToolsMock.mockReset(); @@ -187,6 +195,7 @@ describe('ServiceTools flattened detail panel', () => { // The toggle is temporary: the next tool starts collapsed again. await pressKey(stdin, '\x05'); // expand once more await waitFor(() => !term.text().includes('more line(s)')); + await pressKey(stdin, '\x1b'); // Esc → hand the arrows back to the list await pressKey(stdin, '\x1b[B'); // ↓ → beta await waitFor(() => term.text().includes('▶ ✓ beta')); await waitFor(() => term.text().includes('more line(s)')); @@ -200,7 +209,7 @@ describe('ServiceTools flattened detail panel', () => { await waitFor(() => term.text().includes('Parameters (4)')); expect(term.text()).not.toContain('value'); - await pressKey(stdin, '\t'); // list → fields (q focused) + await tabToParams(stdin); // list → description → fields (q focused) await waitFor(() => term.text().includes('value')); // input placeholder visible // Typing 'a' must edit the field, not batch-enable tools. @@ -216,7 +225,7 @@ describe('ServiceTools flattened detail panel', () => { await waitFor(() => term.text().includes('Parameters (4)')); // Tab now switches REGIONS (list → params); ↑/↓ moves between parameters. - await pressKey(stdin, '\t'); // list → params (q expanded) + await tabToParams(stdin); // list → description → params (q expanded) await typeKeys(stdin, 'hello'); await pressKey(stdin, '\x1b[B'); // ↓ → limit await typeKeys(stdin, '3'); @@ -260,7 +269,7 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); // Ctrl+R await waitFor(() => term.text().includes('Result: ✓')); @@ -278,7 +287,7 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); // → q (left empty) + await tabToParams(stdin); // → q (left empty) await pressKey(stdin, '\x12'); // Ctrl+R with required q blank await waitFor(() => term.text().includes('q: is required')); @@ -315,7 +324,7 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); await waitFor(() => term.text().includes('boom from backend')); @@ -331,7 +340,7 @@ describe('ServiceTools flattened detail panel', () => { await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); // → fields + await tabToParams(stdin); // → description → fields await waitFor(() => term.text().includes('value')); await pressKey(stdin, '\x1b'); // leave editing → list focus @@ -380,7 +389,7 @@ describe('ServiceTools flattened detail panel', () => { .some((l) => l.trim().startsWith('───')) ).toBe(true); - await pressKey(stdin, '\t'); // → params: the focused one expands fully + await tabToParams(stdin); // → params: the focused one expands fully await waitFor(() => term.text().includes('▶ 1 q string *required')); await waitFor(() => term.text().includes('must be cut off')); // The other parameter keeps its single line. @@ -397,13 +406,13 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('PARAMETERS (4)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); // Ctrl+R → focus lands on the result region await waitFor(() => term.text().includes('Result: ✓')); // The footer advertises the result-region keys once it has focus. - await waitFor(() => term.text().includes('PgUp/PgDn Page')); + await waitFor(() => term.text().includes('Ctrl+Y Copy result')); // ↑/↓ scroll the panel line by line from the result region (this is the // capability that was previously missing entirely). @@ -427,7 +436,7 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); // Ctrl+R → focus lands on the result region await waitFor(() => term.text().includes('Result: ✓')); @@ -452,7 +461,7 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); await waitFor(() => term.text().includes('Result: ✓')); @@ -474,7 +483,7 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); // Ctrl+R → result focus await waitFor(() => term.text().includes('Result: ✓')); @@ -496,7 +505,7 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); await waitFor(() => term.text().includes('Result: ✓')); @@ -526,7 +535,7 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); await waitFor(() => term.text().includes('Result: ✓')); @@ -566,7 +575,7 @@ describe('ServiceTools flattened detail panel', () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - await pressKey(stdin, '\t'); + await tabToParams(stdin); await typeKeys(stdin, 'x'); await pressKey(stdin, '\x12'); await waitFor(() => term.text().includes('Result: ✓')); @@ -621,14 +630,16 @@ describe('ServiceTools flattened detail panel', () => { instance.unmount(); }); - it('keeps a literal section heading for each region with the same focus bar', async () => { + it('keeps a literal section heading per region, with no focus marker glyph', async () => { const { instance, term, stdin } = renderApp(30, 100); await waitFor(() => term.text().includes('Parameters (4)')); - // The bar glyph is identical in both focus states — focus is carried by the - // bar's color, which the SGR-stripping harness cannot observe. + // Headings are literal in every focus state: the row keeps the same bar + // glyph and label, and focus is signalled by colour alone (verified against + // real SGR codes in scripts/tui-e2e.mjs T15 — this harness drops them). expect(term.text()).toContain('▌ DESCRIPTION'); expect(term.text()).toContain('▌ PARAMETERS (4)'); + expect(term.text()).not.toMatch(/▶ ▌/); // The rule is drawn out to the panel width. expect( term @@ -637,10 +648,12 @@ describe('ServiceTools flattened detail panel', () => { .some((l) => l.includes('DESCRIPTION ───')) ).toBe(true); - // Moving focus between regions must not change the glyph. + // Moving focus must not change the glyph, shift the label, or add a marker. await pressKey(stdin, '\t'); - await waitFor(() => term.text().includes('▌ PARAMETERS (4)')); + await waitFor(() => term.text().includes('Quick Actions — Description')); expect(term.text()).toContain('▌ DESCRIPTION'); + expect(term.text()).toContain('▌ PARAMETERS (4)'); + expect(term.text()).not.toMatch(/▶ ▌/); instance.unmount(); }); diff --git a/tests/integration/tui-service-tools-scroll.test.ts b/tests/integration/tui-service-tools-scroll.test.ts index 1b0d843..543c3c1 100644 --- a/tests/integration/tui-service-tools-scroll.test.ts +++ b/tests/integration/tui-service-tools-scroll.test.ts @@ -294,7 +294,7 @@ describe('ServiceTools scroll indicator (real components, optimized chrome)', () instance.unmount(); }); - it('scrolls an expanded description with ↑/↓, falls through to the next tool at the end', async () => { + it('scrolls an expanded description in its own region, leaving ↑/↓ to tool navigation', async () => { const longDesc = Array.from( { length: 80 }, (_, i) => `desc-line-${String(i).padStart(2, '0')}` @@ -313,26 +313,28 @@ describe('ServiceTools scroll indicator (real components, optimized chrome)', () const { instance, term, stdin } = renderApp(24, 80); await waitFor(() => term.text().includes('desc-line-00')); - // 展开:立刻回到描述开头(展开是为了从头读) + // 展开:立刻回到描述开头(展开是为了从头读),焦点交给描述区 await pressKey(stdin, '\x05'); // Ctrl+E await waitFor(() => !term.text().includes('Ctrl+E expands')); expect(term.text()).toContain('desc-line-00'); + expect(term.text()).toContain('↑/↓ Scroll line'); + expect(term.text()).toContain('Esc Back to tool list'); - // 展开期间 ↑/↓ 逐行滚动,而不再切换工具(关键判别:选中项必须没变) + // 描述区里 ↑/↓ 逐行滚动,而不再切换工具(关键判别:选中项必须没变) await pressKey(stdin, '\x1b[B'); // ↓ await waitFor(() => !term.text().includes('desc-line-00')); expect(term.text()).not.toContain('bravo short description'); - // 折叠后再展开,仍回到顶部 - await pressKey(stdin, '\x05'); // Ctrl+E → 收起 - await pressKey(stdin, '\x05'); // Ctrl+E → 再展开 - await waitFor(() => term.text().includes('desc-line-00')); + // Esc 把箭头交还工具列表,且**不折叠**描述、不离开工具视图 —— + // 这正是「展开后想切工具」那条反馈的判据。 + await pressKey(stdin, '\x1b'); // Esc + await waitFor(() => term.text().includes('↑/↓ Navigate')); + expect(term.text()).not.toContain('↑/↓ Scroll line'); - // 一直按到低:滚到尽头后继续 ↓ 才把选中项交给下一个工具 - for (let i = 0; i < 120 && !term.text().includes('bravo short description'); i += 1) { - await pressKey(stdin, '\x1b[B'); - } + // 列表焦点下 ↓ 立刻切换工具(无需先折叠),换到新工具后描述回到折叠态 + await pressKey(stdin, '\x1b[B'); // ↓ → bravo await waitFor(() => term.text().includes('bravo short description')); + expect(term.text()).toContain('Ctrl+E Expand desc'); instance.unmount(); });