From c1b43848907c136cb87392a8c48045c71c402ddb Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Wed, 4 Feb 2026 01:21:43 -0500 Subject: [PATCH] Remove legacy React/Ink TUI and fix TypeScript errors - Delete old tui/ React/Ink implementation (App.tsx, components/, hooks/, store.ts) - Migrate to tui-solid/ as the sole TUI implementation - Update tui/index.ts to re-export from tui-solid and @/types/tui TypeScript fixes: - Add missing PreCompact to hook event constants - Fix path aliases (@src/ -> @/, @constants/) - Remove unused imports across service files - Add explicit type annotations to callback parameters - Replace Bun.file/write with Node.js fs/promises in mcp/registry - Fix Map.some() -> Array.from().some() in registry - Fix addServer() call signature - Add missing description to brain-mcp schema items - Fix typo in progress-bar import (@interfactes -> @interfaces) --- .../callbacks/on-learning-detected.ts | 4 +- src/constants/hooks.ts | 3 + src/services/agent-definition-loader.ts | 21 +- src/services/background-task-service.ts | 9 +- src/services/brain/mcp-server.ts | 18 +- src/services/brain/project-service.ts | 11 +- src/services/confidence-filter.ts | 8 +- src/services/mcp/registry.ts | 24 +- src/services/session-compaction.ts | 1 - src/tui/App.tsx | 857 ------------------ src/tui/components/AgentSelect.tsx | 251 ----- src/tui/components/BouncingLoader.tsx | 81 -- src/tui/components/CommandMenu.tsx | 269 ------ src/tui/components/DiffView.tsx | 19 - src/tui/components/FilePicker.tsx | 168 ---- src/tui/components/Header.tsx | 54 -- src/tui/components/ImageAttachment.tsx | 74 -- src/tui/components/InputArea.tsx | 277 ------ src/tui/components/LearningModal.tsx | 75 -- src/tui/components/LogPanel.tsx | 8 - src/tui/components/MCPBrowser.tsx | 600 ------------ src/tui/components/MCPSelect.tsx | 533 ----------- src/tui/components/ModelSelect.tsx | 264 ------ src/tui/components/PermissionModal.tsx | 78 -- src/tui/components/SelectMenu.tsx | 104 --- src/tui/components/StatusBar.tsx | 205 ----- src/tui/components/StreamingMessage.tsx | 78 -- src/tui/components/ThemeSelect.tsx | 199 ---- src/tui/components/TodoPanel.tsx | 206 ----- src/tui/components/VimStatusLine.tsx | 150 --- src/tui/components/diff-view/index.tsx | 102 --- .../components/diff-view/line-renderers.tsx | 223 ----- src/tui/components/diff-view/utils.ts | 162 ---- src/tui/components/home/HomeContent.tsx | 66 -- src/tui/components/home/HomeFooter.tsx | 55 -- src/tui/components/home/HomeScreen.tsx | 69 -- src/tui/components/home/Logo.tsx | 27 - src/tui/components/home/PromptBox.tsx | 134 --- src/tui/components/home/SessionHeader.tsx | 161 ---- src/tui/components/home/index.ts | 11 - src/tui/components/index.ts | 44 - src/tui/components/input-line/index.tsx | 216 ----- .../components/log-panel/entry-renderers.tsx | 172 ---- src/tui/components/log-panel/index.tsx | 186 ---- .../log-panel/thinking-indicator.tsx | 31 - src/tui/components/log-panel/utils.ts | 36 - src/tui/hooks/index.ts | 16 - src/tui/hooks/useAutoScroll.ts | 286 ------ src/tui/hooks/useMouseScroll.ts | 109 --- src/tui/hooks/useThemeStore.ts | 43 - src/tui/hooks/useTodoStore.ts | 35 - src/tui/hooks/useVimMode.ts | 359 -------- src/tui/hooks/useVimStore.ts | 18 - src/tui/index.ts | 31 +- src/tui/mouse-handler.ts | 232 ----- src/tui/store.ts | 684 -------------- src/tui/types.ts | 34 - src/types/brain-mcp.ts | 6 +- src/types/parallel.ts | 2 +- src/utils/progress-bar.ts | 2 +- tsconfig.json | 2 +- 61 files changed, 80 insertions(+), 8123 deletions(-) delete mode 100644 src/tui/App.tsx delete mode 100644 src/tui/components/AgentSelect.tsx delete mode 100644 src/tui/components/BouncingLoader.tsx delete mode 100644 src/tui/components/CommandMenu.tsx delete mode 100644 src/tui/components/DiffView.tsx delete mode 100644 src/tui/components/FilePicker.tsx delete mode 100644 src/tui/components/Header.tsx delete mode 100644 src/tui/components/ImageAttachment.tsx delete mode 100644 src/tui/components/InputArea.tsx delete mode 100644 src/tui/components/LearningModal.tsx delete mode 100644 src/tui/components/LogPanel.tsx delete mode 100644 src/tui/components/MCPBrowser.tsx delete mode 100644 src/tui/components/MCPSelect.tsx delete mode 100644 src/tui/components/ModelSelect.tsx delete mode 100644 src/tui/components/PermissionModal.tsx delete mode 100644 src/tui/components/SelectMenu.tsx delete mode 100644 src/tui/components/StatusBar.tsx delete mode 100644 src/tui/components/StreamingMessage.tsx delete mode 100644 src/tui/components/ThemeSelect.tsx delete mode 100644 src/tui/components/TodoPanel.tsx delete mode 100644 src/tui/components/VimStatusLine.tsx delete mode 100644 src/tui/components/diff-view/index.tsx delete mode 100644 src/tui/components/diff-view/line-renderers.tsx delete mode 100644 src/tui/components/diff-view/utils.ts delete mode 100644 src/tui/components/home/HomeContent.tsx delete mode 100644 src/tui/components/home/HomeFooter.tsx delete mode 100644 src/tui/components/home/HomeScreen.tsx delete mode 100644 src/tui/components/home/Logo.tsx delete mode 100644 src/tui/components/home/PromptBox.tsx delete mode 100644 src/tui/components/home/SessionHeader.tsx delete mode 100644 src/tui/components/home/index.ts delete mode 100644 src/tui/components/index.ts delete mode 100644 src/tui/components/input-line/index.tsx delete mode 100644 src/tui/components/log-panel/entry-renderers.tsx delete mode 100644 src/tui/components/log-panel/index.tsx delete mode 100644 src/tui/components/log-panel/thinking-indicator.tsx delete mode 100644 src/tui/components/log-panel/utils.ts delete mode 100644 src/tui/hooks/index.ts delete mode 100644 src/tui/hooks/useAutoScroll.ts delete mode 100644 src/tui/hooks/useMouseScroll.ts delete mode 100644 src/tui/hooks/useThemeStore.ts delete mode 100644 src/tui/hooks/useTodoStore.ts delete mode 100644 src/tui/hooks/useVimMode.ts delete mode 100644 src/tui/hooks/useVimStore.ts delete mode 100644 src/tui/mouse-handler.ts delete mode 100644 src/tui/store.ts delete mode 100644 src/tui/types.ts diff --git a/src/commands/components/callbacks/on-learning-detected.ts b/src/commands/components/callbacks/on-learning-detected.ts index 6dd5446..bf76736 100644 --- a/src/commands/components/callbacks/on-learning-detected.ts +++ b/src/commands/components/callbacks/on-learning-detected.ts @@ -1,6 +1,6 @@ import { v4 as uuidv4 } from "uuid"; -import { appStore } from "@tui/index.ts"; -import type { LearningResponse } from "@tui/types.ts"; +import { appStore } from "@tui/index"; +import type { LearningResponse } from "@/types/tui"; import type { LearningCandidate } from "@services/learning-service.ts"; export const onLearningDetected = async ( diff --git a/src/constants/hooks.ts b/src/constants/hooks.ts index 1b45f29..d536cca 100644 --- a/src/constants/hooks.ts +++ b/src/constants/hooks.ts @@ -42,6 +42,7 @@ export const HOOK_EVENT_LABELS: Record = { SessionStart: "Session Start", SessionEnd: "Session End", UserPromptSubmit: "User Prompt Submit", + PreCompact: "Pre-Compact", Stop: "Stop", }; @@ -55,6 +56,7 @@ export const HOOK_EVENT_DESCRIPTIONS: Record = { SessionStart: "Runs when a new session begins.", SessionEnd: "Runs when a session ends.", UserPromptSubmit: "Runs when user submits a prompt. Can modify or block.", + PreCompact: "Runs before context compaction. For custom compaction logic.", Stop: "Runs when execution is stopped (interrupt, complete, or error).", }; @@ -67,6 +69,7 @@ export const HOOK_EVENT_TYPES: readonly HookEventType[] = [ "SessionStart", "SessionEnd", "UserPromptSubmit", + "PreCompact", "Stop", ] as const; diff --git a/src/services/agent-definition-loader.ts b/src/services/agent-definition-loader.ts index 840adaa..68c95b7 100644 --- a/src/services/agent-definition-loader.ts +++ b/src/services/agent-definition-loader.ts @@ -4,21 +4,20 @@ */ import { readFile, readdir } from "node:fs/promises"; -import { join, basename, extname } from "node:path"; +import { join, extname } from "node:path"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import type { AgentDefinition, AgentFrontmatter, - AgentDefinitionFile, AgentRegistry, AgentLoadResult, AgentTier, AgentColor, -} from "@src/types/agent-definition"; -import { DEFAULT_AGENT_DEFINITION, AGENT_DEFINITION_SCHEMA } from "@src/types/agent-definition"; -import { AGENT_DEFINITION, AGENT_DEFINITION_PATHS, AGENT_MESSAGES } from "@src/constants/agent-definition"; +} from "@/types/agent-definition"; +import { DEFAULT_AGENT_DEFINITION, AGENT_DEFINITION_SCHEMA } from "@/types/agent-definition"; +import { AGENT_DEFINITION, AGENT_DEFINITION_PATHS, AGENT_MESSAGES } from "@constants/agent-definition"; const parseFrontmatter = (content: string): { frontmatter: Record; body: string } | null => { const delimiter = AGENT_DEFINITION.FRONTMATTER_DELIMITER; @@ -207,12 +206,12 @@ export const loadAllAgentDefinitions = async ( agents.set(agent.name, agent); // Index by trigger phrases - agent.triggerPhrases?.forEach((phrase) => { + agent.triggerPhrases?.forEach((phrase: string) => { byTrigger.set(phrase.toLowerCase(), agent.name); }); // Index by capabilities - agent.capabilities?.forEach((capability) => { + agent.capabilities?.forEach((capability: string) => { const existing = byCapability.get(capability) || []; byCapability.set(capability, [...existing, agent.name]); }); @@ -245,8 +244,8 @@ export const findAgentsByCapability = ( ): ReadonlyArray => { const agentNames = registry.byCapability.get(capability) || []; return agentNames - .map((name) => registry.agents.get(name)) - .filter((a): a is AgentDefinition => a !== undefined); + .map((name: string) => registry.agents.get(name)) + .filter((a: AgentDefinition | undefined): a is AgentDefinition => a !== undefined); }; export const getAgentByName = ( @@ -273,12 +272,12 @@ export const createAgentDefinitionContent = (agent: AgentDefinition): string => if (agent.triggerPhrases && agent.triggerPhrases.length > 0) { frontmatter.push("triggerPhrases:"); - agent.triggerPhrases.forEach((phrase) => frontmatter.push(` - ${phrase}`)); + agent.triggerPhrases.forEach((phrase: string) => frontmatter.push(` - ${phrase}`)); } if (agent.capabilities && agent.capabilities.length > 0) { frontmatter.push("capabilities:"); - agent.capabilities.forEach((cap) => frontmatter.push(` - ${cap}`)); + agent.capabilities.forEach((cap: string) => frontmatter.push(` - ${cap}`)); } frontmatter.push("---"); diff --git a/src/services/background-task-service.ts b/src/services/background-task-service.ts index 62b0ee9..03f1243 100644 --- a/src/services/background-task-service.ts +++ b/src/services/background-task-service.ts @@ -19,16 +19,13 @@ import type { TaskError, TaskMetadata, TaskNotification, - TaskStep, - TaskArtifact, -} from "@src/types/background-task"; -import { DEFAULT_BACKGROUND_TASK_CONFIG, BACKGROUND_TASK_PRIORITIES } from "@src/types/background-task"; +} from "@/types/background-task"; +import { DEFAULT_BACKGROUND_TASK_CONFIG, BACKGROUND_TASK_PRIORITIES } from "@/types/background-task"; import { - BACKGROUND_TASK, BACKGROUND_TASK_STORAGE, BACKGROUND_TASK_MESSAGES, BACKGROUND_TASK_STATUS_ICONS, -} from "@src/constants/background-task"; +} from "@constants/background-task"; type TaskHandler = (task: BackgroundTask, updateProgress: (progress: Partial) => void) => Promise; type NotificationHandler = (notification: TaskNotification) => void; diff --git a/src/services/brain/mcp-server.ts b/src/services/brain/mcp-server.ts index 0e3a99a..d1519af 100644 --- a/src/services/brain/mcp-server.ts +++ b/src/services/brain/mcp-server.ts @@ -4,7 +4,6 @@ */ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"; -import { randomUUID } from "node:crypto"; import type { BrainMcpServerConfig, @@ -14,18 +13,17 @@ import type { BrainMcpToolName, McpContent, McpError, -} from "@src/types/brain-mcp"; + BrainMcpTool, +} from "@/types/brain-mcp"; import { DEFAULT_BRAIN_MCP_SERVER_CONFIG, BRAIN_MCP_TOOLS, MCP_ERROR_CODES, -} from "@src/types/brain-mcp"; +} from "@/types/brain-mcp"; import { - BRAIN_MCP_SERVER, BRAIN_MCP_MESSAGES, BRAIN_MCP_ERRORS, - BRAIN_MCP_AUTH, -} from "@src/constants/brain-mcp"; +} from "@constants/brain-mcp"; type BrainService = { recall: (query: string, limit?: number) => Promise; @@ -135,7 +133,7 @@ const handleToolCall = async ( throw createMcpError(MCP_ERROR_CODES.BRAIN_UNAVAILABLE, "Brain service not connected"); } - const tool = BRAIN_MCP_TOOLS.find((t) => t.name === toolName); + const tool = BRAIN_MCP_TOOLS.find((t: BrainMcpTool) => t.name === toolName); if (!tool) { throw createMcpError(MCP_ERROR_CODES.TOOL_NOT_FOUND, `Tool not found: ${toolName}`); } @@ -167,7 +165,7 @@ const handleToolCall = async ( brain_stats: () => state.brainService!.getStats(), brain_projects: async () => { // Import dynamically to avoid circular dependency - const { listProjects } = await import("@src/services/brain/project-service"); + const { listProjects } = await import("@services/brain/project-service"); return listProjects(); }, }; @@ -254,7 +252,7 @@ const handleRequest = async ( res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(createMcpResponse(mcpRequest.id, content))); } else if (mcpRequest.method === "tools/list") { - const tools = BRAIN_MCP_TOOLS.map((tool) => ({ + const tools = BRAIN_MCP_TOOLS.map((tool: BrainMcpTool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, @@ -351,4 +349,4 @@ export const updateConfig = (config: Partial): void => { }; export const getAvailableTools = (): ReadonlyArray<{ name: string; description: string }> => - BRAIN_MCP_TOOLS.map((t) => ({ name: t.name, description: t.description })); + BRAIN_MCP_TOOLS.map((t: BrainMcpTool) => ({ name: t.name, description: t.description })); diff --git a/src/services/brain/project-service.ts b/src/services/brain/project-service.ts index f923703..5b091fe 100644 --- a/src/services/brain/project-service.ts +++ b/src/services/brain/project-service.ts @@ -18,21 +18,16 @@ import type { BrainProjectListResult, BrainProjectExport, BrainProjectImportResult, - ExportedConcept, - ExportedMemory, - ExportedRelationship, -} from "@src/types/brain-project"; +} from "@/types/brain-project"; import { DEFAULT_BRAIN_PROJECT_SETTINGS, BRAIN_PROJECT_EXPORT_VERSION, -} from "@src/types/brain-project"; +} from "@/types/brain-project"; import { BRAIN_PROJECT, BRAIN_PROJECT_STORAGE, - BRAIN_PROJECT_PATHS, BRAIN_PROJECT_MESSAGES, - BRAIN_PROJECT_API, -} from "@src/constants/brain-project"; +} from "@constants/brain-project"; interface ProjectServiceState { projects: Map; diff --git a/src/services/confidence-filter.ts b/src/services/confidence-filter.ts index 6d90ad2..16e1766 100644 --- a/src/services/confidence-filter.ts +++ b/src/services/confidence-filter.ts @@ -11,12 +11,12 @@ import type { FilteredResult, ValidationResult, ConfidenceFilterStats, -} from "@src/types/confidence-filter"; +} from "@/types/confidence-filter"; import { CONFIDENCE_LEVELS, DEFAULT_CONFIDENCE_FILTER_CONFIG, -} from "@src/types/confidence-filter"; -import { CONFIDENCE_FILTER, CONFIDENCE_WEIGHTS } from "@src/constants/confidence-filter"; +} from "@/types/confidence-filter"; +import { CONFIDENCE_FILTER, CONFIDENCE_WEIGHTS } from "@constants/confidence-filter"; export const calculateConfidenceLevel = (score: number): ConfidenceLevel => { const levels = Object.entries(CONFIDENCE_LEVELS) as Array<[ConfidenceLevel, { min: number; max: number }]>; @@ -158,7 +158,7 @@ export const formatConfidenceScore = (confidence: ConfidenceScore, showFactors: if (showFactors && confidence.factors.length > 0) { const factorLines = confidence.factors - .map((f) => ` - ${f.name}: ${f.score}% (weight: ${f.weight})`) + .map((f: ConfidenceFactor) => ` - ${f.name}: ${f.score}% (weight: ${f.weight})`) .join("\n"); result += `\n${factorLines}`; } diff --git a/src/services/mcp/registry.ts b/src/services/mcp/registry.ts index 58f6dfa..44905e2 100644 --- a/src/services/mcp/registry.ts +++ b/src/services/mcp/registry.ts @@ -19,7 +19,6 @@ import { MCP_REGISTRY_CACHE, MCP_REGISTRY_SOURCES, MCP_REGISTRY_ERRORS, - MCP_REGISTRY_SUCCESS, MCP_SEARCH_DEFAULTS, } from "@constants/mcp-registry"; import { addServer, connectServer, getServerInstances } from "./manager"; @@ -42,11 +41,9 @@ const getCacheFilePath = (): string => { const loadCache = async (): Promise => { try { const cachePath = getCacheFilePath(); - const file = Bun.file(cachePath); - if (await file.exists()) { - const data = await file.json(); - return data as MCPRegistryCache; - } + const fs = await import("fs/promises"); + const content = await fs.readFile(cachePath, "utf-8"); + return JSON.parse(content) as MCPRegistryCache; } catch { // Cache doesn't exist or is invalid } @@ -59,7 +56,10 @@ const loadCache = async (): Promise => { const saveCache = async (cache: MCPRegistryCache): Promise => { try { const cachePath = getCacheFilePath(); - await Bun.write(cachePath, JSON.stringify(cache, null, 2)); + const fs = await import("fs/promises"); + const path = await import("path"); + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, JSON.stringify(cache, null, 2)); } catch { // Ignore cache write errors } @@ -300,7 +300,7 @@ export const getServersByCategory = async ( */ export const isServerInstalled = (serverId: string): boolean => { const instances = getServerInstances(); - return instances.some((instance) => + return Array.from(instances.values()).some((instance) => instance.config.name === serverId || instance.config.name.toLowerCase() === serverId.toLowerCase() ); @@ -332,8 +332,8 @@ export const installServer = async ( try { // Add server to configuration await addServer( + server.id, { - name: server.id, command: server.command, args: customArgs || server.args, transport: server.transport, @@ -447,10 +447,8 @@ export const clearRegistryCache = async (): Promise => { registryCache = null; try { const cachePath = getCacheFilePath(); - const file = Bun.file(cachePath); - if (await file.exists()) { - await Bun.write(cachePath, ""); - } + const fs = await import("fs/promises"); + await fs.unlink(cachePath); } catch { // Ignore } diff --git a/src/services/session-compaction.ts b/src/services/session-compaction.ts index 9daf859..332d25e 100644 --- a/src/services/session-compaction.ts +++ b/src/services/session-compaction.ts @@ -21,7 +21,6 @@ import { compactConversation, checkCompactionNeeded, getModelCompactionConfig, - createCompactionSummary, } from "@services/auto-compaction"; import { appStore } from "@tui-solid/context/app"; diff --git a/src/tui/App.tsx b/src/tui/App.tsx deleted file mode 100644 index 2bf6889..0000000 --- a/src/tui/App.tsx +++ /dev/null @@ -1,857 +0,0 @@ -/** - * Main TUI Application Component - */ - -import React, { - useEffect, - useCallback, - useState, - useRef, - useMemo, -} from "react"; -import { Box, useApp, useInput, Text } from "ink"; -import { useAppStore } from "@tui/store"; -import { - LogPanel, - PermissionModal, - LearningModal, - StatusBar, - CommandMenu, - ModelSelect, - AgentSelect, - ThemeSelect, - MCPSelect, - MCPBrowser, - TodoPanel, - FilePicker, - SessionHeader, -} from "@tui/components/index"; -import { InputLine, calculateLineStartPos } from "@tui/components/input-line"; -import { useThemeStore, useThemeColors } from "@tui/hooks/useThemeStore"; -import type { AgentConfig } from "@/types/agent-config"; -import { createFilePickerState } from "@/services/file-picker-service"; -import { INTERRUPT_TIMEOUT } from "@constants/ui"; -import type { AppProps } from "@interfaces/AppProps"; -import { - isMouseEscapeSequence, - cleanInput, - insertAtCursor, - deleteBeforeCursor, - calculateCursorPosition, -} from "@utils/tui-app/input-utils"; - -import { - isInputLocked, - isModalCommand, - isMainInputActive as checkMainInputActive, - isProcessing, -} from "@utils/tui-app/mode-utils"; - -import { - shouldSummarizePaste, - addPastedBlock, - updatePastedBlocksAfterDelete, - expandPastedContent, - normalizeLineEndings, - clearPastedBlocks, -} from "@utils/tui-app/paste-utils"; - -import { PAGE_SCROLL_LINES, MOUSE_SCROLL_LINES } from "@constants/auto-scroll"; -import { useMouseScroll } from "@tui/hooks"; -import type { PasteState } from "@interfaces/PastedContent"; -import { createInitialPasteState } from "@interfaces/PastedContent"; -import { readClipboardImage } from "@services/clipboard-service"; -import { ImageAttachment } from "@tui/components/ImageAttachment"; - -// Re-export for backwards compatibility -export type { AppProps } from "@interfaces/AppProps"; - -export function App({ - sessionId, - provider, - model, - agent = "coder", - version, - onSubmit, - onExit, - onCommand, - onModelSelect, - onAgentSelect, - onThemeSelect, -}: AppProps): React.ReactElement { - const { exit } = useApp(); - const setSessionInfo = useAppStore((state) => state.setSessionInfo); - const setMode = useAppStore((state) => state.setMode); - const addLog = useAppStore((state) => state.addLog); - const mode = useAppStore((state) => state.mode); - const permissionRequest = useAppStore((state) => state.permissionRequest); - const learningPrompt = useAppStore((state) => state.learningPrompt); - const openCommandMenu = useAppStore((state) => state.openCommandMenu); - const closeCommandMenu = useAppStore((state) => state.closeCommandMenu); - const interruptPending = useAppStore((state) => state.interruptPending); - const setInterruptPending = useAppStore((state) => state.setInterruptPending); - const exitPending = useAppStore((state) => state.exitPending); - const setExitPending = useAppStore((state) => state.setExitPending); - const toggleTodos = useAppStore((state) => state.toggleTodos); - const toggleInteractionMode = useAppStore( - (state) => state.toggleInteractionMode, - ); - const interactionMode = useAppStore((state) => state.interactionMode); - const startThinking = useAppStore((state) => state.startThinking); - const stopThinking = useAppStore((state) => state.stopThinking); - const scrollUp = useAppStore((state) => state.scrollUp); - const scrollDown = useAppStore((state) => state.scrollDown); - const scrollToTop = useAppStore((state) => state.scrollToTop); - const scrollToBottom = useAppStore((state) => state.scrollToBottom); - const screenMode = useAppStore((state) => state.screenMode); - const setScreenMode = useAppStore((state) => state.setScreenMode); - const sessionStats = useAppStore((state) => state.sessionStats); - const brain = useAppStore((state) => state.brain); - const dismissBrainBanner = useAppStore((state) => state.dismissBrainBanner); - - // Local input state - const [inputBuffer, setInputBuffer] = useState(""); - const [cursorPos, setCursorPos] = useState(0); - - // Paste state for virtual text - const [pasteState, setPasteState] = useState( - createInitialPasteState, - ); - - // File picker state - const [filePickerOpen, setFilePickerOpen] = useState(false); - const [filePickerQuery, setFilePickerQuery] = useState(""); - const [filePickerTrigger, setFilePickerTrigger] = useState<"@" | "/">("@"); - const filePickerState = useMemo( - () => createFilePickerState(process.cwd()), - [], - ); - - // Interrupt timeout ref - const interruptTimeoutRef = useRef | null>( - null, - ); - - // Exit timeout ref - const exitTimeoutRef = useRef | null>(null); - - const isLocked = isInputLocked(mode); - const isCommandMenuOpen = mode === "command_menu"; - const isModelSelectOpen = mode === "model_select"; - const isAgentSelectOpen = mode === "agent_select"; - const isThemeSelectOpen = mode === "theme_select"; - const isMCPSelectOpen = mode === "mcp_select"; - const isMCPBrowserOpen = mode === "mcp_browse"; - const isLearningPromptOpen = mode === "learning_prompt"; - - // Theme colors - const colors = useThemeColors(); - const setTheme = useThemeStore((state) => state.setTheme); - - // Mouse scroll handling - scroll up pauses auto-scroll, scroll down toward bottom resumes - useMouseScroll({ - onScrollUp: () => scrollUp(MOUSE_SCROLL_LINES), - onScrollDown: () => scrollDown(MOUSE_SCROLL_LINES), - enabled: true, - }); - - // Main input should only be active when not in special modes - const mainInputActive = - checkMainInputActive(mode, isLocked) && !filePickerOpen; - - // Initialize session info - useEffect(() => { - setSessionInfo(sessionId, provider, model); - }, [sessionId, provider, model, setSessionInfo]); - - // Cleanup timeouts on unmount - useEffect(() => { - return () => { - if (interruptTimeoutRef.current) { - clearTimeout(interruptTimeoutRef.current); - } - if (exitTimeoutRef.current) { - clearTimeout(exitTimeoutRef.current); - } - }; - }, []); - - // Handle message submission - const handleSubmit = useCallback( - async (message: string) => { - // Expand pasted content before submitting - const expandedMessage = expandPastedContent( - message, - pasteState.pastedBlocks, - ); - - // Capture images before clearing - const images = - pasteState.pastedImages.length > 0 - ? [...pasteState.pastedImages] - : undefined; - - // Clear paste state after expanding - setPasteState(clearPastedBlocks()); - - // Transition to session view when first message is sent - if (screenMode === "home") { - setScreenMode("session"); - } - - // Build log content with image indicator - const logContent = images - ? `${expandedMessage}\n[${images.length} image${images.length > 1 ? "s" : ""} attached]` - : expandedMessage; - - addLog({ type: "user", content: logContent }); - setMode("thinking"); - startThinking(); - - try { - await onSubmit(expandedMessage, { images }); - } finally { - stopThinking(); - setMode("idle"); - } - }, - [ - onSubmit, - addLog, - setMode, - startThinking, - stopThinking, - screenMode, - setScreenMode, - pasteState.pastedBlocks, - pasteState.pastedImages, - ], - ); - - // Handle command selection from menu - const handleCommandSelect = useCallback( - async (command: string) => { - closeCommandMenu(); - setInputBuffer(""); - setCursorPos(0); - - // Commands that open their own modal will set their own mode - if (!isModalCommand(command)) { - setMode("idle"); - } - - if (onCommand) { - await onCommand(command); - } - }, - [onCommand, closeCommandMenu, setMode], - ); - - // Handle model selection - const handleModelSelect = useCallback( - (selectedModel: string) => { - setMode("idle"); - if (onModelSelect) { - onModelSelect(selectedModel); - } - }, - [onModelSelect, setMode], - ); - - // Handle model select close - const handleModelSelectClose = useCallback(() => { - setMode("idle"); - }, [setMode]); - - // Handle agent selection - const handleAgentSelect = useCallback( - (agentId: string, agentConfig: AgentConfig) => { - setMode("idle"); - if (onAgentSelect) { - onAgentSelect(agentId, agentConfig); - } - addLog({ - type: "system", - content: `Switched to agent: ${agentConfig.name}${agentConfig.description ? `\n${agentConfig.description}` : ""}`, - }); - }, - [onAgentSelect, setMode, addLog], - ); - - // Handle agent select close - const handleAgentSelectClose = useCallback(() => { - setMode("idle"); - }, [setMode]); - - // Handle theme selection - const handleThemeSelect = useCallback( - (themeName: string) => { - setTheme(themeName); - setMode("idle"); - if (onThemeSelect) { - onThemeSelect(themeName); - } - addLog({ - type: "system", - content: `Theme changed to: ${themeName}`, - }); - }, - [setTheme, setMode, onThemeSelect, addLog], - ); - - // Handle theme select close - const handleThemeSelectClose = useCallback(() => { - setMode("idle"); - }, [setMode]); - - // Handle MCP select close - const handleMCPSelectClose = useCallback(() => { - setMode("idle"); - }, [setMode]); - - // Handle MCP browser close - const handleMCPBrowserClose = useCallback(() => { - setMode("idle"); - }, [setMode]); - - // Handle file selection from picker - const handleFileSelect = useCallback( - (path: string): void => { - const fileRef = `${filePickerTrigger}${path}`; - const { newBuffer, newCursorPos } = insertAtCursor( - inputBuffer, - cursorPos, - fileRef + " ", - ); - setInputBuffer(newBuffer); - setCursorPos(newCursorPos); - setFilePickerOpen(false); - setFilePickerQuery(""); - }, - [filePickerTrigger, inputBuffer, cursorPos], - ); - - // Handle file picker cancel - const handleFilePickerCancel = useCallback((): void => { - setFilePickerOpen(false); - setFilePickerQuery(""); - }, []); - - // Handle command menu cancel (Escape or backspace with no filter) - const handleCommandMenuCancel = useCallback((): void => { - setInputBuffer(""); - setCursorPos(0); - }, []); - - // Handle interrupt logic - const handleInterrupt = useCallback(() => { - if (interruptPending) { - // Second press - actually interrupt - if (interruptTimeoutRef.current) { - clearTimeout(interruptTimeoutRef.current); - interruptTimeoutRef.current = null; - } - setInterruptPending(false); - stopThinking(); - addLog({ type: "system", content: "Operation interrupted" }); - setMode("idle"); - } else { - // First press - set pending and start timeout - setInterruptPending(true); - interruptTimeoutRef.current = setTimeout(() => { - setInterruptPending(false); - interruptTimeoutRef.current = null; - }, INTERRUPT_TIMEOUT); - } - }, [interruptPending, setInterruptPending, stopThinking, addLog, setMode]); - - // Global input handler for Ctrl+C, Ctrl+D, Ctrl+T, scroll (always active) - useInput((input, key) => { - // Handle Ctrl+M to toggle interaction mode (Ctrl+Tab doesn't work in most terminals) - if (key.ctrl && input === "m") { - toggleInteractionMode(); - // Note: The log will show the new mode after toggle - const newMode = useAppStore.getState().interactionMode; - addLog({ - type: "system", - content: `Switched to ${newMode} mode (Ctrl+M)`, - }); - return; - } - - // Handle Ctrl+T to toggle todos visibility (only in agent/code-review modes) - if (key.ctrl && input === "t") { - const currentMode = useAppStore.getState().interactionMode; - if (currentMode === "agent" || currentMode === "code-review") { - toggleTodos(); - } - return; - } - - // Handle Page Up/Down for scrolling (works even when locked) - if (key.pageUp) { - scrollUp(PAGE_SCROLL_LINES); - return; - } - - if (key.pageDown) { - scrollDown(PAGE_SCROLL_LINES); - return; - } - - // Handle Ctrl+Home/End for scroll to top/bottom - if (key.ctrl && key.home) { - scrollToTop(); - return; - } - - if (key.ctrl && key.end) { - scrollToBottom(); - return; - } - - // Handle Shift+Up/Down for scrolling - if (key.shift && key.upArrow) { - scrollUp(3); - return; - } - - if (key.shift && key.downArrow) { - scrollDown(3); - return; - } - - // Handle Ctrl+C - if (key.ctrl && input === "c") { - if (isProcessing(mode)) { - handleInterrupt(); - } else if (filePickerOpen) { - handleFilePickerCancel(); - } else if ( - isCommandMenuOpen || - isModelSelectOpen || - isAgentSelectOpen || - isThemeSelectOpen || - isMCPSelectOpen || - isMCPBrowserOpen || - isLearningPromptOpen - ) { - closeCommandMenu(); - setMode("idle"); - } else if (!permissionRequest && !learningPrompt) { - if (exitPending) { - // Second press - actually exit - if (exitTimeoutRef.current) { - clearTimeout(exitTimeoutRef.current); - exitTimeoutRef.current = null; - } - setExitPending(false); - onExit(); - exit(); - } else { - // First press - set pending and show message - setExitPending(true); - addLog({ type: "system", content: "Press Ctrl+C again to quit" }); - exitTimeoutRef.current = setTimeout(() => { - setExitPending(false); - exitTimeoutRef.current = null; - }, INTERRUPT_TIMEOUT); - } - } - return; - } - - // Handle Ctrl+D to exit - if (key.ctrl && input === "d") { - if ((mode === "idle" || mode === "editing") && inputBuffer.length === 0) { - onExit(); - exit(); - } - return; - } - }); - - // Main input handler - only active when in normal input mode - useInput( - (input, key) => { - // Skip if this is a control sequence handled by the global handler - if (key.ctrl && (input === "c" || input === "d" || input === "t")) { - return; - } - - // Handle Enter - if (key.return) { - if (key.meta) { - // Alt+Enter: insert newline - const { newBuffer, newCursorPos } = insertAtCursor( - inputBuffer, - cursorPos, - "\n", - ); - setInputBuffer(newBuffer); - setCursorPos(newCursorPos); - } else { - // Plain Enter: submit - const message = inputBuffer.trim(); - if (message) { - handleSubmit(message); - setInputBuffer(""); - setCursorPos(0); - // Note: paste state is cleared in handleSubmit after expansion - } - } - return; - } - - // Handle Escape - if (key.escape) { - return; - } - - // Handle backspace - if (key.backspace || key.delete) { - const { newBuffer, newCursorPos } = deleteBeforeCursor( - inputBuffer, - cursorPos, - ); - setInputBuffer(newBuffer); - setCursorPos(newCursorPos); - - // Update pasted block positions after deletion - if (pasteState.pastedBlocks.size > 0) { - const deleteLength = inputBuffer.length - newBuffer.length; - const deletePos = newCursorPos; - const updatedBlocks = updatePastedBlocksAfterDelete( - pasteState.pastedBlocks, - deletePos, - deleteLength, - ); - setPasteState((prev) => ({ - ...prev, - pastedBlocks: updatedBlocks, - })); - } - return; - } - - // Handle arrow keys - if (key.leftArrow) { - if (cursorPos > 0) setCursorPos(cursorPos - 1); - return; - } - - if (key.rightArrow) { - if (cursorPos < inputBuffer.length) setCursorPos(cursorPos + 1); - return; - } - - if (key.upArrow || key.downArrow) { - return; - } - - // Handle Ctrl shortcuts - if (key.ctrl) { - if (input === "u") { - setInputBuffer(""); - setCursorPos(0); - setPasteState(clearPastedBlocks()); - } else if (input === "a") { - setCursorPos(0); - } else if (input === "e") { - setCursorPos(inputBuffer.length); - } else if (input === "k") { - setInputBuffer(inputBuffer.slice(0, cursorPos)); - // Update paste state for kill to end of line - if (pasteState.pastedBlocks.size > 0) { - const deleteLength = inputBuffer.length - cursorPos; - const updatedBlocks = updatePastedBlocksAfterDelete( - pasteState.pastedBlocks, - cursorPos, - deleteLength, - ); - setPasteState((prev) => ({ - ...prev, - pastedBlocks: updatedBlocks, - })); - } - } else if (input === "v" || input === "\x16") { - // Handle Ctrl+V for image paste (v or raw control character) - readClipboardImage().then((image) => { - if (image) { - setPasteState((prev) => ({ - ...prev, - pastedImages: [...prev.pastedImages, image], - })); - addLog({ - type: "system", - content: `Image attached (${image.mediaType})`, - }); - } - }); - } else if (input === "i") { - // Handle Ctrl+I as alternative for image paste - readClipboardImage().then((image) => { - if (image) { - setPasteState((prev) => ({ - ...prev, - pastedImages: [...prev.pastedImages, image], - })); - addLog({ - type: "system", - content: `Image attached (${image.mediaType})`, - }); - } else { - addLog({ - type: "system", - content: "No image found in clipboard", - }); - } - }); - } - return; - } - - // Handle Cmd+V (macOS) for image paste - if (key.meta && (input === "v" || input === "\x16")) { - readClipboardImage().then((image) => { - if (image) { - setPasteState((prev) => ({ - ...prev, - pastedImages: [...prev.pastedImages, image], - })); - addLog({ - type: "system", - content: `Image attached (${image.mediaType})`, - }); - } - }); - return; - } - - // Skip meta key combinations - if (key.meta) { - return; - } - - // Check for '/' at start of input to open command menu - if (input === "/" && inputBuffer.length === 0) { - openCommandMenu(); - setInputBuffer("/"); - setCursorPos(1); - return; - } - - // Check for '@' to open file picker - if (input === "@") { - setFilePickerTrigger("@"); - setFilePickerQuery(""); - setFilePickerOpen(true); - return; - } - - // Filter out mouse escape sequences - if (isMouseEscapeSequence(input)) { - return; - } - - // Regular character input - only accept printable characters - if (input && input.length > 0) { - const cleaned = cleanInput(input); - if (cleaned.length > 0) { - // Normalize line endings for pasted content - const normalizedContent = normalizeLineEndings(cleaned); - - // Check if this is a paste that should be summarized - if (shouldSummarizePaste(normalizedContent)) { - // Create virtual text placeholder for large paste - const { newState, pastedContent } = addPastedBlock( - pasteState, - normalizedContent, - cursorPos, - ); - - // Insert placeholder + space into buffer - const placeholderWithSpace = pastedContent.placeholder + " "; - const { newBuffer, newCursorPos } = insertAtCursor( - inputBuffer, - cursorPos, - placeholderWithSpace, - ); - - setInputBuffer(newBuffer); - setCursorPos(newCursorPos); - setPasteState(newState); - } else { - // Normal input - insert as-is - const { newBuffer, newCursorPos } = insertAtCursor( - inputBuffer, - cursorPos, - normalizedContent, - ); - setInputBuffer(newBuffer); - setCursorPos(newCursorPos); - } - } - } - }, - { isActive: mainInputActive }, - ); - - // Render input area inline - const lines = inputBuffer.split("\n"); - const isEmpty = inputBuffer.length === 0; - const { line: cursorLine, col: cursorCol } = calculateCursorPosition( - inputBuffer, - cursorPos, - ); - - // Calculate token count for session header - const totalTokens = sessionStats.inputTokens + sessionStats.outputTokens; - - return ( - - {/* Always show session header and status bar */} - - - - - {/* Main content area with all panes */} - - - {/* LogPanel shows logo when empty, logs otherwise */} - - - - - - - - {/* Command Menu Overlay */} - {isCommandMenuOpen && ( - - )} - - {/* Model Select Overlay */} - {isModelSelectOpen && ( - - )} - - {/* Agent Select Overlay */} - {isAgentSelectOpen && ( - - )} - - {/* Theme Select Overlay */} - {isThemeSelectOpen && ( - - )} - - {/* MCP Select Overlay */} - {isMCPSelectOpen && ( - setMode("mcp_browse")} - isActive={isMCPSelectOpen} - /> - )} - - {/* MCP Browser Overlay */} - {isMCPBrowserOpen && ( - - )} - - {/* File Picker Overlay */} - {filePickerOpen && ( - - )} - - {/* Inline Input Area */} - - {/* Show attached images */} - {pasteState.pastedImages.length > 0 && ( - - )} - - {isLocked ? ( - Input locked during execution... - ) : isEmpty ? ( - - > - - T - - ype your message... - - ) : ( - lines.map((line, i) => ( - - {i === 0 ? "> " : "│ "} - - - )) - )} - - - Enter • @ files • Ctrl+M mode • Ctrl+I image - - - - - ); -} diff --git a/src/tui/components/AgentSelect.tsx b/src/tui/components/AgentSelect.tsx deleted file mode 100644 index 3dc733b..0000000 --- a/src/tui/components/AgentSelect.tsx +++ /dev/null @@ -1,251 +0,0 @@ -/** - * AgentSelect Component - Agent selection menu - * - * Shows available agents loaded from .codetyper/agent/*.agent.md files - */ - -import React, { useState, useMemo, useEffect } from "react"; -import { Box, Text, useInput } from "ink"; -import { agentLoader } from "@services/agent-loader"; -import type { AgentConfig } from "@/types/agent-config"; - -interface AgentSelectProps { - onSelect: (agentId: string, agent: AgentConfig) => void; - onClose: () => void; - currentAgent?: string; - isActive?: boolean; -} - -const MAX_VISIBLE = 10; - -export function AgentSelect({ - onSelect, - onClose, - currentAgent = "coder", - isActive = true, -}: AgentSelectProps): React.ReactElement { - const [agents, setAgents] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - const [filter, setFilter] = useState(""); - const [loading, setLoading] = useState(true); - - // Load agents on mount - useEffect(() => { - const loadAgentsAsync = async () => { - setLoading(true); - const availableAgents = await agentLoader.getAvailableAgents( - process.cwd(), - ); - setAgents(availableAgents); - setLoading(false); - - // Set initial selection to current agent - const currentIdx = availableAgents.findIndex( - (a) => a.id === currentAgent, - ); - if (currentIdx >= 0) { - setSelectedIndex(currentIdx); - if (currentIdx >= MAX_VISIBLE) { - setScrollOffset(currentIdx - MAX_VISIBLE + 1); - } - } - }; - - loadAgentsAsync(); - }, [currentAgent]); - - // Filter agents based on input - const filteredAgents = useMemo(() => { - if (!filter) return agents; - const query = filter.toLowerCase(); - return agents.filter( - (agent) => - agent.id.toLowerCase().includes(query) || - agent.name.toLowerCase().includes(query) || - agent.description.toLowerCase().includes(query), - ); - }, [agents, filter]); - - useInput( - (input, key) => { - if (!isActive) return; - - // Escape to close - if (key.escape) { - onClose(); - return; - } - - // Enter to select - if (key.return) { - if (filteredAgents.length > 0) { - const selected = filteredAgents[selectedIndex]; - if (selected) { - onSelect(selected.id, selected); - onClose(); - } - } - return; - } - - // Navigate up - if (key.upArrow) { - setSelectedIndex((prev) => { - const newIndex = prev > 0 ? prev - 1 : filteredAgents.length - 1; - if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - if (prev === 0 && newIndex === filteredAgents.length - 1) { - setScrollOffset(Math.max(0, filteredAgents.length - MAX_VISIBLE)); - } - return newIndex; - }); - return; - } - - // Navigate down - if (key.downArrow) { - setSelectedIndex((prev) => { - const newIndex = prev < filteredAgents.length - 1 ? prev + 1 : 0; - if (newIndex >= scrollOffset + MAX_VISIBLE) { - setScrollOffset(newIndex - MAX_VISIBLE + 1); - } - if (prev === filteredAgents.length - 1 && newIndex === 0) { - setScrollOffset(0); - } - return newIndex; - }); - return; - } - - // Backspace - if (key.backspace || key.delete) { - if (filter.length > 0) { - setFilter(filter.slice(0, -1)); - setSelectedIndex(0); - setScrollOffset(0); - } - return; - } - - // Regular character input for filtering - if (input && !key.ctrl && !key.meta) { - setFilter(filter + input); - setSelectedIndex(0); - setScrollOffset(0); - } - }, - { isActive }, - ); - - if (loading) { - return ( - - - Loading agents... - - - ); - } - - return ( - - - - Select Agent - - {filter && ( - <> - - filtering: - {filter} - - )} - - - - Current: - - {agents.find((a) => a.id === currentAgent)?.name ?? currentAgent} - - - - {filteredAgents.length === 0 ? ( - No agents match "{filter}" - ) : ( - - {/* Scroll up indicator */} - {scrollOffset > 0 && ( - ↑ {scrollOffset} more above - )} - - {/* Visible agents */} - {filteredAgents - .slice(scrollOffset, scrollOffset + MAX_VISIBLE) - .map((agent, visibleIndex) => { - const actualIndex = scrollOffset + visibleIndex; - const isSelected = actualIndex === selectedIndex; - const isCurrent = agent.id === currentAgent; - const isDefault = agent.id === "coder"; - return ( - - - - {isSelected ? "> " : " "} - - - {agent.name} - - {isCurrent && (current)} - - {agent.description && ( - - {agent.description} - - )} - - ); - })} - - {/* Scroll down indicator */} - {scrollOffset + MAX_VISIBLE < filteredAgents.length && ( - - {" "} - ↓ {filteredAgents.length - scrollOffset - MAX_VISIBLE} more below - - )} - - )} - - - - ↑↓ navigate | Enter select | Type to filter | Esc close - - - - ); -} diff --git a/src/tui/components/BouncingLoader.tsx b/src/tui/components/BouncingLoader.tsx deleted file mode 100644 index ea75f7c..0000000 --- a/src/tui/components/BouncingLoader.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Bouncing Loader Component - * - * A horizontal bouncing loader with gradient colors. - * The active dot bounces back and forth with a colorful trail. - */ - -import React, { useEffect, useState } from "react"; -import { Text } from "ink"; - -const LOADER_COLORS = [ - "#ff00ff", // magenta - "#ff33ff", // light magenta - "#cc66ff", // purple - "#9966ff", // violet - "#6699ff", // blue-violet - "#33ccff", // cyan - "#00ffff", // cyan bright - "#33ffcc", // teal -] as const; - -const LOADER_CONFIG = { - dotCount: 8, - frameInterval: 100, - dotChar: "●", - emptyChar: "○", -} as const; - -export function BouncingLoader(): React.ReactElement { - const [position, setPosition] = useState(0); - const [direction, setDirection] = useState(1); - - useEffect(() => { - const timer = setInterval(() => { - setPosition((prev) => { - const next = prev + direction; - - if (next >= LOADER_CONFIG.dotCount - 1) { - setDirection(-1); - return LOADER_CONFIG.dotCount - 1; - } - - if (next <= 0) { - setDirection(1); - return 0; - } - - return next; - }); - }, LOADER_CONFIG.frameInterval); - - return () => clearInterval(timer); - }, [direction]); - - const dots = Array.from({ length: LOADER_CONFIG.dotCount }, (_, i) => { - const distance = Math.abs(i - position); - const isActive = distance === 0; - const isTrail = distance <= 2; - - // Get color based on position in the gradient - const colorIndex = i % LOADER_COLORS.length; - const color = LOADER_COLORS[colorIndex]; - - return { - char: - isActive || isTrail ? LOADER_CONFIG.dotChar : LOADER_CONFIG.emptyChar, - color, - dimColor: !isActive && !isTrail, - }; - }); - - return ( - - {dots.map((dot, i) => ( - - {dot.char} - - ))} - - ); -} diff --git a/src/tui/components/CommandMenu.tsx b/src/tui/components/CommandMenu.tsx deleted file mode 100644 index 0adbaa8..0000000 --- a/src/tui/components/CommandMenu.tsx +++ /dev/null @@ -1,269 +0,0 @@ -/** - * CommandMenu Component - Slash command selection menu - * - * Shows when user types '/' and provides filterable command list - * Supports scrolling for small terminal windows - */ - -import React, { useMemo, useState, useEffect } from "react"; -import { Box, Text, useInput } from "ink"; -import { useAppStore } from "@tui/store"; -import type { - SlashCommand, - CommandMenuProps, - CommandCategory, -} from "@/types/tui"; -import { SLASH_COMMANDS, COMMAND_CATEGORIES } from "@constants/tui-components"; - -// Re-export for backwards compatibility -export { SLASH_COMMANDS } from "@constants/tui-components"; - -// Maximum visible items before scrolling -const MAX_VISIBLE = 12; - -interface CommandWithIndex extends SlashCommand { - flatIndex: number; -} - -const filterCommands = ( - commands: readonly SlashCommand[], - filter: string, -): SlashCommand[] => { - if (!filter) return [...commands]; - const query = filter.toLowerCase(); - return commands.filter( - (cmd) => - cmd.name.toLowerCase().includes(query) || - cmd.description.toLowerCase().includes(query), - ); -}; - -const groupCommandsByCategory = ( - commands: SlashCommand[], -): Array<{ category: CommandCategory; commands: SlashCommand[] }> => { - return COMMAND_CATEGORIES.map((cat) => ({ - category: cat, - commands: commands.filter((cmd) => cmd.category === cat), - })).filter((group) => group.commands.length > 0); -}; - -const capitalizeCategory = (category: string): string => - category.charAt(0).toUpperCase() + category.slice(1); - -export function CommandMenu({ - onSelect, - onCancel, - isActive = true, -}: CommandMenuProps): React.ReactElement | null { - const commandMenu = useAppStore((state) => state.commandMenu); - const closeCommandMenu = useAppStore((state) => state.closeCommandMenu); - const setCommandFilter = useAppStore((state) => state.setCommandFilter); - const setCommandSelectedIndex = useAppStore( - (state) => state.setCommandSelectedIndex, - ); - - // Scroll offset for viewport - const [scrollOffset, setScrollOffset] = useState(0); - - // Filter commands based on input - const filteredCommands = useMemo( - () => filterCommands(SLASH_COMMANDS, commandMenu.filter), - [commandMenu.filter], - ); - - // Reset scroll when filter changes - useEffect(() => { - setScrollOffset(0); - }, [commandMenu.filter]); - - // Ensure selected index is visible - useEffect(() => { - if (commandMenu.selectedIndex < scrollOffset) { - setScrollOffset(commandMenu.selectedIndex); - } else if (commandMenu.selectedIndex >= scrollOffset + MAX_VISIBLE) { - setScrollOffset(commandMenu.selectedIndex - MAX_VISIBLE + 1); - } - }, [commandMenu.selectedIndex, scrollOffset]); - - // Handle keyboard input - useInput( - (input, key) => { - if (!isActive || !commandMenu.isOpen) return; - - // Escape to close - if (key.escape) { - closeCommandMenu(); - onCancel?.(); - return; - } - - // Enter to select - if (key.return) { - if (filteredCommands.length > 0) { - const selected = filteredCommands[commandMenu.selectedIndex]; - if (selected) { - onSelect(selected.name); - } - } - return; - } - - // Navigate up - if (key.upArrow) { - const newIndex = - commandMenu.selectedIndex > 0 - ? commandMenu.selectedIndex - 1 - : filteredCommands.length - 1; - setCommandSelectedIndex(newIndex); - return; - } - - // Navigate down - if (key.downArrow) { - const newIndex = - commandMenu.selectedIndex < filteredCommands.length - 1 - ? commandMenu.selectedIndex + 1 - : 0; - setCommandSelectedIndex(newIndex); - return; - } - - // Tab to complete/select - if (key.tab) { - if (filteredCommands.length > 0) { - const selected = filteredCommands[commandMenu.selectedIndex]; - if (selected) { - onSelect(selected.name); - } - } - return; - } - - // Backspace - if (key.backspace || key.delete) { - if (commandMenu.filter.length > 0) { - setCommandFilter(commandMenu.filter.slice(0, -1)); - } else { - closeCommandMenu(); - onCancel?.(); - } - return; - } - - // Regular character input for filtering - if (input && !key.ctrl && !key.meta) { - setCommandFilter(commandMenu.filter + input); - } - }, - { isActive: isActive && commandMenu.isOpen }, - ); - - if (!commandMenu.isOpen) return null; - - // Group commands by category - const groupedCommands = groupCommandsByCategory(filteredCommands); - - // Calculate flat index for selection - let flatIndex = 0; - const commandsWithIndex: CommandWithIndex[] = groupedCommands.flatMap( - (group) => - group.commands.map((cmd) => ({ - ...cmd, - flatIndex: flatIndex++, - })), - ); - - // Calculate visible items with scroll - const totalItems = filteredCommands.length; - const hasScrollUp = scrollOffset > 0; - const hasScrollDown = scrollOffset + MAX_VISIBLE < totalItems; - - // Get visible commands - const visibleCommands = commandsWithIndex.slice( - scrollOffset, - scrollOffset + MAX_VISIBLE, - ); - - // Group visible commands by category for display - const visibleGrouped: Array<{ - category: CommandCategory; - commands: CommandWithIndex[]; - }> = []; - - for (const cmd of visibleCommands) { - const existingGroup = visibleGrouped.find((g) => g.category === cmd.category); - if (existingGroup) { - existingGroup.commands.push(cmd); - } else { - visibleGrouped.push({ category: cmd.category, commands: [cmd] }); - } - } - - return ( - - - - Commands - - {commandMenu.filter && - filtering: } - {commandMenu.filter && {commandMenu.filter}} - ({totalItems}) - - - {hasScrollUp && ( - - ↑ more ({scrollOffset} above) - - )} - - {filteredCommands.length === 0 ? ( - No commands match "{commandMenu.filter}" - ) : ( - - {visibleGrouped.map((group) => ( - - - {capitalizeCategory(group.category)} - - {group.commands.map((cmd) => { - const isSelected = cmd.flatIndex === commandMenu.selectedIndex; - return ( - - - {isSelected ? "> " : " "} - - - /{cmd.name} - - - {cmd.description} - - ); - })} - - ))} - - )} - - {hasScrollDown && ( - - ↓ more ({totalItems - scrollOffset - MAX_VISIBLE} below) - - )} - - - - Esc to close | Enter/Tab to select | Type to filter - - - - ); -} diff --git a/src/tui/components/DiffView.tsx b/src/tui/components/DiffView.tsx deleted file mode 100644 index 4787d16..0000000 --- a/src/tui/components/DiffView.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * DiffView Component - Re-exports from modular implementation - */ - -export { DiffView } from "@tui/components/diff-view/index"; -export { DiffLine } from "@tui/components/diff-view/line-renderers"; -export { - parseDiffOutput, - isDiffContent, - stripAnsi, -} from "@tui/components/diff-view/utils"; - -// Re-export types for convenience -export type { - DiffLineData, - DiffViewProps, - DiffLineProps, - DiffLineType, -} from "@/types/tui"; diff --git a/src/tui/components/FilePicker.tsx b/src/tui/components/FilePicker.tsx deleted file mode 100644 index 3c7fd4f..0000000 --- a/src/tui/components/FilePicker.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/** - * FilePicker Component - Interactive file/folder selector (Presentation Only) - * - * This component is purely presentational. All filesystem operations - * are handled by the parent via the filePickerState prop. - * - * - Triggered by @ or / in input - * - Filters as you type - * - Up/Down to navigate, Enter to select - * - Tab to enter directories - * - Escape to cancel - */ - -import React, { useState, useEffect, useMemo } from "react"; -import { Box, Text, useInput } from "ink"; -import { dirname, relative } from "path"; -import type { - FileEntry, - FilePickerState, -} from "@/services/file-picker-service"; - -interface FilePickerProps { - query: string; - cwd: string; - filePickerState: FilePickerState; - onSelect: (path: string) => void; - onCancel: () => void; - onQueryChange: (query: string) => void; - isActive?: boolean; -} - -export function FilePicker({ - query, - cwd, - filePickerState, - onSelect, - onCancel, - onQueryChange, - isActive = true, -}: FilePickerProps): React.ReactElement { - const [selectedIndex, setSelectedIndex] = useState(0); - - // Get filtered files from state manager - const filteredFiles = useMemo( - () => filePickerState.filterFiles(query, 15), - [filePickerState, query], - ); - - // Reset selection when filter changes - useEffect(() => { - setSelectedIndex(0); - }, [query]); - - useInput( - (input, key) => { - if (!isActive) return; - - // Cancel - if (key.escape) { - onCancel(); - return; - } - - // Navigate - if (key.upArrow) { - setSelectedIndex((prev) => Math.max(0, prev - 1)); - return; - } - - if (key.downArrow) { - setSelectedIndex((prev) => - Math.min(filteredFiles.length - 1, prev + 1), - ); - return; - } - - // Select - if (key.return && filteredFiles.length > 0) { - const selected = filteredFiles[selectedIndex]; - if (selected) { - onSelect(selected.relativePath); - } - return; - } - - // Tab to enter directory - if (key.tab && filteredFiles.length > 0) { - const selected = filteredFiles[selectedIndex]; - if (selected?.isDirectory) { - filePickerState.setCurrentDir(selected.path); - onQueryChange(""); - } - return; - } - - // Backspace - if (key.backspace || key.delete) { - if (query.length > 0) { - onQueryChange(query.slice(0, -1)); - } else { - // Go up a directory - const currentDir = filePickerState.getCurrentDir(); - const parent = dirname(currentDir); - if (parent !== currentDir) { - filePickerState.setCurrentDir(parent); - } - } - return; - } - - // Type filter - if (input && !key.ctrl && !key.meta) { - onQueryChange(query + input); - } - }, - { isActive }, - ); - - const relativeDir = relative(cwd, filePickerState.getCurrentDir()) || "."; - - return ( - - - - Select file or folder - - ({relativeDir}) - - - - Filter: - {query || type to filter...} - - - - {filteredFiles.length === 0 ? ( - No matching files - ) : ( - filteredFiles.map((file: FileEntry, index: number) => { - const isSelected = index === selectedIndex; - const icon = file.isDirectory ? "📁" : "📄"; - - return ( - - - {isSelected ? "❯ " : " "} - {icon} {file.relativePath} - - - ); - }) - )} - - - - - ↑↓ navigate • Enter select • Tab enter dir • Esc cancel - - - - ); -} diff --git a/src/tui/components/Header.tsx b/src/tui/components/Header.tsx deleted file mode 100644 index ee64a8c..0000000 --- a/src/tui/components/Header.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Header Component - Shows the CodeTyper banner - */ - -import React from "react"; -import { Box, Text } from "ink"; -import type { HeaderProps } from "@/types/tui"; -import { TUI_BANNER, HEADER_GRADIENT_COLORS } from "@constants/tui-components"; - -export function Header({ - version = "0.1.0", - provider, - model, - showBanner = true, -}: HeaderProps): React.ReactElement { - if (!showBanner) { - return ( - - - codetyper - - - AI Coding Assistant - - ); - } - - const info: string[] = []; - if (version) info.push(`v${version}`); - if (provider) info.push(provider); - if (model) info.push(model); - - return ( - - - {TUI_BANNER.map((line, i) => ( - - {line} - - ))} - - AI Coding Assistant - - {info.length > 0 && {info.join(" | ")}} - - - ); -} diff --git a/src/tui/components/ImageAttachment.tsx b/src/tui/components/ImageAttachment.tsx deleted file mode 100644 index fb22a3b..0000000 --- a/src/tui/components/ImageAttachment.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** - * ImageAttachment Component - Displays pasted image indicators - */ - -import React from "react"; -import { Box, Text } from "ink"; -import type { PastedImage } from "@/types/image"; -import { formatImageSize, getImageSizeFromBase64 } from "@services/clipboard-service"; - -interface ImageAttachmentProps { - images: PastedImage[]; - onRemove?: (id: string) => void; -} - -const IMAGE_ICON = "📷"; - -export function ImageAttachment({ - images, - onRemove, -}: ImageAttachmentProps): React.ReactElement | null { - if (images.length === 0) { - return null; - } - - return ( - - {images.map((image, index) => { - const size = getImageSizeFromBase64(image.data); - const formattedSize = formatImageSize(size); - - return ( - - {IMAGE_ICON} - Image {index + 1} - ({formattedSize}) - {onRemove && ( - [x] - )} - - ); - })} - - ); -} - -export function ImageAttachmentCompact({ - images, -}: { - images: PastedImage[]; -}): React.ReactElement | null { - if (images.length === 0) { - return null; - } - - const totalSize = images.reduce( - (acc, img) => acc + getImageSizeFromBase64(img.data), - 0, - ); - - return ( - - {IMAGE_ICON} - - {images.length} image{images.length > 1 ? "s" : ""} attached - - ({formatImageSize(totalSize)}) - - ); -} diff --git a/src/tui/components/InputArea.tsx b/src/tui/components/InputArea.tsx deleted file mode 100644 index e742409..0000000 --- a/src/tui/components/InputArea.tsx +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Input Area Component - Multi-line input with Enter/Alt+Enter handling - * - * - Enter: Submit message - * - Alt+Enter (Option+Enter): Insert newline - * - @ or /: Open file picker to add files as context - */ - -import React, { useState, useEffect, useMemo } from "react"; -import { Box, Text, useInput } from "ink"; -import { useAppStore } from "@tui/store"; -import { FilePicker } from "@tui/components/FilePicker"; -import { BouncingLoader } from "@tui/components/BouncingLoader"; -import { createFilePickerState } from "@/services/file-picker-service"; - -interface InputAreaProps { - onSubmit: (message: string) => void; - placeholder?: string; -} - -export function InputArea({ - onSubmit, - placeholder = "Type your message...", -}: InputAreaProps): React.ReactElement { - // Use local state for responsive input handling - const [buffer, setBuffer] = useState(""); - const [cursorPos, setCursorPos] = useState(0); - const [cursorVisible, setCursorVisible] = useState(true); - - // File picker state - const [filePickerOpen, setFilePickerOpen] = useState(false); - const [filePickerQuery, setFilePickerQuery] = useState(""); - const [filePickerTrigger, setFilePickerTrigger] = useState<"@" | "/">("@"); - const filePickerState = useMemo( - () => createFilePickerState(process.cwd()), - [], - ); - - const mode = useAppStore((state) => state.mode); - const isLocked = - mode === "thinking" || - mode === "tool_execution" || - mode === "permission_prompt"; - - // Cursor blink effect - useEffect(() => { - if (isLocked) return; - - const timer = setInterval(() => { - setCursorVisible((prev) => !prev); - }, 530); - - return () => clearInterval(timer); - }, [isLocked]); - - // Handle input with useInput hook - useInput( - (input, key) => { - if (isLocked) return; - - // Handle Enter - if (key.return) { - if (key.meta) { - // Alt+Enter or Option+Enter: insert newline - const before = buffer.slice(0, cursorPos); - const after = buffer.slice(cursorPos); - setBuffer(before + "\n" + after); - setCursorPos(cursorPos + 1); - } else { - // Plain Enter: submit - const message = buffer.trim(); - if (message) { - onSubmit(message); - setBuffer(""); - setCursorPos(0); - } - } - return; - } - - // Handle Escape - check for Alt+Enter sequence (ESC followed by Enter) - if (key.escape) { - return; - } - - // Handle backspace - if (key.backspace || key.delete) { - if (cursorPos > 0) { - const before = buffer.slice(0, cursorPos - 1); - const after = buffer.slice(cursorPos); - setBuffer(before + after); - setCursorPos(cursorPos - 1); - } - return; - } - - // Handle arrow keys - if (key.leftArrow) { - if (cursorPos > 0) { - setCursorPos(cursorPos - 1); - } - return; - } - - if (key.rightArrow) { - if (cursorPos < buffer.length) { - setCursorPos(cursorPos + 1); - } - return; - } - - if (key.upArrow || key.downArrow) { - // For now, just ignore up/down in single-line scenarios - // Multi-line navigation can be added later - return; - } - - // Handle Ctrl+U (clear all) - if (key.ctrl && input === "u") { - setBuffer(""); - setCursorPos(0); - return; - } - - // Handle Ctrl+A (beginning of line) - if (key.ctrl && input === "a") { - setCursorPos(0); - return; - } - - // Handle Ctrl+E (end of line) - if (key.ctrl && input === "e") { - setCursorPos(buffer.length); - return; - } - - // Handle Ctrl+K (kill to end) - if (key.ctrl && input === "k") { - setBuffer(buffer.slice(0, cursorPos)); - return; - } - - // Skip if it's a control sequence - if (key.ctrl || key.meta) { - return; - } - - // Check for file picker triggers (@ or /) - if (input === "@" || input === "/") { - setFilePickerTrigger(input as "@" | "/"); - setFilePickerQuery(""); - setFilePickerOpen(true); - return; - } - - // Regular character input - this is the key fix! - // The input parameter contains the typed character - if (input) { - const before = buffer.slice(0, cursorPos); - const after = buffer.slice(cursorPos); - setBuffer(before + input + after); - setCursorPos(cursorPos + input.length); - } - }, - { isActive: !isLocked && !filePickerOpen }, - ); - - // File picker handlers - const handleFileSelect = (path: string): void => { - // Insert the file reference at cursor position - const fileRef = `${filePickerTrigger}${path}`; - const before = buffer.slice(0, cursorPos); - const after = buffer.slice(cursorPos); - const newBuffer = before + fileRef + " " + after; - setBuffer(newBuffer); - setCursorPos(cursorPos + fileRef.length + 1); - setFilePickerOpen(false); - setFilePickerQuery(""); - }; - - const handleFilePickerCancel = (): void => { - setFilePickerOpen(false); - setFilePickerQuery(""); - }; - - // Render - const lines = buffer.split("\n"); - const isEmpty = buffer.length === 0; - - // Calculate cursor position - let cursorLine = 0; - let cursorCol = 0; - let charCount = 0; - - for (let i = 0; i < lines.length; i++) { - if (charCount + lines[i].length >= cursorPos || i === lines.length - 1) { - cursorLine = i; - cursorCol = cursorPos - charCount; - if (cursorCol > lines[i].length) cursorCol = lines[i].length; - break; - } - charCount += lines[i].length + 1; - } - - return ( - - {/* File Picker Overlay */} - {filePickerOpen && ( - - )} - - - {isLocked ? ( - - - esc - interrupt - - ) : isEmpty ? ( - - > - {cursorVisible ? ( - - {placeholder[0]} - - ) : ( - {placeholder[0]} - )} - {placeholder.slice(1)} - - ) : ( - lines.map((line, i) => ( - - {i === 0 ? "> " : "│ "} - {i === cursorLine ? ( - - {line.slice(0, cursorCol)} - {cursorVisible ? ( - - {cursorCol < line.length ? line[cursorCol] : " "} - - ) : ( - - {cursorCol < line.length ? line[cursorCol] : ""} - - )} - {line.slice(cursorCol + 1)} - - ) : ( - {line} - )} - - )) - )} - - - - Enter to send • Alt+Enter for newline • @ or / to add files - - - - - ); -} diff --git a/src/tui/components/LearningModal.tsx b/src/tui/components/LearningModal.tsx deleted file mode 100644 index 0275e62..0000000 --- a/src/tui/components/LearningModal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Learning Modal Component - Prompts user to save a learning - */ - -import React from "react"; -import { Box, Text } from "ink"; -import { useAppStore } from "@tui/store"; -import { SelectMenu } from "@tui/components/SelectMenu"; -import type { SelectOption, LearningScope } from "@/types/tui"; -import { - LEARNING_OPTIONS, - LEARNING_CONTENT_MAX_LENGTH, - LEARNING_TRUNCATION_SUFFIX, -} from "@constants/tui-components"; - -const truncateContent = (content: string): string => { - if (content.length <= LEARNING_CONTENT_MAX_LENGTH) { - return content; - } - const truncateAt = - LEARNING_CONTENT_MAX_LENGTH - LEARNING_TRUNCATION_SUFFIX.length; - return content.slice(0, truncateAt) + LEARNING_TRUNCATION_SUFFIX; -}; - -export function LearningModal(): React.ReactElement | null { - const learningPrompt = useAppStore((state) => state.learningPrompt); - - if (!learningPrompt) return null; - - const handleSelect = (option: SelectOption): void => { - learningPrompt.resolve({ - save: option.value !== "skip", - scope: - option.value === "skip" ? undefined : (option.value as LearningScope), - }); - }; - - const displayContent = truncateContent(learningPrompt.content); - - return ( - - - - Remember this? - - - - - {displayContent} - {learningPrompt.context && ( - ({learningPrompt.context}) - )} - - - - - - - Learnings help codetyper understand your project preferences. - - - - ); -} diff --git a/src/tui/components/LogPanel.tsx b/src/tui/components/LogPanel.tsx deleted file mode 100644 index 5c76a5a..0000000 --- a/src/tui/components/LogPanel.tsx +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Log Panel Component - Re-exports from modular implementation - */ - -export { LogPanel } from "@tui/components/log-panel/index"; -export { LogEntryDisplay } from "@tui/components/log-panel/entry-renderers"; -export { ThinkingIndicator } from "@tui/components/log-panel/thinking-indicator"; -export { estimateEntryLines } from "@tui/components/log-panel/utils"; diff --git a/src/tui/components/MCPBrowser.tsx b/src/tui/components/MCPBrowser.tsx deleted file mode 100644 index 78dfc05..0000000 --- a/src/tui/components/MCPBrowser.tsx +++ /dev/null @@ -1,600 +0,0 @@ -/** - * MCPBrowser Component - Browse and search MCP servers from registry - * - * Allows users to discover, search, and install MCP servers - */ - -import React, { useState, useMemo, useEffect, useCallback } from "react"; -import { Box, Text, useInput } from "ink"; -import { - searchServers, - getCuratedServers, - installServer, - isServerInstalled, - getCategoriesWithCounts, -} from "@services/mcp/index"; -import type { - MCPRegistryServer, - MCPServerCategory, -} from "@/types/mcp-registry"; -import { - MCP_CATEGORY_LABELS, - MCP_CATEGORY_ICONS, -} from "@constants/mcp-registry"; - -interface MCPBrowserProps { - onClose: () => void; - onInstalled?: (serverName: string) => void; - isActive?: boolean; -} - -type BrowserMode = "browse" | "search" | "category" | "detail" | "installing"; - -interface CategoryCount { - category: MCPServerCategory; - count: number; -} - -const MAX_VISIBLE = 8; - -const STATUS_COLORS = { - verified: "green", - installed: "cyan", - popular: "yellow", - default: "white", -} as const; - -export function MCPBrowser({ - onClose, - onInstalled, - isActive = true, -}: MCPBrowserProps): React.ReactElement { - const [mode, setMode] = useState("browse"); - const [servers, setServers] = useState([]); - const [categories, setCategories] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedCategory, setSelectedCategory] = useState(null); - const [selectedServer, setSelectedServer] = useState(null); - const [loading, setLoading] = useState(true); - const [message, setMessage] = useState(null); - const [messageType, setMessageType] = useState<"success" | "error" | "info">("info"); - - // Load initial data - const loadData = useCallback(async () => { - setLoading(true); - try { - const curatedServers = getCuratedServers(); - setServers(curatedServers); - const cats = await getCategoriesWithCounts(); - setCategories(cats); - } catch { - setServers(getCuratedServers()); - } - setLoading(false); - }, []); - - useEffect(() => { - loadData(); - }, [loadData]); - - // Search handler - const handleSearch = useCallback(async (query: string) => { - setLoading(true); - try { - const result = await searchServers({ - query, - category: selectedCategory || undefined, - }); - setServers(result.servers); - } catch { - setMessage("Search failed"); - setMessageType("error"); - } - setLoading(false); - }, [selectedCategory]); - - // Category filter handler - const handleCategorySelect = useCallback(async (category: MCPServerCategory | null) => { - setSelectedCategory(category); - setLoading(true); - try { - const result = await searchServers({ - query: searchQuery, - category: category || undefined, - }); - setServers(result.servers); - } catch { - setMessage("Failed to filter"); - setMessageType("error"); - } - setLoading(false); - setMode("browse"); - setSelectedIndex(0); - setScrollOffset(0); - }, [searchQuery]); - - // Install handler - const handleInstall = useCallback(async (server: MCPRegistryServer) => { - setMode("installing"); - setMessage(`Installing ${server.name}...`); - setMessageType("info"); - - try { - const result = await installServer(server, { connect: true }); - if (result.success) { - setMessage(`Installed ${server.name}${result.connected ? " and connected" : ""}`); - setMessageType("success"); - onInstalled?.(server.id); - } else { - setMessage(result.error || "Installation failed"); - setMessageType("error"); - } - } catch (error) { - setMessage(error instanceof Error ? error.message : "Installation failed"); - setMessageType("error"); - } - - setMode("browse"); - setTimeout(() => setMessage(null), 3000); - }, [onInstalled]); - - // Filtered and displayed items - const displayItems = useMemo(() => { - if (mode === "category") { - return categories.map((cat) => ({ - id: cat.category, - label: `${MCP_CATEGORY_ICONS[cat.category]} ${MCP_CATEGORY_LABELS[cat.category]}`, - count: cat.count, - })); - } - return servers; - }, [mode, servers, categories]); - - // Visible window - const visibleItems = useMemo(() => { - if (mode === "category") { - return displayItems.slice(scrollOffset, scrollOffset + MAX_VISIBLE); - } - return servers.slice(scrollOffset, scrollOffset + MAX_VISIBLE); - }, [displayItems, servers, scrollOffset, mode]); - - // Input handling - useInput( - (input, key) => { - if (!isActive) return; - - // Clear message on any key - if (message && mode !== "installing") { - setMessage(null); - } - - // Escape handling - if (key.escape) { - if (mode === "detail") { - setMode("browse"); - setSelectedServer(null); - } else if (mode === "search") { - setMode("browse"); - setSearchQuery(""); - } else if (mode === "category") { - setMode("browse"); - } else { - onClose(); - } - return; - } - - // Installing mode - ignore input - if (mode === "installing") return; - - // Search mode - typing - if (mode === "search") { - if (key.return) { - handleSearch(searchQuery); - setMode("browse"); - } else if (key.backspace || key.delete) { - setSearchQuery((prev) => prev.slice(0, -1)); - } else if (input && !key.ctrl && !key.meta) { - setSearchQuery((prev) => prev + input); - } - return; - } - - // Detail mode - if (mode === "detail" && selectedServer) { - if (key.return || input === "i") { - if (!isServerInstalled(selectedServer.id)) { - handleInstall(selectedServer); - } else { - setMessage("Already installed"); - setMessageType("info"); - setTimeout(() => setMessage(null), 2000); - } - } - return; - } - - // Navigation - if (key.upArrow || input === "k") { - setSelectedIndex((prev) => { - const newIndex = Math.max(0, prev - 1); - if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - return newIndex; - }); - } else if (key.downArrow || input === "j") { - const maxIndex = (mode === "category" ? categories.length : servers.length) - 1; - setSelectedIndex((prev) => { - const newIndex = Math.min(maxIndex, prev + 1); - if (newIndex >= scrollOffset + MAX_VISIBLE) { - setScrollOffset(newIndex - MAX_VISIBLE + 1); - } - return newIndex; - }); - } else if (key.return) { - // Select item - if (mode === "category") { - const cat = categories[selectedIndex]; - if (cat) { - handleCategorySelect(cat.category); - } - } else { - const server = servers[selectedIndex]; - if (server) { - setSelectedServer(server); - setMode("detail"); - } - } - } else if (input === "/") { - setMode("search"); - setSearchQuery(""); - } else if (input === "c") { - setMode("category"); - setSelectedIndex(0); - setScrollOffset(0); - } else if (input === "i" && mode === "browse") { - const server = servers[selectedIndex]; - if (server && !isServerInstalled(server.id)) { - handleInstall(server); - } - } else if (input === "r") { - loadData(); - } - }, - { isActive } - ); - - // Render loading - if (loading && servers.length === 0) { - return ( - - - - MCP Server Browser - - - - Loading servers... - - - ); - } - - // Render detail view - if (mode === "detail" && selectedServer) { - const installed = isServerInstalled(selectedServer.id); - return ( - - - - {selectedServer.name} - - {selectedServer.verified && ( - - )} - - - - - {selectedServer.description} - - - - Author: - {selectedServer.author} - - - - Category: - - {MCP_CATEGORY_ICONS[selectedServer.category]}{" "} - {MCP_CATEGORY_LABELS[selectedServer.category]} - - - - - Package: - {selectedServer.package} - - - {selectedServer.envVars && selectedServer.envVars.length > 0 && ( - - Required env vars: - {selectedServer.envVars.map((envVar) => ( - - {" "}${envVar} - - ))} - - )} - - {selectedServer.installHint && ( - - - Note: {selectedServer.installHint} - - - )} - - - {message && ( - - - {message} - - - )} - - - {installed ? ( - Already installed - ) : ( - - [Enter/i] Install{" "} - [Esc] Back - - )} - - - ); - } - - // Render category view - if (mode === "category") { - return ( - - - - Select Category - - - - {/* All categories option */} - - - 📋 All Categories - - - - {visibleItems.map((item, index) => { - const actualIndex = index + scrollOffset; - const isSelected = actualIndex === selectedIndex; - const catItem = item as { id: string; label: string; count: number }; - - return ( - - - {catItem.label} ({catItem.count}) - - - ); - })} - - - - [↑↓] Navigate{" "} - [Enter] Select{" "} - [Esc] Back - - - - ); - } - - // Render search mode - if (mode === "search") { - return ( - - - - Search MCP Servers - - - - - / - {searchQuery} - - - - - - [Enter] Search{" "} - [Esc] Cancel - - - - ); - } - - // Render browse mode - const hasMore = servers.length > scrollOffset + MAX_VISIBLE; - const hasLess = scrollOffset > 0; - - return ( - - - - MCP Server Browser - - - {servers.length} servers - {selectedCategory && ` • ${MCP_CATEGORY_LABELS[selectedCategory]}`} - - - - {hasLess && ( - - ↑ more - - )} - - {visibleItems.map((server, index) => { - const actualIndex = index + scrollOffset; - const isSelected = actualIndex === selectedIndex; - const installed = isServerInstalled((server as MCPRegistryServer).id); - - return ( - - - - {(server as MCPRegistryServer).verified ? "✓ " : " "} - {(server as MCPRegistryServer).name} - - - - {installed ? ( - installed - ) : ( - - {MCP_CATEGORY_ICONS[(server as MCPRegistryServer).category]} - - )} - - - ); - })} - - {hasMore && ( - - ↓ more - - )} - - {message && ( - - - {message} - - - )} - - - - [/] Search{" "} - [c] Category{" "} - [i] Install{" "} - [Esc] Close - - - - ); -} - -export default MCPBrowser; diff --git a/src/tui/components/MCPSelect.tsx b/src/tui/components/MCPSelect.tsx deleted file mode 100644 index d5fdc7b..0000000 --- a/src/tui/components/MCPSelect.tsx +++ /dev/null @@ -1,533 +0,0 @@ -/** - * MCPSelect Component - MCP server management menu - * - * Shows configured MCP servers with status and allows adding new servers - */ - -import React, { useState, useMemo, useEffect } from "react"; -import { Box, Text, useInput } from "ink"; -import { - initializeMCP, - getMCPConfig, - getServerInstances, - connectServer, - disconnectServer, - addServer, -} from "@services/mcp/index"; -import type { MCPServerInstance, MCPServerConfig } from "@/types/mcp"; - -interface MCPSelectProps { - onClose: () => void; - onBrowse?: () => void; - isActive?: boolean; -} - -type MenuMode = "list" | "add_name" | "add_command" | "add_args"; - -type ActionType = "add" | "browse" | "search" | "popular"; - -interface MenuItem { - id: string; - name: string; - type: "server" | "action"; - actionType?: ActionType; - server?: MCPServerInstance; - config?: MCPServerConfig; -} - -const MAX_VISIBLE = 8; - -const STATE_COLORS: Record = { - connected: "green", - connecting: "yellow", - disconnected: "gray", - error: "red", -}; - -export function MCPSelect({ - onClose, - onBrowse, - isActive = true, -}: MCPSelectProps): React.ReactElement { - const [servers, setServers] = useState>( - new Map(), - ); - const [selectedIndex, setSelectedIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - const [filter, setFilter] = useState(""); - const [loading, setLoading] = useState(true); - const [message, setMessage] = useState(null); - - // Add new server state - const [mode, setMode] = useState("list"); - const [newServerName, setNewServerName] = useState(""); - const [newServerCommand, setNewServerCommand] = useState(""); - const [newServerArgs, setNewServerArgs] = useState(""); - - // Load servers on mount - const loadServers = async () => { - setLoading(true); - await initializeMCP(); - const instances = getServerInstances(); - setServers(instances); - setLoading(false); - }; - - useEffect(() => { - loadServers(); - }, []); - - // Build menu items - const menuItems = useMemo((): MenuItem[] => { - const items: MenuItem[] = []; - - // Add action items first - items.push({ - id: "__browse__", - name: "🔍 Browse & Search servers", - type: "action", - actionType: "browse", - }); - - items.push({ - id: "__popular__", - name: "⭐ Popular servers", - type: "action", - actionType: "popular", - }); - - items.push({ - id: "__add__", - name: "+ Add server manually", - type: "action", - actionType: "add", - }); - - // Add servers - for (const [name, instance] of servers) { - items.push({ - id: name, - name, - type: "server", - server: instance, - config: instance.config, - }); - } - - return items; - }, [servers]); - - // Filter items - const filteredItems = useMemo(() => { - if (!filter) return menuItems; - const query = filter.toLowerCase(); - return menuItems.filter((item) => item.name.toLowerCase().includes(query)); - }, [menuItems, filter]); - - // Handle server toggle (connect/disconnect) - const toggleServer = async (item: MenuItem) => { - if (!item.server) return; - - setMessage(null); - try { - if (item.server.state === "connected") { - await disconnectServer(item.id); - setMessage(`Disconnected from ${item.id}`); - } else { - setMessage(`Connecting to ${item.id}...`); - await connectServer(item.id); - setMessage(`Connected to ${item.id}`); - } - await loadServers(); - } catch (err) { - setMessage(`Error: ${err}`); - } - }; - - // Handle adding new server - const handleAddServer = async () => { - if (!newServerName || !newServerCommand) { - setMessage("Name and command are required"); - setMode("list"); - return; - } - - setMessage(`Adding ${newServerName}...`); - try { - const args = newServerArgs.split(/\s+/).filter((a) => a.length > 0); - - await addServer(newServerName, { - command: newServerCommand, - args: args.length > 0 ? args : undefined, - enabled: true, - }); - - setMessage(`Added ${newServerName}`); - setNewServerName(""); - setNewServerCommand(""); - setNewServerArgs(""); - setMode("list"); - await loadServers(); - } catch (err) { - setMessage(`Error: ${err}`); - setMode("list"); - } - }; - - useInput( - (input, key) => { - if (!isActive) return; - - // Handle add server input modes - if (mode === "add_name") { - if (key.escape) { - setMode("list"); - setNewServerName(""); - return; - } - if (key.return) { - if (newServerName.trim()) { - setMode("add_command"); - } - return; - } - if (key.backspace || key.delete) { - setNewServerName(newServerName.slice(0, -1)); - return; - } - if (input && !key.ctrl && !key.meta) { - setNewServerName(newServerName + input); - } - return; - } - - if (mode === "add_command") { - if (key.escape) { - setMode("add_name"); - return; - } - if (key.return) { - if (newServerCommand.trim()) { - setMode("add_args"); - } - return; - } - if (key.backspace || key.delete) { - setNewServerCommand(newServerCommand.slice(0, -1)); - return; - } - if (input && !key.ctrl && !key.meta) { - setNewServerCommand(newServerCommand + input); - } - return; - } - - if (mode === "add_args") { - if (key.escape) { - setMode("add_command"); - return; - } - if (key.return) { - handleAddServer(); - return; - } - if (key.backspace || key.delete) { - setNewServerArgs(newServerArgs.slice(0, -1)); - return; - } - if (input && !key.ctrl && !key.meta) { - setNewServerArgs(newServerArgs + input); - } - return; - } - - // List mode - if (key.escape) { - onClose(); - return; - } - - if (key.return) { - if (filteredItems.length > 0) { - const selected = filteredItems[selectedIndex]; - if (selected) { - if (selected.type === "action") { - const actionHandlers: Record void> = { - add: () => { - setMode("add_name"); - setMessage(null); - }, - browse: () => { - if (onBrowse) { - onBrowse(); - } else { - onClose(); - } - }, - popular: () => { - if (onBrowse) { - onBrowse(); - } else { - onClose(); - } - }, - search: () => { - if (onBrowse) { - onBrowse(); - } else { - onClose(); - } - }, - }; - const handler = actionHandlers[selected.actionType || "add"]; - handler(); - } else if (selected.type === "server") { - toggleServer(selected); - } - } - } - return; - } - - // Navigate up - if (key.upArrow) { - setSelectedIndex((prev) => { - const newIndex = prev > 0 ? prev - 1 : filteredItems.length - 1; - if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - if (prev === 0 && newIndex === filteredItems.length - 1) { - setScrollOffset(Math.max(0, filteredItems.length - MAX_VISIBLE)); - } - return newIndex; - }); - return; - } - - // Navigate down - if (key.downArrow) { - setSelectedIndex((prev) => { - const newIndex = prev < filteredItems.length - 1 ? prev + 1 : 0; - if (newIndex >= scrollOffset + MAX_VISIBLE) { - setScrollOffset(newIndex - MAX_VISIBLE + 1); - } - if (prev === filteredItems.length - 1 && newIndex === 0) { - setScrollOffset(0); - } - return newIndex; - }); - return; - } - - // Backspace - if (key.backspace || key.delete) { - if (filter.length > 0) { - setFilter(filter.slice(0, -1)); - setSelectedIndex(0); - setScrollOffset(0); - } - return; - } - - // Regular character input for filtering - if (input && !key.ctrl && !key.meta) { - setFilter(filter + input); - setSelectedIndex(0); - setScrollOffset(0); - } - }, - { isActive }, - ); - - if (loading) { - return ( - - - Loading MCP servers... - - - ); - } - - // Add new server form - if (mode !== "list") { - return ( - - - - Add New MCP Server - - - - - - Name: - - {newServerName || (mode === "add_name" ? "█" : "")} - - {mode === "add_name" && newServerName && ( - - )} - - - - Command: - - {newServerCommand || (mode === "add_command" ? "█" : "")} - - {mode === "add_command" && newServerCommand && ( - - )} - - - - Args (space-separated): - - {newServerArgs || (mode === "add_args" ? "█" : "(optional)")} - - {mode === "add_args" && newServerArgs && ( - - )} - - - - - - {mode === "add_name" && "Enter server name, then press Enter"} - {mode === "add_command" && "Enter command (e.g., npx), then Enter"} - {mode === "add_args" && "Enter args or press Enter to finish"} - - - - - Esc to go back - - - {message && ( - - {message} - - )} - - ); - } - - return ( - - - - MCP Servers - - {filter && ( - <> - - filtering: - {filter} - - )} - - - {message && ( - - {message} - - )} - - {filteredItems.length === 0 ? ( - No servers match "{filter}" - ) : ( - - {/* Scroll up indicator */} - {scrollOffset > 0 && ( - ↑ {scrollOffset} more above - )} - - {/* Visible items */} - {filteredItems - .slice(scrollOffset, scrollOffset + MAX_VISIBLE) - .map((item, visibleIndex) => { - const actualIndex = scrollOffset + visibleIndex; - const isSelected = actualIndex === selectedIndex; - - if (item.type === "action") { - return ( - - - {isSelected ? "> " : " "} - - - {item.name} - - - ); - } - - const state = item.server?.state || "disconnected"; - const stateColor = STATE_COLORS[state] || "gray"; - const toolCount = - state === "connected" - ? ` (${item.server?.tools.length || 0} tools)` - : ""; - - return ( - - - - {isSelected ? "> " : " "} - - - {item.name} - - - [{state}] - {toolCount} - - {item.server?.error && ( - - {item.server.error} - - )} - - ); - })} - - {/* Scroll down indicator */} - {scrollOffset + MAX_VISIBLE < filteredItems.length && ( - - ↓ {filteredItems.length - scrollOffset - MAX_VISIBLE} more below - - )} - - )} - - - - ↑↓ navigate | Enter toggle/add | Type to filter | Esc close - - - - ); -} diff --git a/src/tui/components/ModelSelect.tsx b/src/tui/components/ModelSelect.tsx deleted file mode 100644 index 3b7631c..0000000 --- a/src/tui/components/ModelSelect.tsx +++ /dev/null @@ -1,264 +0,0 @@ -/** - * ModelSelect Component - Model selection menu - * - * Shows available models with "auto" option like VSCode Copilot - * Displays cost multiplier for each model (0.0x = unlimited, 1.0x = standard, etc.) - */ - -import React, { useState, useMemo } from "react"; -import { Box, Text, useInput } from "ink"; -import { useAppStore } from "@tui/store"; -import type { ProviderModel } from "@/types/providers"; - -interface ModelSelectProps { - onSelect: (model: string) => void; - onClose: () => void; - isActive?: boolean; -} - -const MAX_VISIBLE = 10; - -// Auto model option -const AUTO_MODEL: ProviderModel = { - id: "auto", - name: "Auto", - supportsTools: true, - supportsStreaming: true, - costMultiplier: undefined, - isUnlimited: true, -}; - -const formatCostMultiplier = (model: ProviderModel): string => { - if (model.id === "auto") return ""; - - const multiplier = model.costMultiplier; - if (multiplier === undefined) { - return ""; - } - if (multiplier === 0 || model.isUnlimited) { - return "Unlimited"; - } - return `${multiplier}x`; -}; - -const getCostColor = (model: ProviderModel): string | undefined => { - if (model.id === "auto") return undefined; - - const multiplier = model.costMultiplier; - if (multiplier === undefined) { - return "gray"; - } - if (multiplier === 0 || model.isUnlimited) { - return "green"; - } - if (multiplier <= 0.1) { - return "cyan"; - } - if (multiplier <= 1.0) { - return "yellow"; - } - return "red"; -}; - -export function ModelSelect({ - onSelect, - onClose, - isActive = true, -}: ModelSelectProps): React.ReactElement { - const availableModels = useAppStore((state) => state.availableModels); - const currentModel = useAppStore((state) => state.model); - const [selectedIndex, setSelectedIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - const [filter, setFilter] = useState(""); - - // Add "auto" option at the beginning - const allModels = useMemo((): ProviderModel[] => { - return [AUTO_MODEL, ...availableModels]; - }, [availableModels]); - - // Filter models based on input - const filteredModels = useMemo((): ProviderModel[] => { - if (!filter) return allModels; - const query = filter.toLowerCase(); - return allModels.filter( - (model) => - model.id.toLowerCase().includes(query) || - model.name.toLowerCase().includes(query), - ); - }, [allModels, filter]); - - useInput( - (input, key) => { - if (!isActive) return; - - // Escape to close - if (key.escape) { - onClose(); - return; - } - - // Enter to select - if (key.return) { - if (filteredModels.length > 0) { - const selected = filteredModels[selectedIndex]; - if (selected) { - onSelect(selected.id); - onClose(); - } - } - return; - } - - // Navigate up - if (key.upArrow) { - setSelectedIndex((prev) => { - const newIndex = prev > 0 ? prev - 1 : filteredModels.length - 1; - // Scroll up if needed - if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - // Jump to bottom - scroll to show bottom items - if (prev === 0 && newIndex === filteredModels.length - 1) { - setScrollOffset(Math.max(0, filteredModels.length - MAX_VISIBLE)); - } - return newIndex; - }); - return; - } - - // Navigate down - if (key.downArrow) { - setSelectedIndex((prev) => { - const newIndex = prev < filteredModels.length - 1 ? prev + 1 : 0; - // Scroll down if needed - if (newIndex >= scrollOffset + MAX_VISIBLE) { - setScrollOffset(newIndex - MAX_VISIBLE + 1); - } - // Jump to top - scroll to top - if (prev === filteredModels.length - 1 && newIndex === 0) { - setScrollOffset(0); - } - return newIndex; - }); - return; - } - - // Backspace - if (key.backspace || key.delete) { - if (filter.length > 0) { - setFilter(filter.slice(0, -1)); - setSelectedIndex(0); - setScrollOffset(0); - } - return; - } - - // Regular character input for filtering - if (input && !key.ctrl && !key.meta) { - setFilter(filter + input); - setSelectedIndex(0); - setScrollOffset(0); - } - }, - { isActive }, - ); - - return ( - - - - Select Model - - {filter && ( - <> - - filtering: - {filter} - - )} - - - - Current: - {currentModel} - - - {filteredModels.length === 0 ? ( - No models match "{filter}" - ) : ( - - {/* Scroll up indicator */} - {scrollOffset > 0 && ( - ↑ {scrollOffset} more above - )} - - {/* Visible models */} - {filteredModels - .slice(scrollOffset, scrollOffset + MAX_VISIBLE) - .map((model, visibleIndex) => { - const actualIndex = scrollOffset + visibleIndex; - const isSelected = actualIndex === selectedIndex; - const isCurrent = model.id === currentModel; - const isAuto = model.id === "auto"; - const costLabel = formatCostMultiplier(model); - const costColor = getCostColor(model); - return ( - - - {isSelected ? "> " : " "} - - - {model.id} - - {costLabel && !isAuto && ( - [{costLabel}] - )} - {isCurrent && (current)} - {isAuto && ( - - Let Copilot choose the best model - )} - - ); - })} - - {/* Scroll down indicator */} - {scrollOffset + MAX_VISIBLE < filteredModels.length && ( - - {" "} - ↓ {filteredModels.length - scrollOffset - MAX_VISIBLE} more below - - )} - - )} - - - - Cost: - Unlimited - | - Low - | - Standard - | - Premium - - - ↑↓ navigate | Enter select | Type to filter | Esc close - - - - ); -} diff --git a/src/tui/components/PermissionModal.tsx b/src/tui/components/PermissionModal.tsx deleted file mode 100644 index 6ba00b8..0000000 --- a/src/tui/components/PermissionModal.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Permission Modal Component - Y/N prompts for tool execution - */ - -import React from "react"; -import { Box, Text } from "ink"; -import { useAppStore } from "@tui/store"; -import { SelectMenu } from "@tui/components/SelectMenu"; -import type { - SelectOption, - PermissionType, - PermissionScope, -} from "@/types/tui"; -import { - PERMISSION_OPTIONS, - PERMISSION_TYPE_LABELS, -} from "@constants/tui-components"; - -export function PermissionModal(): React.ReactElement | null { - const permissionRequest = useAppStore((state) => state.permissionRequest); - const setPermissionRequest = useAppStore( - (state) => state.setPermissionRequest, - ); - - if (!permissionRequest) return null; - - const handleSelect = (option: SelectOption): void => { - permissionRequest.resolve({ - allowed: option.value !== "deny", - scope: - option.value === "deny" ? undefined : (option.value as PermissionScope), - }); - setPermissionRequest(null); - }; - - const typeLabel = - PERMISSION_TYPE_LABELS[permissionRequest.type as PermissionType] ?? - "Unknown operation"; - - return ( - - - - ⚠ Permission Required - - - - - {typeLabel}: - - {permissionRequest.command || - permissionRequest.path || - permissionRequest.description} - - - - {permissionRequest.description && - permissionRequest.description !== permissionRequest.command && ( - - {permissionRequest.description} - - )} - - - - ); -} diff --git a/src/tui/components/SelectMenu.tsx b/src/tui/components/SelectMenu.tsx deleted file mode 100644 index cc66bef..0000000 --- a/src/tui/components/SelectMenu.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * SelectMenu Component - Keyboard navigable selection menu - * - * - Up/Down arrows to navigate - * - Enter to select - * - Optional shortcut keys for quick selection - */ - -import React, { useState } from "react"; -import { Box, Text, useInput } from "ink"; -import type { SelectOption } from "@/types/tui"; - -// Re-export type for backwards compatibility -export type { SelectOption } from "@/types/tui"; - -interface SelectMenuProps { - options: SelectOption[]; - onSelect: (option: SelectOption) => void; - title?: string; - initialIndex?: number; - isActive?: boolean; -} - -export function SelectMenu({ - options, - onSelect, - title, - initialIndex = 0, - isActive = true, -}: SelectMenuProps): React.ReactElement { - const [selectedIndex, setSelectedIndex] = useState(initialIndex); - - useInput( - (input, key) => { - if (!isActive) return; - - // Navigate with arrow keys - if (key.upArrow) { - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1)); - return; - } - - if (key.downArrow) { - setSelectedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0)); - return; - } - - // Select with Enter - if (key.return) { - onSelect(options[selectedIndex]); - return; - } - - // Quick select with shortcut key - const lowerInput = input.toLowerCase(); - const optionIndex = options.findIndex( - (o) => o.key?.toLowerCase() === lowerInput, - ); - if (optionIndex !== -1) { - onSelect(options[optionIndex]); - } - }, - { isActive }, - ); - - return ( - - {title && ( - - - {title} - - - )} - - {options.map((option, index) => { - const isSelected = index === selectedIndex; - return ( - - - {isSelected ? "❯ " : " "} - - {option.key && ( - [{option.key}] - )} - - {option.label} - - {option.description && ( - - {option.description} - )} - - ); - })} - - - ↑↓ to navigate • Enter to select - {options.some((o) => o.key) && ( - • or press shortcut key - )} - - - ); -} diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx deleted file mode 100644 index b6be602..0000000 --- a/src/tui/components/StatusBar.tsx +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Status Bar Component - Shows session stats, keyboard hints, and current mode - */ - -import React, { useEffect, useState, useRef } from "react"; -import { Box, Text } from "ink"; -import { useAppStore } from "@tui/store"; -import { - STATUS_HINTS, - STATUS_SEPARATOR, - TIME_UNITS, - TOKEN_DISPLAY, -} from "@constants/ui"; -import { getThinkingMessage, getToolMessage } from "@constants/status-messages"; -import { - MODE_DISPLAY_CONFIG, - DEFAULT_MODE_DISPLAY, - type ModeDisplayConfig, -} from "@constants/tui-components"; -import type { AppMode, ToolCall } from "@/types/tui"; -import { useTodoStore } from "@tui/hooks/useTodoStore"; - -const formatDuration = (ms: number): string => { - const totalSeconds = Math.floor(ms / TIME_UNITS.SECOND); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s`; - } - if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } - return `${seconds}s`; -}; - -const formatTokens = (count: number): string => { - if (count >= TOKEN_DISPLAY.K_THRESHOLD) { - return `${(count / TOKEN_DISPLAY.K_THRESHOLD).toFixed(TOKEN_DISPLAY.DECIMALS)}k`; - } - return String(count); -}; - -const getModeDisplay = ( - mode: AppMode, - statusMessage: string, - currentToolCall: ToolCall | null, -): ModeDisplayConfig => { - const baseConfig = MODE_DISPLAY_CONFIG[mode] ?? DEFAULT_MODE_DISPLAY; - - // Override text for dynamic modes - if (mode === "thinking" && statusMessage) { - return { ...baseConfig, text: statusMessage }; - } - - if (mode === "tool_execution") { - const toolText = - statusMessage || `✻ Running ${currentToolCall?.name || "tool"}…`; - return { ...baseConfig, text: toolText }; - } - - return baseConfig; -}; - -export function StatusBar(): React.ReactElement { - const mode = useAppStore((state) => state.mode); - const currentToolCall = useAppStore((state) => state.currentToolCall); - const sessionStats = useAppStore((state) => state.sessionStats); - const todosVisible = useAppStore((state) => state.todosVisible); - const interruptPending = useAppStore((state) => state.interruptPending); - const hasPlan = useTodoStore((state) => state.currentPlan !== null); - - // Elapsed time tracking with re-render every second - const [elapsed, setElapsed] = useState(0); - - useEffect(() => { - const timer = setInterval(() => { - setElapsed(Date.now() - sessionStats.startTime); - }, 1000); - return () => clearInterval(timer); - }, [sessionStats.startTime]); - - // Calculate thinking time - only update once per second to reduce re-renders - const [thinkingTime, setThinkingTime] = useState(0); - useEffect(() => { - if (sessionStats.thinkingStartTime === null) { - setThinkingTime(0); - return; - } - // Update immediately - setThinkingTime( - Math.floor((Date.now() - sessionStats.thinkingStartTime) / 1000), - ); - // Then update every second (not 100ms to reduce re-renders) - const timer = setInterval(() => { - setThinkingTime( - Math.floor((Date.now() - sessionStats.thinkingStartTime!) / 1000), - ); - }, 1000); - return () => clearInterval(timer); - }, [sessionStats.thinkingStartTime]); - - // Rotating whimsical status message - const [statusMessage, setStatusMessage] = useState(""); - const prevModeRef = useRef(mode); - const prevToolRef = useRef(currentToolCall?.name); - - useEffect(() => { - const isProcessing = mode === "thinking" || mode === "tool_execution"; - - if (!isProcessing) { - setStatusMessage(""); - return; - } - - // Generate new message when mode or tool changes - const modeChanged = prevModeRef.current !== mode; - const toolChanged = prevToolRef.current !== currentToolCall?.name; - - if (modeChanged || toolChanged) { - if (mode === "thinking") { - setStatusMessage(getThinkingMessage()); - } else if (mode === "tool_execution" && currentToolCall) { - setStatusMessage( - getToolMessage(currentToolCall.name, currentToolCall.description), - ); - } - } - - prevModeRef.current = mode; - prevToolRef.current = currentToolCall?.name; - - // Rotate message every 2.5 seconds during processing - const timer = setInterval(() => { - if (mode === "thinking") { - setStatusMessage(getThinkingMessage()); - } else if (mode === "tool_execution" && currentToolCall) { - setStatusMessage( - getToolMessage(currentToolCall.name, currentToolCall.description), - ); - } - }, 2500); - - return () => clearInterval(timer); - }, [mode, currentToolCall]); - - const isProcessing = mode === "thinking" || mode === "tool_execution"; - const totalTokens = sessionStats.inputTokens + sessionStats.outputTokens; - - // Build status hints - const hints: string[] = []; - - // Interrupt hint - if (isProcessing) { - hints.push( - interruptPending - ? STATUS_HINTS.INTERRUPT_CONFIRM - : STATUS_HINTS.INTERRUPT, - ); - } - - // Todo toggle hint (only show when there's a plan) - if (hasPlan) { - hints.push( - todosVisible ? STATUS_HINTS.TOGGLE_TODOS : STATUS_HINTS.TOGGLE_TODOS_SHOW, - ); - } - - // Elapsed time - hints.push(formatDuration(elapsed)); - - // Token count - if (totalTokens > 0) { - hints.push(`↓ ${formatTokens(totalTokens)} tokens`); - } - - // Thinking time (only during thinking or show last duration) - if (sessionStats.thinkingStartTime !== null) { - hints.push(`${thinkingTime}s`); - } else if (sessionStats.lastThinkingDuration > 0 && mode === "idle") { - hints.push(`thought for ${sessionStats.lastThinkingDuration}s`); - } - - const modeDisplay = getModeDisplay(mode, statusMessage, currentToolCall); - - return ( - - - {modeDisplay.text} - - - {hints.join(STATUS_SEPARATOR)} - - - ); -} diff --git a/src/tui/components/StreamingMessage.tsx b/src/tui/components/StreamingMessage.tsx deleted file mode 100644 index 0467212..0000000 --- a/src/tui/components/StreamingMessage.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Streaming Message Component - * - * Renders an assistant message that updates in real-time as content streams in. - * Shows a cursor indicator while streaming is active. - */ - -import React from "react"; -import { Box, Text } from "ink"; -import type { LogEntry } from "@/types/tui"; - -// ============================================================================= -// Props -// ============================================================================= - -interface StreamingMessageProps { - entry: LogEntry; -} - -// ============================================================================= -// Streaming Cursor Component -// ============================================================================= - -const StreamingCursor: React.FC = () => { - const [visible, setVisible] = React.useState(true); - - React.useEffect(() => { - const interval = setInterval(() => { - setVisible((v) => !v); - }, 500); - - return () => clearInterval(interval); - }, []); - - return {visible ? "▌" : " "}; -}; - -// ============================================================================= -// Main Component -// ============================================================================= - -export const StreamingMessage: React.FC = ({ - entry, -}) => { - const isStreaming = entry.metadata?.isStreaming ?? false; - const content = entry.content || ""; - - // Split content into lines for display - const lines = content.split("\n"); - const lastLineIndex = lines.length - 1; - - return ( - - - CodeTyper - - - {lines.map((line, index) => ( - - {line} - {isStreaming && index === lastLineIndex && } - - ))} - {content === "" && isStreaming && ( - - - - )} - - - ); -}; - -// ============================================================================= -// Export -// ============================================================================= - -export default StreamingMessage; diff --git a/src/tui/components/ThemeSelect.tsx b/src/tui/components/ThemeSelect.tsx deleted file mode 100644 index c90b2ff..0000000 --- a/src/tui/components/ThemeSelect.tsx +++ /dev/null @@ -1,199 +0,0 @@ -/** - * ThemeSelect Component - Theme selection menu - * - * Shows available color themes for the TUI - * Previews themes live as user navigates - */ - -import React, { useState, useMemo, useEffect, useRef } from "react"; -import { Box, Text, useInput } from "ink"; -import { useThemeStore, useThemeColors } from "@tui/hooks/useThemeStore"; -import { THEMES } from "@constants/themes"; - -interface ThemeSelectProps { - onSelect: (theme: string) => void; - onClose: () => void; - isActive?: boolean; -} - -export function ThemeSelect({ - onSelect, - onClose, - isActive = true, -}: ThemeSelectProps): React.ReactElement { - const currentTheme = useThemeStore((state) => state.currentTheme); - const setTheme = useThemeStore((state) => state.setTheme); - const colors = useThemeColors(); - const [selectedIndex, setSelectedIndex] = useState(0); - const [filter, setFilter] = useState(""); - - // Store the original theme to revert on cancel - const originalTheme = useRef(currentTheme); - - const allThemes = useMemo(() => { - return Object.values(THEMES).map((theme) => ({ - name: theme.name, - displayName: theme.displayName, - colors: theme.colors, - })); - }, []); - - const filteredThemes = useMemo(() => { - if (!filter) return allThemes; - const query = filter.toLowerCase(); - return allThemes.filter( - (theme) => - theme.name.toLowerCase().includes(query) || - theme.displayName.toLowerCase().includes(query), - ); - }, [allThemes, filter]); - - // Preview theme as user navigates - useEffect(() => { - if (filteredThemes.length > 0 && filteredThemes[selectedIndex]) { - const previewTheme = filteredThemes[selectedIndex].name; - setTheme(previewTheme); - } - }, [selectedIndex, filteredThemes, setTheme]); - - // Store original theme on mount - useEffect(() => { - originalTheme.current = currentTheme; - }, []); - - const handleCancel = () => { - // Revert to original theme - setTheme(originalTheme.current); - onClose(); - }; - - const handleConfirm = () => { - if (filteredThemes.length > 0) { - const selected = filteredThemes[selectedIndex]; - if (selected) { - // Theme is already applied via preview, just confirm it - onSelect(selected.name); - onClose(); - } - } - }; - - useInput( - (input, key) => { - if (!isActive) return; - - if (key.escape) { - handleCancel(); - return; - } - - if (key.return) { - handleConfirm(); - return; - } - - if (key.upArrow) { - setSelectedIndex((prev) => - prev > 0 ? prev - 1 : filteredThemes.length - 1, - ); - return; - } - - if (key.downArrow) { - setSelectedIndex((prev) => - prev < filteredThemes.length - 1 ? prev + 1 : 0, - ); - return; - } - - if (key.backspace || key.delete) { - if (filter.length > 0) { - setFilter(filter.slice(0, -1)); - setSelectedIndex(0); - } - return; - } - - if (input && !key.ctrl && !key.meta) { - setFilter(filter + input); - setSelectedIndex(0); - } - }, - { isActive }, - ); - - return ( - - - - Select Theme - - {filter && ( - <> - - filtering: - {filter} - - )} - - - - Original: - - {THEMES[originalTheme.current]?.displayName ?? originalTheme.current} - - → Preview: - - {THEMES[currentTheme]?.displayName ?? currentTheme} - - - - {filteredThemes.length === 0 ? ( - No themes match "{filter}" - ) : ( - - {filteredThemes.map((theme, index) => { - const isSelected = index === selectedIndex; - const isOriginal = theme.name === originalTheme.current; - - return ( - - - {isSelected ? "> " : " "} - - - {theme.displayName} - - {isOriginal && (original)} - - {/* Color preview squares */} - - - - - - - ); - })} - - )} - - - - ↑↓ live preview | Enter confirm | Type to filter | Esc cancel - - - - ); -} diff --git a/src/tui/components/TodoPanel.tsx b/src/tui/components/TodoPanel.tsx deleted file mode 100644 index eb44495..0000000 --- a/src/tui/components/TodoPanel.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/** - * TodoPanel Component - Shows agent-generated task plan as a right-side pane - * - * Displays current plan with task status and progress in Claude Code style: - * - Spinner with current task title, duration, and tokens - * - ✓ with strikethrough for completed tasks - * - ■ for in_progress tasks - * - □ for pending tasks - * - Collapsible completed tasks view - */ - -import React, { useEffect, useState } from "react"; -import { Box, Text } from "ink"; -import { useTodoStore } from "@tui/hooks/useTodoStore"; -import { useAppStore } from "@tui/store"; -import type { TodoItem, TodoStatus } from "@/types/todo"; - -const STATUS_ICONS: Record = { - pending: "□", - in_progress: "■", - completed: "✓", - failed: "✗", -}; - -const STATUS_COLORS: Record = { - pending: "white", - in_progress: "yellow", - completed: "green", - failed: "red", -}; - -const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -const MAX_VISIBLE_COMPLETED = 3; -const PANEL_WIDTH = 50; - -const formatDuration = (ms: number): string => { - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${hours}h ${minutes}m`; - } - if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } - return `${seconds}s`; -}; - -const formatTokens = (count: number): string => { - if (count >= 1000) { - return `${(count / 1000).toFixed(1)}k`; - } - return String(count); -}; - -interface TaskItemProps { - item: TodoItem; - isLast: boolean; -} - -const TaskItem = ({ item, isLast }: TaskItemProps): React.ReactElement => { - const icon = STATUS_ICONS[item.status]; - const color = STATUS_COLORS[item.status]; - const isCompleted = item.status === "completed"; - const isInProgress = item.status === "in_progress"; - - // Tree connector: L for last item, ├ for other items - const connector = isLast ? "└" : "├"; - - return ( - - {connector}─ - {icon} - - {item.title} - - - ); -}; - -export function TodoPanel(): React.ReactElement | null { - const currentPlan = useTodoStore((state) => state.currentPlan); - const todosVisible = useAppStore((state) => state.todosVisible); - const sessionStats = useAppStore((state) => state.sessionStats); - - // Spinner animation - const [spinnerFrame, setSpinnerFrame] = useState(0); - - // Elapsed time tracking - const [elapsed, setElapsed] = useState(0); - - useEffect(() => { - if (!currentPlan) return; - - const timer = setInterval(() => { - setSpinnerFrame((f) => (f + 1) % SPINNER_FRAMES.length); - setElapsed(Date.now() - currentPlan.createdAt); - }, 100); - - return () => clearInterval(timer); - }, [currentPlan]); - - // Don't render if no plan or hidden - if (!currentPlan || !todosVisible) { - return null; - } - - const { completed, total } = useTodoStore.getState().getProgress(); - const totalTokens = sessionStats.inputTokens + sessionStats.outputTokens; - - // Get current in_progress task - const currentTask = currentPlan.items.find( - (item) => item.status === "in_progress", - ); - - // Separate tasks by status - const completedTasks = currentPlan.items.filter( - (item) => item.status === "completed", - ); - const pendingTasks = currentPlan.items.filter( - (item) => item.status === "pending", - ); - const inProgressTasks = currentPlan.items.filter( - (item) => item.status === "in_progress", - ); - const failedTasks = currentPlan.items.filter( - (item) => item.status === "failed", - ); - - // Determine which completed tasks to show (most recent) - const visibleCompletedTasks = completedTasks.slice(-MAX_VISIBLE_COMPLETED); - const hiddenCompletedCount = Math.max( - 0, - completedTasks.length - MAX_VISIBLE_COMPLETED, - ); - - // Build task list in display order: - // 1. Visible completed tasks (oldest of the visible first) - // 2. In-progress task (current) - // 3. Pending tasks - // 4. Failed tasks - const displayTasks = [ - ...visibleCompletedTasks, - ...inProgressTasks, - ...pendingTasks, - ...failedTasks, - ]; - - return ( - - {/* Header with spinner, task name, duration, and tokens */} - - - {SPINNER_FRAMES[spinnerFrame]} - - {currentTask?.title ?? currentPlan.title} - - - - - ({formatDuration(elapsed)} · ↓ {formatTokens(totalTokens)} tokens) - - - - - {/* Task list with tree connectors */} - - {displayTasks.map((item, index) => ( - - ))} - - {/* Hidden completed tasks summary */} - {hiddenCompletedCount > 0 && ( - - └─ ... +{hiddenCompletedCount} completed - - )} - - - {/* Footer with progress */} - - - {completed}/{total} tasks - - Ctrl+T to hide - - - ); -} diff --git a/src/tui/components/VimStatusLine.tsx b/src/tui/components/VimStatusLine.tsx deleted file mode 100644 index cb764bb..0000000 --- a/src/tui/components/VimStatusLine.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * VimStatusLine Component - * - * Displays the current vim mode and hints - */ - -import React from "react"; -import { Box, Text } from "ink"; -import { useVimStore } from "@tui/hooks/useVimStore"; -import { useThemeColors } from "@tui/hooks/useThemeStore"; -import { - VIM_MODE_LABELS, - VIM_MODE_COLORS, - VIM_MODE_HINTS, -} from "@constants/vim"; -import type { VimMode } from "@/types/vim"; - -/** - * Props for VimStatusLine - */ -interface VimStatusLineProps { - /** Whether to show hints */ - showHints?: boolean; - /** Whether to show command buffer in command mode */ - showCommandBuffer?: boolean; - /** Whether to show search pattern */ - showSearchPattern?: boolean; -} - -/** - * Get mode display color - */ -const getModeColor = (mode: VimMode): string => { - return VIM_MODE_COLORS[mode] || "white"; -}; - -/** - * VimStatusLine Component - */ -export const VimStatusLine: React.FC = ({ - showHints = true, - showCommandBuffer = true, - showSearchPattern = true, -}) => { - const enabled = useVimStore((state) => state.enabled); - const mode = useVimStore((state) => state.mode); - const commandBuffer = useVimStore((state) => state.commandBuffer); - const searchPattern = useVimStore((state) => state.searchPattern); - const searchMatches = useVimStore((state) => state.searchMatches); - const currentMatchIndex = useVimStore((state) => state.currentMatchIndex); - const colors = useThemeColors(); - - // Don't render if vim mode is disabled - if (!enabled) { - return null; - } - - const modeLabel = VIM_MODE_LABELS[mode]; - const modeColor = getModeColor(mode); - const modeHint = VIM_MODE_HINTS[mode]; - - // Build status content - const renderModeIndicator = () => ( - - - {` ${modeLabel} `} - - - ); - - const renderCommandBuffer = () => { - if (!showCommandBuffer || mode !== "command" || !commandBuffer) { - return null; - } - - return ( - - : - {commandBuffer.slice(1)} - _ - - ); - }; - - const renderSearchInfo = () => { - if (!showSearchPattern || !searchPattern) { - return null; - } - - const matchInfo = - searchMatches.length > 0 - ? ` [${currentMatchIndex + 1}/${searchMatches.length}]` - : " [no matches]"; - - return ( - - - /{searchPattern} - {matchInfo} - - - ); - }; - - const renderHints = () => { - if (!showHints || mode === "command") { - return null; - } - - return ( - - {modeHint} - - ); - }; - - return ( - - - {renderModeIndicator()} - {renderCommandBuffer()} - {renderSearchInfo()} - - {renderHints()} - - ); -}; - -/** - * Compact vim mode indicator (just the mode label) - */ -export const VimModeIndicator: React.FC = () => { - const enabled = useVimStore((state) => state.enabled); - const mode = useVimStore((state) => state.mode); - - if (!enabled) { - return null; - } - - const modeLabel = VIM_MODE_LABELS[mode]; - const modeColor = getModeColor(mode); - - return ( - - {` ${modeLabel} `} - - ); -}; - -export default VimStatusLine; diff --git a/src/tui/components/diff-view/index.tsx b/src/tui/components/diff-view/index.tsx deleted file mode 100644 index a19cb4d..0000000 --- a/src/tui/components/diff-view/index.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * DiffView Component - Human-readable diff display for TUI - * - * Renders git-style diffs with: - * - Clear file headers - * - Line numbers - * - Color-coded additions/deletions - * - Syntax highlighting based on file type - * - Hunk separators - * - Summary statistics - */ - -import React from "react"; -import { Box, Text } from "ink"; -import type { DiffViewProps } from "@/types/tui"; -import { - detectLanguage, - getLanguageDisplayName, -} from "@utils/syntax-highlight/detect"; -import { DiffLine } from "@tui/components/diff-view/line-renderers"; - -export function DiffView({ - lines, - filePath, - additions = 0, - deletions = 0, - compact = false, - language: providedLanguage, -}: DiffViewProps): React.ReactElement { - // Calculate max line number width for alignment - const maxLineNum = lines.reduce((max, line) => { - const oldNum = line.oldLineNum ?? 0; - const newNum = line.newLineNum ?? 0; - return Math.max(max, oldNum, newNum); - }, 0); - const maxLineNumWidth = Math.max(3, String(maxLineNum).length); - - const showLineNumbers = !compact && lines.length > 0; - - // Auto-detect language from file path if not provided - const language = - providedLanguage || (filePath ? detectLanguage(filePath) : undefined); - const languageDisplay = language - ? getLanguageDisplayName(language) - : undefined; - - return ( - - {/* File header */} - {filePath && ( - - - 📄{" "} - - - {filePath} - - {languageDisplay && ( - - {" "} - ({languageDisplay}) - - )} - - )} - - {/* Diff content */} - - {lines.length === 0 ? ( - No changes - ) : ( - lines.map((line, index) => ( - - )) - )} - - - {/* Summary */} - {(additions > 0 || deletions > 0) && ( - - Changes: - {additions > 0 && ( - - +{additions}{" "} - - )} - {deletions > 0 && ( - - -{deletions} - - )} - - )} - - ); -} diff --git a/src/tui/components/diff-view/line-renderers.tsx b/src/tui/components/diff-view/line-renderers.tsx deleted file mode 100644 index eb0d0c7..0000000 --- a/src/tui/components/diff-view/line-renderers.tsx +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Diff Line Renderer Components - * - * Each diff line type has its own renderer function - */ - -import React from "react"; -import { Box, Text, Transform } from "ink"; -import type { DiffLineData, DiffLineProps, DiffLineType } from "@/types/tui"; -import { highlightLine } from "@utils/syntax-highlight/highlight"; - -// ============================================================================ -// Utility Components -// ============================================================================ - -interface HighlightedCodeProps { - content: string; - language?: string; -} - -const HighlightedCode = ({ - content, - language, -}: HighlightedCodeProps): React.ReactElement => { - if (!language || !content.trim()) { - return {content}; - } - - const highlighted = highlightLine(content, language); - - // Transform passes through the ANSI codes from cli-highlight - return ( - output}> - {highlighted} - - ); -}; - -// ============================================================================ -// Line Number Formatting -// ============================================================================ - -const padNum = (num: number | undefined, width: number): string => { - if (num === undefined) return " ".repeat(width); - return String(num).padStart(width, " "); -}; - -// ============================================================================ -// Line Renderers by Type -// ============================================================================ - -type LineRendererContext = { - showLineNumbers: boolean; - maxLineNumWidth: number; - language?: string; -}; - -const renderHeaderLine = (line: DiffLineData): React.ReactElement => ( - - - {line.content} - - -); - -const renderHunkLine = (line: DiffLineData): React.ReactElement => ( - - - {line.content} - - -); - -const renderAddLine = ( - line: DiffLineData, - ctx: LineRendererContext, -): React.ReactElement => ( - - {ctx.showLineNumbers && ( - <> - - {" ".repeat(ctx.maxLineNumWidth)} - - - {" "} - │{" "} - - - {padNum(line.newLineNum, ctx.maxLineNumWidth)} - - - {" "} - │{" "} - - - )} - - +{line.content} - - -); - -const renderRemoveLine = ( - line: DiffLineData, - ctx: LineRendererContext, -): React.ReactElement => ( - - {ctx.showLineNumbers && ( - <> - {padNum(line.oldLineNum, ctx.maxLineNumWidth)} - - {" "} - │{" "} - - - {" ".repeat(ctx.maxLineNumWidth)} - - - {" "} - │{" "} - - - )} - - -{line.content} - - -); - -const renderContextLine = ( - line: DiffLineData, - ctx: LineRendererContext, -): React.ReactElement => ( - - {ctx.showLineNumbers && ( - <> - - {padNum(line.oldLineNum, ctx.maxLineNumWidth)} - - - {" "} - │{" "} - - - {padNum(line.newLineNum, ctx.maxLineNumWidth)} - - - {" "} - │{" "} - - - )} - - - -); - -const renderSummaryLine = (): React.ReactElement => ( - - ────────────────────────────────────── - -); - -const renderDefaultLine = (line: DiffLineData): React.ReactElement => ( - - {line.content} - -); - -// ============================================================================ -// Line Renderer Registry -// ============================================================================ - -type SimpleLineRenderer = (line: DiffLineData) => React.ReactElement; -type ContextLineRenderer = ( - line: DiffLineData, - ctx: LineRendererContext, -) => React.ReactElement; - -const SIMPLE_LINE_RENDERERS: Partial> = - { - header: renderHeaderLine, - hunk: renderHunkLine, - summary: renderSummaryLine, - }; - -const CONTEXT_LINE_RENDERERS: Partial< - Record -> = { - add: renderAddLine, - remove: renderRemoveLine, - context: renderContextLine, -}; - -// ============================================================================ -// Main Export -// ============================================================================ - -export function DiffLine({ - line, - showLineNumbers, - maxLineNumWidth, - language, -}: DiffLineProps): React.ReactElement { - // Try simple renderers first (no context needed) - const simpleRenderer = SIMPLE_LINE_RENDERERS[line.type]; - if (simpleRenderer) { - return simpleRenderer(line); - } - - // Try context-aware renderers - const contextRenderer = CONTEXT_LINE_RENDERERS[line.type]; - if (contextRenderer) { - return contextRenderer(line, { - showLineNumbers, - maxLineNumWidth, - language, - }); - } - - // Default fallback - return renderDefaultLine(line); -} diff --git a/src/tui/components/diff-view/utils.ts b/src/tui/components/diff-view/utils.ts deleted file mode 100644 index 4a543ce..0000000 --- a/src/tui/components/diff-view/utils.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Diff View Utility Functions - */ - -import type { DiffLineData } from "@/types/tui"; - -/** - * Strip ANSI escape codes from a string - */ -export const stripAnsi = (str: string): string => { - return str.replace(/\x1b\[[0-9;]*m/g, ""); -}; - -/** - * Check if content looks like a diff output - */ -export const isDiffContent = (content: string): boolean => { - // Strip ANSI codes for pattern matching - const cleanContent = stripAnsi(content); - - // Check for common diff markers (not anchored to line start due to title prefixes) - const diffPatterns = [ - /@@\s*-\d+/m, // Hunk header - /---\s+[ab]?\//m, // File header - /\+\+\+\s+[ab]?\//m, // File header - ]; - - return diffPatterns.some((pattern) => pattern.test(cleanContent)); -}; - -/** - * Parse raw diff output into structured DiffLineData - */ -export const parseDiffOutput = ( - diffOutput: string, -): { - lines: DiffLineData[]; - filePath?: string; - additions: number; - deletions: number; -} => { - const rawLines = diffOutput.split("\n"); - const lines: DiffLineData[] = []; - let filePath: string | undefined; - let additions = 0; - let deletions = 0; - let currentOldLine = 0; - let currentNewLine = 0; - let inDiff = false; // Track if we're inside the diff content - - for (const rawLine of rawLines) { - // Strip ANSI codes for parsing - const cleanLine = stripAnsi(rawLine); - - // Skip title lines (e.g., "Edited: filename" before diff starts) - if ( - !inDiff && - !cleanLine.startsWith("---") && - !cleanLine.startsWith("@@") - ) { - // Check if this looks like a title line - if (cleanLine.match(/^(Edited|Created|Wrote|Modified|Deleted):/i)) { - continue; - } - // Skip empty lines before diff starts - if (cleanLine.trim() === "") { - continue; - } - } - - // File header detection (marks start of diff) - if (cleanLine.startsWith("--- a/") || cleanLine.startsWith("--- ")) { - inDiff = true; - continue; // Skip old file header - } - - if (cleanLine.startsWith("+++ b/") || cleanLine.startsWith("+++ ")) { - inDiff = true; - filePath = cleanLine.replace(/^\+\+\+ [ab]?\//, "").trim(); - continue; - } - - // Hunk header: @@ -oldStart,oldCount +newStart,newCount @@ - const hunkMatch = cleanLine.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (hunkMatch) { - inDiff = true; - currentOldLine = parseInt(hunkMatch[1], 10); - currentNewLine = parseInt(hunkMatch[2], 10); - lines.push({ type: "hunk", content: cleanLine }); - continue; - } - - // Only process diff content lines after we've entered the diff - if (!inDiff) { - continue; - } - - // Summary line (e.g., "+5 / -3") - marks end of diff - if (cleanLine.match(/^\+\d+\s*\/\s*-\d+$/)) { - const summaryMatch = cleanLine.match(/^\+(\d+)\s*\/\s*-(\d+)$/); - if (summaryMatch) { - // Use parsed values if we haven't counted any yet - if (additions === 0 && deletions === 0) { - additions = parseInt(summaryMatch[1], 10); - deletions = parseInt(summaryMatch[2], 10); - } - } - continue; - } - - // Addition line - if (cleanLine.startsWith("+")) { - lines.push({ - type: "add", - content: cleanLine.slice(1), - newLineNum: currentNewLine, - }); - currentNewLine++; - additions++; - continue; - } - - // Deletion line - if (cleanLine.startsWith("-")) { - lines.push({ - type: "remove", - content: cleanLine.slice(1), - oldLineNum: currentOldLine, - }); - currentOldLine++; - deletions++; - continue; - } - - // Context line (starts with space or is part of diff) - if (cleanLine.startsWith(" ")) { - lines.push({ - type: "context", - content: cleanLine.slice(1), - oldLineNum: currentOldLine, - newLineNum: currentNewLine, - }); - currentOldLine++; - currentNewLine++; - continue; - } - - // Handle empty lines in diff context - if (cleanLine === "") { - lines.push({ - type: "context", - content: "", - oldLineNum: currentOldLine, - newLineNum: currentNewLine, - }); - currentOldLine++; - currentNewLine++; - } - } - - return { lines, filePath, additions, deletions }; -}; diff --git a/src/tui/components/home/HomeContent.tsx b/src/tui/components/home/HomeContent.tsx deleted file mode 100644 index 30d07d4..0000000 --- a/src/tui/components/home/HomeContent.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * HomeContent Component - * Logo and info content for the home screen (without input) - */ - -import React, { useMemo } from "react"; -import { Box, Text, useStdout } from "ink"; -import { Logo } from "./Logo"; - -interface HomeContentProps { - provider: string; - model: string; - version: string; -} - -export const HomeContent: React.FC = ({ - provider, - model, - version, -}) => { - const { stdout } = useStdout(); - const terminalHeight = stdout?.rows ?? 24; - - // Build info line like: "v0.1.74 | GitHub Copilot | gpt-5.1" - const infoLine = useMemo(() => { - const parts: string[] = []; - if (version) parts.push(`v${version}`); - if (provider) parts.push(provider); - if (model) parts.push(model); - return parts.join(" | "); - }, [version, provider, model]); - - // Calculate spacing to center the logo - // Logo (3 lines) + subtitle (1) + info (1) + margins (~2) = ~7 lines - // Input box (~4 lines) - const contentHeight = 7; - const inputHeight = 4; - const availableSpace = terminalHeight - contentHeight - inputHeight; - const topPadding = Math.max(0, Math.floor(availableSpace / 2)); - - return ( - - {/* Top padding to center content */} - {topPadding > 0 && } - - {/* Main content area - logo and info */} - - {/* Logo with gradient */} - - - {/* Subtitle */} - - AI Coding Assistant - - - {/* Version and provider info */} - - {infoLine} - - - - {/* Spacer to push input to bottom */} - - - ); -}; diff --git a/src/tui/components/home/HomeFooter.tsx b/src/tui/components/home/HomeFooter.tsx deleted file mode 100644 index 2c69964..0000000 --- a/src/tui/components/home/HomeFooter.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * HomeFooter Component - * Bottom status bar showing directory, MCP status, and version - */ - -import React from "react"; -import { Box, Text } from "ink"; -import { useThemeColors } from "@tui/hooks/useThemeStore"; -import { MCP_INDICATORS } from "@constants/home-screen"; -import type { HomeFooterProps } from "@types/home-screen"; - -export const HomeFooter: React.FC = ({ - directory, - mcpConnectedCount, - mcpHasErrors, - version, -}) => { - const colors = useThemeColors(); - - const mcpStatusColor = mcpHasErrors - ? colors.error - : mcpConnectedCount > 0 - ? colors.success - : colors.textDim; - - return ( - - {directory} - - {mcpConnectedCount > 0 && ( - - - {MCP_INDICATORS.connected} - {mcpConnectedCount} MCP - - /status - - )} - - - - - v{version} - - - ); -}; diff --git a/src/tui/components/home/HomeScreen.tsx b/src/tui/components/home/HomeScreen.tsx deleted file mode 100644 index 663020a..0000000 --- a/src/tui/components/home/HomeScreen.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * HomeScreen Component - * Main welcome/home screen with logo vertically centered and input at footer - */ - -import React, { useMemo } from "react"; -import { Box, Text, useStdout } from "ink"; -import { Logo } from "./Logo"; -import { PromptBox } from "./PromptBox"; -import type { HomeScreenProps } from "@types/home-screen"; - -export const HomeScreen: React.FC = ({ - onSubmit, - provider, - model, - version, -}) => { - const { stdout } = useStdout(); - const terminalHeight = stdout?.rows ?? 24; - - // Build info line like: "v0.1.74 | GitHub Copilot | gpt-5.1" - const infoLine = useMemo(() => { - const parts: string[] = []; - if (version) parts.push(`v${version}`); - if (provider) parts.push(provider); - if (model) parts.push(model); - return parts.join(" | "); - }, [version, provider, model]); - - // Calculate spacing to center the logo - // Logo (3 lines) + subtitle (1) + info (1) + margins (~2) = ~7 lines - // Input box (~4 lines) - // So we need (terminalHeight - 7 - 4) / 2 lines of padding above the logo - const contentHeight = 7; - const inputHeight = 4; - const availableSpace = terminalHeight - contentHeight - inputHeight; - const topPadding = Math.max(0, Math.floor(availableSpace / 2)); - - return ( - - {/* Top padding to center content */} - {topPadding > 0 && } - - {/* Main content area - logo and info */} - - {/* Logo with gradient */} - - - {/* Subtitle */} - - AI Coding Assistant - - - {/* Version and provider info */} - - {infoLine} - - - - {/* Spacer to push input to bottom */} - - - {/* Footer with input box - always at bottom, full width */} - - - - - ); -}; diff --git a/src/tui/components/home/Logo.tsx b/src/tui/components/home/Logo.tsx deleted file mode 100644 index fe29503..0000000 --- a/src/tui/components/home/Logo.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Logo Component - * ASCII art logo with gradient colors for the home screen - */ - -import React from "react"; -import { Box, Text } from "ink"; -import { TUI_BANNER, HEADER_GRADIENT_COLORS } from "@constants/tui-components"; - -export const Logo: React.FC = () => { - return ( - - {TUI_BANNER.map((line, i) => ( - - {line} - - ))} - - ); -}; diff --git a/src/tui/components/home/PromptBox.tsx b/src/tui/components/home/PromptBox.tsx deleted file mode 100644 index 7730749..0000000 --- a/src/tui/components/home/PromptBox.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/** - * PromptBox Component - * Bordered input box at the footer with placeholder and help text - */ - -import React, { useState, useCallback } from "react"; -import { Box, Text, useInput } from "ink"; -import { useThemeColors } from "@tui/hooks/useThemeStore"; -import { PLACEHOLDERS } from "@constants/home-screen"; - -interface PromptBoxProps { - onSubmit: (message: string) => void; - placeholder?: string; -} - -const getRandomPlaceholder = (): string => { - return PLACEHOLDERS[Math.floor(Math.random() * PLACEHOLDERS.length)]; -}; - -export const PromptBox: React.FC = ({ - onSubmit, - placeholder, -}) => { - const colors = useThemeColors(); - const [value, setValue] = useState(""); - const [cursorPos, setCursorPos] = useState(0); - const [randomPlaceholder] = useState( - () => placeholder ?? getRandomPlaceholder(), - ); - - const handleInput = useCallback( - ( - input: string, - key: { - return?: boolean; - backspace?: boolean; - delete?: boolean; - leftArrow?: boolean; - rightArrow?: boolean; - ctrl?: boolean; - }, - ) => { - if (key.return) { - if (value.trim()) { - onSubmit(value.trim()); - setValue(""); - setCursorPos(0); - } - return; - } - - if (key.backspace) { - if (cursorPos > 0) { - setValue(value.slice(0, cursorPos - 1) + value.slice(cursorPos)); - setCursorPos(cursorPos - 1); - } - return; - } - - if (key.delete) { - if (cursorPos < value.length) { - setValue(value.slice(0, cursorPos) + value.slice(cursorPos + 1)); - } - return; - } - - if (key.leftArrow) { - setCursorPos(Math.max(0, cursorPos - 1)); - return; - } - - if (key.rightArrow) { - setCursorPos(Math.min(value.length, cursorPos + 1)); - return; - } - - // Clear line with Ctrl+U - if (key.ctrl && input === "u") { - setValue(""); - setCursorPos(0); - return; - } - - if (input && !key.return) { - setValue(value.slice(0, cursorPos) + input + value.slice(cursorPos)); - setCursorPos(cursorPos + input.length); - } - }, - [value, cursorPos, onSubmit], - ); - - useInput(handleInput); - - const isEmpty = value.length === 0; - - return ( - - {/* Input line */} - {isEmpty ? ( - - > - - T - - ype your message... - - ) : ( - - > - - {value.slice(0, cursorPos)} - - {cursorPos < value.length ? value[cursorPos] : " "} - - {value.slice(cursorPos + 1)} - - - )} - - {/* Help text */} - - - Enter to send • Alt+Enter for newline • @ to add files - - - - ); -}; diff --git a/src/tui/components/home/SessionHeader.tsx b/src/tui/components/home/SessionHeader.tsx deleted file mode 100644 index d7e2eca..0000000 --- a/src/tui/components/home/SessionHeader.tsx +++ /dev/null @@ -1,161 +0,0 @@ -/** - * SessionHeader Component - * Header showing session title, token count, cost, version, and brain status - */ - -import React from "react"; -import { Box, Text } from "ink"; -import { useThemeColors } from "@tui/hooks/useThemeStore"; -import type { SessionHeaderProps } from "@types/home-screen"; -import { BRAIN_BANNER } from "@constants/brain"; - -const formatCost = (cost: number): string => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(cost); -}; - -const formatTokenCount = (count: number): string => { - if (count >= 1000) { - return `${(count / 1000).toFixed(1)}k`; - } - return count.toLocaleString(); -}; - -const MODE_COLORS: Record = { - agent: "cyan", - ask: "green", - "code-review": "yellow", -}; - -const MODE_LABELS: Record = { - agent: "AGENT", - ask: "ASK", - "code-review": "REVIEW", -}; - -const BRAIN_STATUS_COLORS: Record = { - connected: "green", - connecting: "yellow", - disconnected: "gray", - error: "red", -}; - -const BRAIN_STATUS_ICONS: Record = { - connected: BRAIN_BANNER.EMOJI_CONNECTED, - connecting: "...", - disconnected: BRAIN_BANNER.EMOJI_DISCONNECTED, - error: "!", -}; - -export const SessionHeader: React.FC = ({ - title, - tokenCount, - contextPercentage, - cost, - version, - interactionMode = "agent", - brain, - onDismissBrainBanner, -}) => { - const colors = useThemeColors(); - - const contextInfo = - contextPercentage !== undefined - ? `${formatTokenCount(tokenCount)} ${contextPercentage}%` - : formatTokenCount(tokenCount); - - const modeColor = MODE_COLORS[interactionMode] || "cyan"; - const modeLabel = MODE_LABELS[interactionMode] || interactionMode.toUpperCase(); - - const brainStatus = brain?.status ?? "disconnected"; - const brainColor = BRAIN_STATUS_COLORS[brainStatus] || "gray"; - const brainIcon = BRAIN_STATUS_ICONS[brainStatus] || BRAIN_BANNER.EMOJI_DISCONNECTED; - const showBrainBanner = brain?.showBanner && brainStatus === "disconnected"; - - return ( - - {/* Brain Banner - shown when not connected */} - {showBrainBanner && ( - - - - - {BRAIN_BANNER.EMOJI_CONNECTED} - - - {BRAIN_BANNER.TITLE} - - - {" "} - - {BRAIN_BANNER.CTA}:{" "} - - - {BRAIN_BANNER.URL} - - - - [press q to dismiss] - - - - )} - - {/* Main Header */} - - - {/* Title and Mode */} - - - # {title} - - - [{modeLabel}] - - - - {/* Brain status, Context info and version */} - - {/* Brain status indicator */} - {brain && ( - - - {brainIcon} - - {brainStatus === "connected" && ( - - {" "} - {brain.knowledgeCount}K/{brain.memoryCount}M - - )} - - )} - - {contextInfo} ({formatCost(cost)}) - - v{version} - - - - - ); -}; diff --git a/src/tui/components/home/index.ts b/src/tui/components/home/index.ts deleted file mode 100644 index 9640395..0000000 --- a/src/tui/components/home/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Home Screen Components - * Export all home screen related components - */ - -export { Logo } from "./Logo"; -export { HomeFooter } from "./HomeFooter"; -export { PromptBox } from "./PromptBox"; -export { HomeScreen } from "./HomeScreen"; -export { HomeContent } from "./HomeContent"; -export { SessionHeader } from "./SessionHeader"; diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts deleted file mode 100644 index d179d52..0000000 --- a/src/tui/components/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * TUI Components - Export all components - */ - -export { LogPanel } from "@tui/components/LogPanel"; -export { InputArea } from "@tui/components/InputArea"; -export { PermissionModal } from "@tui/components/PermissionModal"; -export { StatusBar } from "@tui/components/StatusBar"; -export { Header } from "@tui/components/Header"; -export { SelectMenu } from "@tui/components/SelectMenu"; -export { FilePicker } from "@tui/components/FilePicker"; -export { CommandMenu, SLASH_COMMANDS } from "@tui/components/CommandMenu"; -export { ModelSelect } from "@tui/components/ModelSelect"; -export { AgentSelect } from "@tui/components/AgentSelect"; -export { ThemeSelect } from "@tui/components/ThemeSelect"; -export { MCPSelect } from "@tui/components/MCPSelect"; -export { MCPBrowser } from "@tui/components/MCPBrowser"; -export { TodoPanel } from "@tui/components/TodoPanel"; -export { LearningModal } from "@tui/components/LearningModal"; -export { ImageAttachment, ImageAttachmentCompact } from "@tui/components/ImageAttachment"; -export { BouncingLoader } from "@tui/components/BouncingLoader"; -export { - DiffView, - parseDiffOutput, - isDiffContent, -} from "@tui/components/DiffView"; -export { - VimStatusLine, - VimModeIndicator, -} from "@tui/components/VimStatusLine"; - -// Home screen components -export { - HomeScreen, - HomeContent, - SessionHeader, - Logo, - HomeFooter, - PromptBox, -} from "@tui/components/home/index"; - -// Re-export types for convenience -export type { SelectOption } from "@/types/tui"; -export type { DiffLineData, DiffViewProps } from "@/types/tui"; diff --git a/src/tui/components/input-line/index.tsx b/src/tui/components/input-line/index.tsx deleted file mode 100644 index cbd68dd..0000000 --- a/src/tui/components/input-line/index.tsx +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Input Line Component with Styled Placeholders - */ - -import React from "react"; -import { Text } from "ink"; -import type { PastedContent } from "@interfaces/PastedContent"; - -type InputLineProps = { - line: string; - lineIndex: number; - lineStartPos: number; - cursorLine: number; - cursorCol: number; - pastedBlocks: Map; - colors: { - primary: string; - bgCursor: string; - textDim: string; - secondary: string; - }; -}; - -type Segment = { - text: string; - isPasted: boolean; - startPos: number; - endPos: number; -}; - -/** - * Splits a line into segments, identifying pasted placeholders - */ -const segmentLine = ( - line: string, - lineStartPos: number, - pastedBlocks: Map, -): Segment[] => { - const segments: Segment[] = []; - const lineEndPos = lineStartPos + line.length; - - // Find all pasted blocks that overlap with this line - const overlappingBlocks: PastedContent[] = []; - for (const block of pastedBlocks.values()) { - if (block.startPos < lineEndPos && block.endPos > lineStartPos) { - overlappingBlocks.push(block); - } - } - - if (overlappingBlocks.length === 0) { - return [ - { - text: line, - isPasted: false, - startPos: lineStartPos, - endPos: lineEndPos, - }, - ]; - } - - // Sort blocks by start position - overlappingBlocks.sort((a, b) => a.startPos - b.startPos); - - let currentPos = lineStartPos; - - for (const block of overlappingBlocks) { - // Add text before this block - if (currentPos < block.startPos && block.startPos <= lineEndPos) { - const beforeEnd = Math.min(block.startPos, lineEndPos); - const textStart = currentPos - lineStartPos; - const textEnd = beforeEnd - lineStartPos; - segments.push({ - text: line.slice(textStart, textEnd), - isPasted: false, - startPos: currentPos, - endPos: beforeEnd, - }); - currentPos = beforeEnd; - } - - // Add the pasted block portion within this line - const blockStart = Math.max(block.startPos, lineStartPos); - const blockEnd = Math.min(block.endPos, lineEndPos); - if (blockStart < blockEnd) { - const textStart = blockStart - lineStartPos; - const textEnd = blockEnd - lineStartPos; - segments.push({ - text: line.slice(textStart, textEnd), - isPasted: true, - startPos: blockStart, - endPos: blockEnd, - }); - currentPos = blockEnd; - } - } - - // Add remaining text after all blocks - if (currentPos < lineEndPos) { - const textStart = currentPos - lineStartPos; - segments.push({ - text: line.slice(textStart), - isPasted: false, - startPos: currentPos, - endPos: lineEndPos, - }); - } - - return segments; -}; - -export function InputLine({ - line, - lineIndex, - lineStartPos, - cursorLine, - cursorCol, - pastedBlocks, - colors, -}: InputLineProps): React.ReactElement { - const isCursorLine = lineIndex === cursorLine; - const segments = segmentLine(line, lineStartPos, pastedBlocks); - - // If this is the cursor line, we need to handle cursor rendering within segments - if (isCursorLine) { - return ( - - {segments.map((segment, segIdx) => { - const segmentStartInLine = segment.startPos - lineStartPos; - const segmentEndInLine = segment.endPos - lineStartPos; - const cursorInSegment = - cursorCol >= segmentStartInLine && cursorCol < segmentEndInLine; - - if (cursorInSegment) { - const cursorPosInSegment = cursorCol - segmentStartInLine; - const beforeCursor = segment.text.slice(0, cursorPosInSegment); - const atCursor = segment.text[cursorPosInSegment] ?? " "; - const afterCursor = segment.text.slice(cursorPosInSegment + 1); - - return ( - - {segment.isPasted ? ( - <> - - {beforeCursor} - - - {atCursor} - - - {afterCursor} - - - ) : ( - <> - {beforeCursor} - - {atCursor} - - {afterCursor} - - )} - - ); - } - - // Cursor is not in this segment - if (segment.isPasted) { - return ( - - {segment.text} - - ); - } - - return {segment.text}; - })} - {/* Handle cursor at end of line */} - {cursorCol >= line.length && ( - - {" "} - - )} - - ); - } - - // Non-cursor line - simple rendering - return ( - - {segments.map((segment, segIdx) => - segment.isPasted ? ( - - {segment.text} - - ) : ( - {segment.text} - ), - )} - - ); -} - -/** - * Calculates the starting position of a line in the buffer - */ -export const calculateLineStartPos = ( - buffer: string, - lineIndex: number, -): number => { - const lines = buffer.split("\n"); - let pos = 0; - for (let i = 0; i < lineIndex && i < lines.length; i++) { - pos += lines[i].length + 1; // +1 for newline - } - return pos; -}; diff --git a/src/tui/components/log-panel/entry-renderers.tsx b/src/tui/components/log-panel/entry-renderers.tsx deleted file mode 100644 index 11bae34..0000000 --- a/src/tui/components/log-panel/entry-renderers.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Log Entry Renderer Components - * - * Each entry type has its own renderer function - */ - -import React from "react"; -import { Box, Text } from "ink"; -import type { LogEntry, LogEntryProps, ToolStatus } from "@/types/tui"; -import { - TOOL_STATUS_ICONS, - TOOL_STATUS_COLORS, - type ToolStatusColor, -} from "@constants/tui-components"; -import { - DiffView, - parseDiffOutput, - isDiffContent, -} from "@tui/components/DiffView"; -import { StreamingMessage } from "@tui/components/StreamingMessage"; - -// ============================================================================ -// Entry Renderers by Type -// ============================================================================ - -const renderUserEntry = (entry: LogEntry): React.ReactElement => ( - - - You - - - {entry.content} - - -); - -const renderAssistantEntry = (entry: LogEntry): React.ReactElement => ( - - - CodeTyper - - - {entry.content} - - -); - -const renderErrorEntry = (entry: LogEntry): React.ReactElement => ( - - ✗ Error: {entry.content} - -); - -const renderSystemEntry = (entry: LogEntry): React.ReactElement => ( - - ⚙ {entry.content} - -); - -const renderThinkingEntry = (entry: LogEntry): React.ReactElement => ( - - ● {entry.content} - -); - -const renderDefaultEntry = (entry: LogEntry): React.ReactElement => ( - - {entry.content} - -); - -const renderToolEntry = (entry: LogEntry): React.ReactElement => { - const toolStatus: ToolStatus = entry.metadata?.toolStatus ?? "pending"; - const statusIcon = TOOL_STATUS_ICONS[toolStatus]; - const statusColor = TOOL_STATUS_COLORS[toolStatus] as ToolStatusColor; - - // Check if content contains diff output - const hasDiff = - entry.metadata?.diffData?.isDiff || isDiffContent(entry.content); - - if (hasDiff && entry.metadata?.toolStatus === "success") { - const diffData = parseDiffOutput(entry.content); - return ( - - - {statusIcon} - {entry.metadata?.toolName || "tool"} - {entry.metadata?.toolDescription && ( - <> - : - {entry.metadata.toolDescription} - - )} - - - - - - ); - } - - // Check if content is multiline (file read output) - const isMultiline = entry.content.includes("\n"); - - if (isMultiline) { - // Render multiline content compactly in a column layout - const lines = entry.content.split("\n"); - return ( - - - {statusIcon} - {entry.metadata?.toolName || "tool"} - : - {entry.metadata?.toolDescription || lines[0]} - - - {lines - .slice(entry.metadata?.toolDescription ? 0 : 1) - .map((line, i) => ( - - {line} - - ))} - - - ); - } - - return ( - - {statusIcon} - {entry.metadata?.toolName || "tool"} - : - {entry.content} - - ); -}; - -// ============================================================================ -// Entry Renderer Registry -// ============================================================================ - -type EntryRenderer = (entry: LogEntry) => React.ReactElement; - -const renderStreamingEntry = (entry: LogEntry): React.ReactElement => ( - -); - -const ENTRY_RENDERERS: Record = { - user: renderUserEntry, - assistant: renderAssistantEntry, - assistant_streaming: renderStreamingEntry, - tool: renderToolEntry, - error: renderErrorEntry, - system: renderSystemEntry, - thinking: renderThinkingEntry, -}; - -// ============================================================================ -// Main Export -// ============================================================================ - -export function LogEntryDisplay({ entry }: LogEntryProps): React.ReactElement { - const renderer = ENTRY_RENDERERS[entry.type] ?? renderDefaultEntry; - return renderer(entry); -} diff --git a/src/tui/components/log-panel/index.tsx b/src/tui/components/log-panel/index.tsx deleted file mode 100644 index 71b4e75..0000000 --- a/src/tui/components/log-panel/index.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Log Panel Component - Displays conversation history and tool execution - * Supports scrolling with Page Up/Down, mouse wheel, and auto-scroll on new messages - * - * Features: - * - Auto-scroll to bottom when new content arrives (during active operations) - * - User scroll detection (scrolling up pauses auto-scroll) - * - Resume auto-scroll when user scrolls back to bottom - * - Virtual scrolling for performance - * - Shows logo and welcome screen when no logs - */ - -import React, { useEffect, useRef, useMemo } from "react"; -import { Box, Text, useStdout } from "ink"; -import { useAppStore } from "@tui/store"; -import { - LOG_PANEL_RESERVED_HEIGHT, - LOG_PANEL_MIN_HEIGHT, - LOG_PANEL_DEFAULT_TERMINAL_HEIGHT, -} from "@constants/tui-components"; -import { LogEntryDisplay } from "@tui/components/log-panel/entry-renderers"; -import { ThinkingIndicator } from "@tui/components/log-panel/thinking-indicator"; -import { estimateEntryLines } from "@tui/components/log-panel/utils"; -import { Logo } from "@tui/components/home/Logo"; - -export function LogPanel(): React.ReactElement { - const allLogs = useAppStore((state) => state.logs); - const thinkingMessage = useAppStore((state) => state.thinkingMessage); - const mode = useAppStore((state) => state.mode); - const autoScroll = useAppStore((state) => state.autoScroll); - const userScrolled = useAppStore((state) => state.userScrolled); - const setScrollDimensions = useAppStore((state) => state.setScrollDimensions); - const getEffectiveScrollOffset = useAppStore( - (state) => state.getEffectiveScrollOffset, - ); - const { stdout } = useStdout(); - - // Filter out quiet tool entries (read-only operations like file reads, ls, etc.) - const logs = useMemo(() => { - return allLogs.filter((entry) => { - // Always show non-tool entries - if (entry.type !== "tool") return true; - // Hide quiet operations - if (entry.metadata?.quiet) return false; - return true; - }); - }, [allLogs]); - - // Get terminal height (subtract space for header, status bar, input, borders) - const terminalHeight = stdout?.rows ?? LOG_PANEL_DEFAULT_TERMINAL_HEIGHT; - const visibleHeight = Math.max( - LOG_PANEL_MIN_HEIGHT, - terminalHeight - LOG_PANEL_RESERVED_HEIGHT, - ); - - // Calculate total content height - const totalLines = useMemo(() => { - return logs.reduce((sum, entry) => sum + estimateEntryLines(entry), 0); - }, [logs]); - - // Track previous values for change detection - const prevLogCountRef = useRef(logs.length); - const prevTotalLinesRef = useRef(totalLines); - - // Update scroll dimensions when content or viewport changes - useEffect(() => { - setScrollDimensions(totalLines, visibleHeight); - }, [totalLines, visibleHeight, setScrollDimensions]); - - // Auto-scroll to bottom when new logs arrive (only if auto-scroll is enabled) - useEffect(() => { - const logsChanged = logs.length !== prevLogCountRef.current; - const contentGrew = totalLines > prevTotalLinesRef.current; - - if ((logsChanged || contentGrew) && autoScroll && !userScrolled) { - // New content added and auto-scroll is active, update dimensions to scroll to bottom - setScrollDimensions(totalLines, visibleHeight); - } - - prevLogCountRef.current = logs.length; - prevTotalLinesRef.current = totalLines; - }, [ - logs.length, - totalLines, - autoScroll, - userScrolled, - visibleHeight, - setScrollDimensions, - ]); - - // Calculate effective scroll offset - const maxScroll = Math.max(0, totalLines - visibleHeight); - const effectiveOffset = getEffectiveScrollOffset(); - - // Calculate which entries to show based on scroll position - const { visibleEntries } = useMemo(() => { - let currentLine = 0; - let startIdx = 0; - let endIdx = logs.length; - let skipLines = 0; - - // Find start index - for (let i = 0; i < logs.length; i++) { - const entryLines = estimateEntryLines(logs[i]); - if (currentLine + entryLines > effectiveOffset) { - startIdx = i; - skipLines = effectiveOffset - currentLine; - break; - } - currentLine += entryLines; - } - - // Find end index - currentLine = 0; - for (let i = startIdx; i < logs.length; i++) { - const entryLines = estimateEntryLines(logs[i]); - currentLine += entryLines; - if (currentLine >= visibleHeight + (i === startIdx ? skipLines : 0)) { - endIdx = i + 1; - break; - } - } - - return { - visibleEntries: logs.slice(startIdx, endIdx), - startOffset: skipLines, - }; - }, [logs, effectiveOffset, visibleHeight]); - - // Show scroll indicators - const canScrollUp = effectiveOffset > 0; - const canScrollDown = effectiveOffset < maxScroll; - - return ( - - {/* Scroll up indicator */} - {canScrollUp && ( - - - ▲ {userScrolled ? "Auto-scroll paused • " : ""}Scroll: Shift+↑ | - PageUp | Mouse - - - )} - - {logs.length === 0 && !thinkingMessage ? ( - - - - AI Coding Assistant - - - Type your prompt below • Ctrl+M to switch modes - - - ) : ( - - {visibleEntries.map((entry) => ( - - ))} - - {mode === "thinking" && thinkingMessage && ( - - )} - - )} - - {/* Scroll down indicator */} - {canScrollDown && ( - - - ▼ Scroll: Shift+↓ | PageDown | Mouse - {userScrolled ? " • Ctrl+End to resume" : ""} - - - )} - - ); -} diff --git a/src/tui/components/log-panel/thinking-indicator.tsx b/src/tui/components/log-panel/thinking-indicator.tsx deleted file mode 100644 index 833bd16..0000000 --- a/src/tui/components/log-panel/thinking-indicator.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Thinking Indicator Component - */ - -import React, { useState, useEffect } from "react"; -import { Box, Text } from "ink"; -import type { ThinkingIndicatorProps } from "@/types/tui"; -import { - THINKING_SPINNER_FRAMES, - THINKING_SPINNER_INTERVAL, -} from "@constants/tui-components"; - -export function ThinkingIndicator({ - message, -}: ThinkingIndicatorProps): React.ReactElement { - const [frame, setFrame] = useState(0); - - useEffect(() => { - const timer = setInterval(() => { - setFrame((prev) => (prev + 1) % THINKING_SPINNER_FRAMES.length); - }, THINKING_SPINNER_INTERVAL); - return () => clearInterval(timer); - }, []); - - return ( - - {THINKING_SPINNER_FRAMES[frame]} - {message} - - ); -} diff --git a/src/tui/components/log-panel/utils.ts b/src/tui/components/log-panel/utils.ts deleted file mode 100644 index 489938d..0000000 --- a/src/tui/components/log-panel/utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Log Panel Utility Functions - */ - -import type { LogEntry, LogEntryType } from "@/types/tui"; -import { LOG_ENTRY_EXTRA_LINES } from "@constants/tui-components"; -import { isDiffContent } from "@tui/components/DiffView"; - -const ENTRY_LINES_CONFIG: Record = { - user: LOG_ENTRY_EXTRA_LINES.user, - assistant: LOG_ENTRY_EXTRA_LINES.assistant, - assistant_streaming: LOG_ENTRY_EXTRA_LINES.assistant, - tool: LOG_ENTRY_EXTRA_LINES.tool, - error: LOG_ENTRY_EXTRA_LINES.default, - system: LOG_ENTRY_EXTRA_LINES.default, - thinking: LOG_ENTRY_EXTRA_LINES.default, -}; - -/** - * Estimate lines needed for a log entry (rough calculation) - */ -export const estimateEntryLines = (entry: LogEntry): number => { - const contentLines = entry.content.split("\n").length; - const baseExtra = - ENTRY_LINES_CONFIG[entry.type] ?? LOG_ENTRY_EXTRA_LINES.default; - - // Diff views take more space - if ( - entry.type === "tool" && - (entry.metadata?.diffData?.isDiff || isDiffContent(entry.content)) - ) { - return contentLines + LOG_ENTRY_EXTRA_LINES.toolWithDiff; - } - - return contentLines + baseExtra; -}; diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts deleted file mode 100644 index 936aee2..0000000 --- a/src/tui/hooks/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * TUI Hooks exports - */ - -export { useMouseScroll } from "@tui/hooks/useMouseScroll"; -export { useAutoScroll } from "@tui/hooks/useAutoScroll"; - -// Re-export types for convenience -export type { MouseScrollOptions } from "@interfaces/MouseScrollOptions"; -export type { ScrollDirection } from "@constants/mouse-scroll"; -export type { - AutoScrollOptions, - AutoScrollState, - AutoScrollActions, - AutoScrollReturn, -} from "@interfaces/AutoScrollOptions"; diff --git a/src/tui/hooks/useAutoScroll.ts b/src/tui/hooks/useAutoScroll.ts deleted file mode 100644 index 37881b1..0000000 --- a/src/tui/hooks/useAutoScroll.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Auto-Scroll Hook - * - * Manages auto-scroll behavior for the log panel, inspired by opencode's implementation. - * Features: - * - Automatic scroll to bottom when content is being generated - * - User scroll detection (scrolling up pauses auto-scroll) - * - Resume auto-scroll when user scrolls back to bottom - * - Settling period after operations complete - * - Distinguishes between user-initiated and programmatic scrolls - */ - -import { useState, useCallback, useEffect, useRef } from "react"; -import type { - AutoScrollOptions, - AutoScrollReturn, -} from "@interfaces/AutoScrollOptions"; -import { - BOTTOM_THRESHOLD, - SETTLE_TIMEOUT_MS, - AUTO_SCROLL_MARK_TIMEOUT_MS, - KEYBOARD_SCROLL_LINES, -} from "@constants/auto-scroll"; - -interface AutoMark { - offset: number; - time: number; -} - -export const useAutoScroll = ({ - isWorking, - totalLines, - visibleHeight, - onUserInteracted, - bottomThreshold = BOTTOM_THRESHOLD, -}: AutoScrollOptions): AutoScrollReturn => { - const [scrollOffset, setScrollOffset] = useState(0); - const [autoScroll, setAutoScroll] = useState(true); - const [userScrolled, setUserScrolled] = useState(false); - const [isSettling, setIsSettling] = useState(false); - - // Refs for timers and auto-mark tracking - const settleTimerRef = useRef | null>(null); - const autoMarkRef = useRef(null); - const autoTimerRef = useRef | null>(null); - const prevWorkingRef = useRef(false); - const prevTotalLinesRef = useRef(totalLines); - - // Calculate max scroll offset - const maxScroll = Math.max(0, totalLines - visibleHeight); - - // Check if currently active (working or settling) - const isActive = isWorking() || isSettling; - - /** - * Mark a scroll as programmatic (auto-scroll) - * This helps distinguish auto-scrolls from user scrolls in async scenarios - */ - const markAuto = useCallback((offset: number) => { - autoMarkRef.current = { - offset, - time: Date.now(), - }; - - if (autoTimerRef.current) { - clearTimeout(autoTimerRef.current); - } - - autoTimerRef.current = setTimeout(() => { - autoMarkRef.current = null; - autoTimerRef.current = null; - }, AUTO_SCROLL_MARK_TIMEOUT_MS); - }, []); - - /** - * Calculate distance from bottom - */ - const distanceFromBottom = useCallback( - (offset: number): number => { - return maxScroll - offset; - }, - [maxScroll], - ); - - /** - * Check if we can scroll (content exceeds viewport) - */ - const canScroll = useCallback((): boolean => { - return totalLines > visibleHeight; - }, [totalLines, visibleHeight]); - - /** - * Scroll to bottom programmatically - */ - const scrollToBottomInternal = useCallback( - (force: boolean) => { - if (!force && !isActive) return; - if (!force && userScrolled) return; - - if (force && userScrolled) { - setUserScrolled(false); - } - - const distance = distanceFromBottom(scrollOffset); - if (distance < 2) return; - - // Mark as auto-scroll and update offset - markAuto(maxScroll); - setScrollOffset(maxScroll); - setAutoScroll(true); - }, - [ - isActive, - userScrolled, - distanceFromBottom, - scrollOffset, - markAuto, - maxScroll, - ], - ); - - /** - * Pause auto-scroll (user scrolled up) - */ - const pause = useCallback(() => { - if (!canScroll()) { - if (userScrolled) setUserScrolled(false); - return; - } - if (userScrolled) return; - - setUserScrolled(true); - setAutoScroll(false); - onUserInteracted?.(); - }, [canScroll, userScrolled, onUserInteracted]); - - /** - * Resume auto-scroll mode - */ - const resume = useCallback(() => { - if (userScrolled) { - setUserScrolled(false); - } - scrollToBottomInternal(true); - }, [userScrolled, scrollToBottomInternal]); - - /** - * User-initiated scroll up - */ - const scrollUp = useCallback( - (lines: number = KEYBOARD_SCROLL_LINES) => { - const newOffset = Math.max(0, scrollOffset - lines); - setScrollOffset(newOffset); - - // User scrolling up always pauses auto-scroll - pause(); - }, - [scrollOffset, pause], - ); - - /** - * User-initiated scroll down - */ - const scrollDown = useCallback( - (lines: number = KEYBOARD_SCROLL_LINES) => { - const newOffset = Math.min(maxScroll, scrollOffset + lines); - setScrollOffset(newOffset); - - // Check if user scrolled back to bottom - if (distanceFromBottom(newOffset) <= bottomThreshold) { - setUserScrolled(false); - setAutoScroll(true); - } - }, - [scrollOffset, maxScroll, distanceFromBottom, bottomThreshold], - ); - - /** - * Scroll to top (user-initiated) - */ - const scrollToTop = useCallback(() => { - setScrollOffset(0); - pause(); - }, [pause]); - - /** - * Scroll to bottom and resume auto-scroll - */ - const scrollToBottom = useCallback(() => { - resume(); - }, [resume]); - - /** - * Get effective scroll offset (clamped) - */ - const getEffectiveOffset = useCallback((): number => { - if (autoScroll && !userScrolled) { - return maxScroll; - } - return Math.min(scrollOffset, maxScroll); - }, [autoScroll, userScrolled, scrollOffset, maxScroll]); - - /** - * Check if can scroll up - */ - const canScrollUp = useCallback((): boolean => { - return getEffectiveOffset() > 0; - }, [getEffectiveOffset]); - - /** - * Check if can scroll down - */ - const canScrollDown = useCallback((): boolean => { - return getEffectiveOffset() < maxScroll; - }, [getEffectiveOffset, maxScroll]); - - // Handle working state changes (settling period) - useEffect(() => { - const working = isWorking(); - - if (working !== prevWorkingRef.current) { - prevWorkingRef.current = working; - - // Clear existing settle timer - if (settleTimerRef.current) { - clearTimeout(settleTimerRef.current); - settleTimerRef.current = null; - } - - if (working) { - // Starting work - scroll to bottom if not user-scrolled - setIsSettling(false); - if (!userScrolled) { - scrollToBottomInternal(true); - } - } else { - // Finished work - enter settling period - setIsSettling(true); - settleTimerRef.current = setTimeout(() => { - setIsSettling(false); - settleTimerRef.current = null; - }, SETTLE_TIMEOUT_MS); - } - } - }, [isWorking, userScrolled, scrollToBottomInternal]); - - // Auto-scroll when new content arrives - useEffect(() => { - if (totalLines > prevTotalLinesRef.current) { - // New content added - if (autoScroll && !userScrolled) { - markAuto(maxScroll); - setScrollOffset(maxScroll); - } - } - prevTotalLinesRef.current = totalLines; - }, [totalLines, autoScroll, userScrolled, maxScroll, markAuto]); - - // Cleanup timers on unmount - useEffect(() => { - return () => { - if (settleTimerRef.current) { - clearTimeout(settleTimerRef.current); - } - if (autoTimerRef.current) { - clearTimeout(autoTimerRef.current); - } - }; - }, []); - - return { - scrollOffset, - autoScroll, - userScrolled, - isSettling, - scrollUp, - scrollDown, - scrollToTop, - scrollToBottom, - resume, - pause, - getEffectiveOffset, - canScrollUp, - canScrollDown, - }; -}; diff --git a/src/tui/hooks/useMouseScroll.ts b/src/tui/hooks/useMouseScroll.ts deleted file mode 100644 index 345e13c..0000000 --- a/src/tui/hooks/useMouseScroll.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Mouse scroll hook for Ink TUI - * Enables mouse mode in the terminal and handles scroll wheel events - */ - -import { useEffect } from "react"; -import { useStdin } from "ink"; -import type { MouseScrollOptions } from "@interfaces/MouseScrollOptions"; -import type { ScrollDirection } from "@constants/mouse-scroll"; -import { - MOUSE_ESCAPE_SEQUENCES, - MOUSE_BUTTON_TO_DIRECTION, - SGR_MOUSE_PATTERN, - X10_MOUSE_PREFIX, - X10_MIN_LENGTH, - X10_BUTTON_OFFSET, -} from "@constants/mouse-scroll"; - -type ScrollHandlers = { - up: () => void; - down: () => void; -}; - -/** - * Parse SGR mouse mode format and return scroll direction - * Format: \x1b[ { - const match = data.match(SGR_MOUSE_PATTERN); - if (!match) return null; - - const button = parseInt(match[1], 10); - return MOUSE_BUTTON_TO_DIRECTION[button] ?? null; -}; - -/** - * Parse X10/Normal mouse mode format and return scroll direction - * Format: \x1b[M followed by 3 bytes (button at offset 3) - */ -const parseX10MouseEvent = (data: string): ScrollDirection | null => { - const isX10Format = - data.startsWith(X10_MOUSE_PREFIX) && data.length >= X10_MIN_LENGTH; - - if (!isX10Format) return null; - - const button = data.charCodeAt(X10_BUTTON_OFFSET); - return MOUSE_BUTTON_TO_DIRECTION[button] ?? null; -}; - -/** - * Parse mouse event data and return scroll direction - */ -const parseMouseEvent = (data: string): ScrollDirection | null => { - // Try SGR format first, then X10 format - return parseSgrMouseEvent(data) ?? parseX10MouseEvent(data); -}; - -/** - * Create scroll handler that dispatches to the appropriate callback - */ -const createScrollDispatcher = (handlers: ScrollHandlers) => { - return (direction: ScrollDirection): void => { - handlers[direction](); - }; -}; - -/** - * Hook to enable mouse scroll support in the terminal - * - * @param options - Scroll handlers and enabled flag - */ -export const useMouseScroll = ({ - onScrollUp, - onScrollDown, - enabled = true, -}: MouseScrollOptions): void => { - const { stdin } = useStdin(); - - useEffect(() => { - if (!enabled || !stdin) return; - - // Enable mouse mode - process.stdout.write(MOUSE_ESCAPE_SEQUENCES.ENABLE); - - const handlers: ScrollHandlers = { - up: onScrollUp, - down: onScrollDown, - }; - - const dispatchScroll = createScrollDispatcher(handlers); - - const handleData = (data: Buffer): void => { - const str = data.toString(); - const direction = parseMouseEvent(str); - - if (direction) { - dispatchScroll(direction); - } - }; - - stdin.on("data", handleData); - - return () => { - // Disable mouse mode on cleanup - process.stdout.write(MOUSE_ESCAPE_SEQUENCES.DISABLE); - stdin.off("data", handleData); - }; - }, [enabled, stdin, onScrollUp, onScrollDown]); -}; diff --git a/src/tui/hooks/useThemeStore.ts b/src/tui/hooks/useThemeStore.ts deleted file mode 100644 index e3ef7b0..0000000 --- a/src/tui/hooks/useThemeStore.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * useThemeStore React Hook - * - * React hook for accessing theme store state - */ - -import { useStore } from "zustand"; -import { - themeStoreVanilla, - themeActions, - type ThemeState, -} from "@stores/theme-store"; -import type { ThemeColors } from "@/types/theme"; - -/** - * Theme store with actions for React components - */ -interface ThemeStoreWithActions extends ThemeState { - setTheme: (themeName: string) => void; -} - -/** - * React hook for theme store - */ -export const useThemeStore = ( - selector: (state: ThemeStoreWithActions) => T, -): T => { - const state = useStore(themeStoreVanilla, (s) => s); - const stateWithActions: ThemeStoreWithActions = { - ...state, - setTheme: themeActions.setTheme, - }; - return selector(stateWithActions); -}; - -/** - * React hook for theme colors only - */ -export const useThemeColors = (): ThemeColors => { - return useStore(themeStoreVanilla, (state) => state.colors); -}; - -export default useThemeStore; diff --git a/src/tui/hooks/useTodoStore.ts b/src/tui/hooks/useTodoStore.ts deleted file mode 100644 index 2d21b4a..0000000 --- a/src/tui/hooks/useTodoStore.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * useTodoStore React Hook - * - * React hook for accessing todo store state - */ - -import { useStore } from "zustand"; -import { - todoStoreVanilla, - todoStore, - type TodoState, -} from "@stores/todo-store"; - -/** - * Todo store with actions for React components - */ -interface TodoStoreWithActions extends TodoState { - getProgress: () => { completed: number; total: number; percentage: number }; -} - -/** - * React hook for todo store - */ -export const useTodoStore = ( - selector: (state: TodoStoreWithActions) => T, -): T => { - const state = useStore(todoStoreVanilla, (s) => s); - const stateWithActions: TodoStoreWithActions = { - ...state, - getProgress: todoStore.getProgress, - }; - return selector(stateWithActions); -}; - -export default useTodoStore; diff --git a/src/tui/hooks/useVimMode.ts b/src/tui/hooks/useVimMode.ts deleted file mode 100644 index c4f65eb..0000000 --- a/src/tui/hooks/useVimMode.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * useVimMode Hook - * - * React hook for vim mode keyboard handling in the TUI - */ - -import { useCallback, useEffect } from "react"; -import { useInput } from "ink"; -import type { Key } from "ink"; -import type { VimMode, VimAction, VimKeyEventResult } from "@/types/vim"; -import { useVimStore, vimActions } from "@stores/vim-store"; -import { - VIM_DEFAULT_BINDINGS, - VIM_SCROLL_AMOUNTS, - ESCAPE_KEYS, - VIM_COMMANDS, -} from "@constants/vim"; - -/** - * Options for useVimMode hook - */ -interface UseVimModeOptions { - /** Whether the hook is active */ - isActive?: boolean; - /** Callback when scrolling up */ - onScrollUp?: (lines: number) => void; - /** Callback when scrolling down */ - onScrollDown?: (lines: number) => void; - /** Callback when going to top */ - onGoToTop?: () => void; - /** Callback when going to bottom */ - onGoToBottom?: () => void; - /** Callback when entering insert mode */ - onEnterInsert?: () => void; - /** Callback when executing a command */ - onCommand?: (command: string) => void; - /** Callback when search pattern changes */ - onSearch?: (pattern: string) => void; - /** Callback when going to next search match */ - onSearchNext?: () => void; - /** Callback when going to previous search match */ - onSearchPrev?: () => void; - /** Callback for quit command */ - onQuit?: () => void; - /** Callback for write (save) command */ - onWrite?: () => void; -} - -/** - * Parse a vim command string - */ -const parseCommand = ( - command: string -): { name: string; args: string[] } | null => { - const trimmed = command.trim(); - if (!trimmed) return null; - - const parts = trimmed.split(/\s+/); - const name = parts[0] || ""; - const args = parts.slice(1); - - return { name, args }; -}; - -/** - * Find matching key binding - */ -const findBinding = ( - key: string, - mode: VimMode, - ctrl: boolean, - shift: boolean -) => { - return VIM_DEFAULT_BINDINGS.find((binding) => { - if (binding.mode !== mode) return false; - if (binding.key.toLowerCase() !== key.toLowerCase()) return false; - if (binding.ctrl && !ctrl) return false; - if (binding.shift && !shift) return false; - return true; - }); -}; - -/** - * Check if key is escape - */ -const isEscape = (input: string, key: Key): boolean => { - return key.escape || ESCAPE_KEYS.includes(input); -}; - -/** - * useVimMode hook - */ -export const useVimMode = (options: UseVimModeOptions = {}) => { - const { - isActive = true, - onScrollUp, - onScrollDown, - onGoToTop, - onGoToBottom, - onEnterInsert, - onCommand, - onSearch, - onSearchNext, - onSearchPrev, - onQuit, - onWrite, - } = options; - - const mode = useVimStore((state) => state.mode); - const enabled = useVimStore((state) => state.enabled); - const commandBuffer = useVimStore((state) => state.commandBuffer); - const searchPattern = useVimStore((state) => state.searchPattern); - const config = useVimStore((state) => state.config); - - /** - * Handle action execution - */ - const executeAction = useCallback( - (action: VimAction, argument?: string | number): void => { - const actionHandlers: Record void> = { - scroll_up: () => onScrollUp?.(VIM_SCROLL_AMOUNTS.LINE), - scroll_down: () => onScrollDown?.(VIM_SCROLL_AMOUNTS.LINE), - scroll_half_up: () => onScrollUp?.(VIM_SCROLL_AMOUNTS.HALF_PAGE), - scroll_half_down: () => onScrollDown?.(VIM_SCROLL_AMOUNTS.HALF_PAGE), - goto_top: () => onGoToTop?.(), - goto_bottom: () => onGoToBottom?.(), - enter_insert: () => { - vimActions.setMode("insert"); - onEnterInsert?.(); - }, - enter_command: () => { - vimActions.setMode("command"); - vimActions.clearCommandBuffer(); - }, - enter_visual: () => { - vimActions.setMode("visual"); - }, - exit_mode: () => { - vimActions.setMode("normal"); - vimActions.clearCommandBuffer(); - vimActions.clearSearch(); - }, - search_start: () => { - vimActions.setMode("command"); - vimActions.setCommandBuffer("/"); - }, - search_next: () => { - vimActions.nextMatch(); - onSearchNext?.(); - }, - search_prev: () => { - vimActions.prevMatch(); - onSearchPrev?.(); - }, - execute_command: () => { - const buffer = vimActions.getState().commandBuffer; - - // Check if it's a search - if (buffer.startsWith("/")) { - const pattern = buffer.slice(1); - vimActions.setSearchPattern(pattern); - onSearch?.(pattern); - vimActions.setMode("normal"); - vimActions.clearCommandBuffer(); - return; - } - - const parsed = parseCommand(buffer); - if (parsed) { - const { name, args } = parsed; - - // Handle built-in commands - if (name === VIM_COMMANDS.QUIT || name === VIM_COMMANDS.QUIT_FORCE) { - onQuit?.(); - } else if (name === VIM_COMMANDS.WRITE) { - onWrite?.(); - } else if (name === VIM_COMMANDS.WRITE_QUIT) { - onWrite?.(); - onQuit?.(); - } else if (name === VIM_COMMANDS.NOHL) { - vimActions.clearSearch(); - } else { - onCommand?.(buffer); - } - } - - vimActions.setMode("normal"); - vimActions.clearCommandBuffer(); - }, - cancel: () => { - vimActions.setMode("normal"); - vimActions.clearCommandBuffer(); - }, - yank: () => { - // Yank would copy content to register - // Implementation depends on what content is available - }, - paste: () => { - // Paste from register - // Implementation depends on context - }, - delete: () => { - // Delete selected content - }, - undo: () => { - // Undo last change - }, - redo: () => { - // Redo last undone change - }, - word_forward: () => { - // Move to next word - }, - word_backward: () => { - // Move to previous word - }, - line_start: () => { - // Move to line start - }, - line_end: () => { - // Move to line end - }, - none: () => { - // No action - }, - }; - - const handler = actionHandlers[action]; - if (handler) { - handler(); - } - }, - [ - onScrollUp, - onScrollDown, - onGoToTop, - onGoToBottom, - onEnterInsert, - onCommand, - onSearch, - onSearchNext, - onSearchPrev, - onQuit, - onWrite, - ] - ); - - /** - * Handle key input - */ - const handleInput = useCallback( - (input: string, key: Key): VimKeyEventResult => { - // Not enabled, pass through - if (!enabled) { - return { handled: false, preventDefault: false }; - } - - // Handle escape in any mode - if (isEscape(input, key) && mode !== "normal") { - executeAction("exit_mode"); - return { handled: true, preventDefault: true }; - } - - // Command mode - build command buffer - if (mode === "command") { - if (key.return) { - executeAction("execute_command"); - return { handled: true, preventDefault: true }; - } - - if (key.backspace || key.delete) { - const buffer = vimActions.getState().commandBuffer; - if (buffer.length > 0) { - vimActions.setCommandBuffer(buffer.slice(0, -1)); - } - if (buffer.length <= 1) { - executeAction("cancel"); - } - return { handled: true, preventDefault: true }; - } - - // Add character to command buffer - if (input && input.length === 1) { - vimActions.appendCommandBuffer(input); - return { handled: true, preventDefault: true }; - } - - return { handled: true, preventDefault: true }; - } - - // Normal mode - check bindings - if (mode === "normal") { - const binding = findBinding(input, mode, key.ctrl, key.shift); - - if (binding) { - executeAction(binding.action, binding.argument); - return { - handled: true, - action: binding.action, - preventDefault: true, - }; - } - - // Handle 'gg' for go to top (two-key sequence) - // For simplicity, we handle 'g' as goto_top - // A full implementation would track pending keys - - return { handled: false, preventDefault: false }; - } - - // Visual mode - if (mode === "visual") { - const binding = findBinding(input, mode, key.ctrl, key.shift); - - if (binding) { - executeAction(binding.action, binding.argument); - return { - handled: true, - action: binding.action, - preventDefault: true, - }; - } - - return { handled: false, preventDefault: false }; - } - - // Insert mode - pass through to normal input handling - return { handled: false, preventDefault: false }; - }, - [enabled, mode, executeAction] - ); - - // Use ink's input hook - useInput( - (input, key) => { - if (!isActive || !enabled) return; - - const result = handleInput(input, key); - - // Result handling is done by the callbacks - }, - { isActive: isActive && enabled } - ); - - return { - mode, - enabled, - commandBuffer, - searchPattern, - config, - handleInput, - enable: vimActions.enable, - disable: vimActions.disable, - toggle: vimActions.toggle, - setMode: vimActions.setMode, - }; -}; - -export default useVimMode; diff --git a/src/tui/hooks/useVimStore.ts b/src/tui/hooks/useVimStore.ts deleted file mode 100644 index 26631ec..0000000 --- a/src/tui/hooks/useVimStore.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * useVimStore React Hook - * - * React hook for accessing vim store state - */ - -import { useStore } from "zustand"; -import { vimStore } from "@stores/vim-store"; -import type { VimStore } from "@stores/vim-store"; - -/** - * React hook for vim store - */ -export const useVimStore = (selector: (state: VimStore) => T): T => { - return useStore(vimStore, selector); -}; - -export default useVimStore; diff --git a/src/tui/index.ts b/src/tui/index.ts index e1389b3..ad39171 100644 --- a/src/tui/index.ts +++ b/src/tui/index.ts @@ -3,7 +3,36 @@ * Re-exports from @opentui/solid implementation */ -export * from "@tui/types"; +export type { + AppMode, + CommandCategory, + SlashCommand, + CommandMenuState, + SelectOption, + DiffData, + DiffLineType, + DiffLineData, + DiffViewProps, + DiffLineProps, + LogEntryType, + ToolStatus, + LogEntryMetadata, + LogEntry, + LogEntryProps, + ThinkingIndicatorProps, + ToolCall, + PermissionType, + PermissionScope, + PermissionRequest, + PermissionResponse, + LearningScope, + LearningPrompt, + LearningResponse, + SessionStats, + HeaderProps, + CommandMenuProps, + AppState, +} from "@/types/tui"; export { tui, appStore } from "@tui-solid/index"; export type { TuiRenderOptions } from "@tui-solid/app"; export { diff --git a/src/tui/mouse-handler.ts b/src/tui/mouse-handler.ts deleted file mode 100644 index 9eded96..0000000 --- a/src/tui/mouse-handler.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Mouse Handler - Intercepts mouse events from stdin before Ink processes them - * - * This module intercepts stdin data events to: - * 1. Enable mouse tracking in the terminal - * 2. Filter out mouse escape sequences - * 3. Handle scroll wheel events - * 4. Pass all other input through to Ink - */ - -import type { MouseHandlerCallbacks } from "@interfaces/MouseHandlerCallbacks"; -import type { MouseScrollDirection } from "@constants/mouse-handler"; -import { - MOUSE_SCROLL_LINES, - SGR_MOUSE_REGEX, - X10_MOUSE_REGEX, - PARTIAL_SGR_REGEX, - PARTIAL_X10_REGEX, - MOUSE_TRACKING_SEQUENCES, - MOUSE_BUTTON_TO_SCROLL, -} from "@constants/mouse-handler"; - -// Re-export interface for convenience -export type { MouseHandlerCallbacks } from "@interfaces/MouseHandlerCallbacks"; - -// Scroll direction handlers type -type ScrollHandlers = Record void>; - -/** - * Enable mouse tracking in the terminal - */ -export const enableMouseTracking = (): void => { - process.stdout.write(MOUSE_TRACKING_SEQUENCES.ENABLE_BUTTON); - process.stdout.write(MOUSE_TRACKING_SEQUENCES.ENABLE_SGR); -}; - -/** - * Disable mouse tracking in the terminal - */ -export const disableMouseTracking = (): void => { - process.stdout.write(MOUSE_TRACKING_SEQUENCES.DISABLE_SGR); - process.stdout.write(MOUSE_TRACKING_SEQUENCES.DISABLE_BUTTON); -}; - -/** - * Create scroll handlers from callbacks - */ -const createScrollHandlers = ( - callbacks: MouseHandlerCallbacks, -): ScrollHandlers => ({ - up: callbacks.onScrollUp, - down: callbacks.onScrollDown, -}); - -/** - * Dispatch scroll event to the appropriate handler - */ -const dispatchScrollEvent = ( - direction: MouseScrollDirection, - handlers: ScrollHandlers, - lines: number, -): void => { - handlers[direction](lines); -}; - -/** - * Parse SGR mouse events and extract scroll wheel actions - * Returns the data with mouse sequences removed - */ -const filterMouseEvents = ( - data: string, - callbacks: MouseHandlerCallbacks, -): string => { - const handlers = createScrollHandlers(callbacks); - - // Handle SGR mouse sequences - let filtered = data.replace( - SGR_MOUSE_REGEX, - (_match, button, _col, _row, action) => { - const buttonNum = parseInt(button, 10); - const direction = MOUSE_BUTTON_TO_SCROLL[buttonNum]; - - // Only handle button press events (M), not release (m) - if (action === "M" && direction) { - dispatchScrollEvent(direction, handlers, MOUSE_SCROLL_LINES); - } - - // Remove the sequence - return ""; - }, - ); - - // Remove X10 mouse sequences - filtered = filtered.replace(X10_MOUSE_REGEX, ""); - - // Remove partial/incomplete sequences - filtered = filtered.replace(PARTIAL_SGR_REGEX, ""); - filtered = filtered.replace(PARTIAL_X10_REGEX, ""); - - return filtered; -}; - -// Store for the original emit function and current callbacks -let originalEmit: typeof process.stdin.emit | null = null; -let currentCallbacks: MouseHandlerCallbacks | null = null; -let isSetup = false; - -/** - * Cleanup function that can be called from anywhere - */ -const doCleanup = (): void => { - if (!isSetup) return; - - // Disable mouse tracking first (most important for terminal state) - disableMouseTracking(); - - // Restore original emit - if (originalEmit) { - process.stdin.emit = originalEmit as typeof process.stdin.emit; - originalEmit = null; - } - - currentCallbacks = null; - isSetup = false; -}; - -/** - * Emergency cleanup on process exit - ensures terminal is restored - */ -const emergencyCleanup = (): void => { - // Just disable mouse tracking - don't try to restore emit - // This is safe to call multiple times - process.stdout.write(MOUSE_TRACKING_SEQUENCES.DISABLE_SGR); - process.stdout.write(MOUSE_TRACKING_SEQUENCES.DISABLE_BUTTON); -}; - -/** - * Register process exit handlers for cleanup - */ -const registerExitHandlers = (): void => { - process.on("exit", emergencyCleanup); - process.on("SIGINT", () => { - emergencyCleanup(); - process.exit(130); - }); - process.on("SIGTERM", () => { - emergencyCleanup(); - process.exit(143); - }); - process.on("uncaughtException", (err) => { - emergencyCleanup(); - console.error("Uncaught exception:", err); - process.exit(1); - }); - process.on("unhandledRejection", (reason) => { - emergencyCleanup(); - console.error("Unhandled rejection:", reason); - process.exit(1); - }); -}; - -/** - * Create data event handler - */ -const createDataHandler = ( - origEmit: typeof process.stdin.emit, -): ((event: string | symbol, ...args: unknown[]) => boolean) => { - return function (event: string | symbol, ...args: unknown[]): boolean { - if (event !== "data" || !currentCallbacks || !args[0]) { - return origEmit.call(process.stdin, event, ...args); - } - - const chunk = args[0]; - let data: string; - - if (Buffer.isBuffer(chunk)) { - data = chunk.toString("utf8"); - } else if (typeof chunk === "string") { - data = chunk; - } else { - return origEmit.call(process.stdin, event, ...args); - } - - // Filter mouse events - const filtered = filterMouseEvents(data, currentCallbacks); - - // Only emit if there's remaining data - if (filtered.length > 0) { - return origEmit.call(process.stdin, event, filtered); - } - - // Return true to indicate event was handled - return true; - }; -}; - -/** - * Setup mouse handling by intercepting stdin data events - * This approach preserves raw mode support required by Ink - */ -export const setupMouseHandler = ( - callbacks: MouseHandlerCallbacks, -): { - cleanup: () => void; -} => { - if (isSetup) { - // Already setup, just update callbacks - currentCallbacks = callbacks; - return { cleanup: doCleanup }; - } - - currentCallbacks = callbacks; - isSetup = true; - - // Register exit handlers - registerExitHandlers(); - - // Enable mouse tracking - enableMouseTracking(); - - // Store original emit - originalEmit = process.stdin.emit.bind(process.stdin); - - // Override emit to intercept 'data' events - process.stdin.emit = createDataHandler( - originalEmit, - ) as typeof process.stdin.emit; - - return { - cleanup: doCleanup, - }; -}; diff --git a/src/tui/store.ts b/src/tui/store.ts deleted file mode 100644 index 424f9a4..0000000 --- a/src/tui/store.ts +++ /dev/null @@ -1,684 +0,0 @@ -/** - * Application State Management with Zustand - */ - -import { create } from "zustand"; -import type { - AppState, - AppMode, - ScreenMode, - InteractionMode, - LogEntry, - ToolCall, - PermissionRequest, - LearningPrompt, - SessionStats, - StreamingLogState, - SuggestionState, - SuggestionPrompt, -} from "@/types/tui"; -import type { ProviderModel } from "@/types/providers"; -import type { BrainConnectionStatus, BrainUser } from "@/types/brain"; - -const createInitialBrainState = () => ({ - status: "disconnected" as BrainConnectionStatus, - user: null as BrainUser | null, - knowledgeCount: 0, - memoryCount: 0, - showBanner: true, -}); - -const createInitialSessionStats = (): SessionStats => ({ - startTime: Date.now(), - inputTokens: 0, - outputTokens: 0, - thinkingStartTime: null, - lastThinkingDuration: 0, -}); - -const createInitialStreamingState = (): StreamingLogState => ({ - logId: null, - content: "", - isStreaming: false, -}); - -const createInitialSuggestionState = (): SuggestionState => ({ - suggestions: [], - selectedIndex: 0, - visible: false, -}); - -let logIdCounter = 0; - -const generateLogId = (): string => { - return `log-${++logIdCounter}-${Date.now()}`; -}; - -const INTERACTION_MODES: InteractionMode[] = ["agent", "ask", "code-review"]; - -export const useAppStore = create((set, get) => ({ - // Initial state - mode: "idle", - screenMode: "home", - interactionMode: "agent" as InteractionMode, - inputBuffer: "", - inputCursorPosition: 0, - logs: [], - currentToolCall: null, - permissionRequest: null, - learningPrompt: null, - thinkingMessage: null, - sessionId: null, - provider: "copilot", - model: "", - version: "0.1.0", - commandMenu: { - isOpen: false, - filter: "", - selectedIndex: 0, - }, - availableModels: [], - sessionStats: createInitialSessionStats(), - todosVisible: true, - interruptPending: false, - exitPending: false, - isCompacting: false, - scrollOffset: 0, - autoScroll: true, - userScrolled: false, - isSettling: false, - totalLines: 0, - visibleHeight: 20, - streamingLog: createInitialStreamingState(), - suggestions: createInitialSuggestionState(), - brain: createInitialBrainState(), - - // Mode actions - setMode: (mode: AppMode) => set({ mode }), - setScreenMode: (screenMode: ScreenMode) => set({ screenMode }), - setInteractionMode: (interactionMode: InteractionMode) => set({ interactionMode }), - toggleInteractionMode: () => set((state) => { - const currentIndex = INTERACTION_MODES.indexOf(state.interactionMode); - const nextIndex = (currentIndex + 1) % INTERACTION_MODES.length; - return { interactionMode: INTERACTION_MODES[nextIndex] }; - }), - - // Input actions - setInputBuffer: (buffer: string) => set({ inputBuffer: buffer }), - setInputCursorPosition: (position: number) => - set({ inputCursorPosition: position }), - - appendToInput: (text: string) => { - const { inputBuffer, inputCursorPosition } = get(); - const before = inputBuffer.slice(0, inputCursorPosition); - const after = inputBuffer.slice(inputCursorPosition); - set({ - inputBuffer: before + text + after, - inputCursorPosition: inputCursorPosition + text.length, - }); - }, - - clearInput: () => set({ inputBuffer: "", inputCursorPosition: 0 }), - - // Log actions - addLog: (entry: Omit) => { - const newEntry: LogEntry = { - ...entry, - id: generateLogId(), - timestamp: Date.now(), - }; - set((state) => ({ logs: [...state.logs, newEntry] })); - return newEntry.id; - }, - - updateLog: (id: string, updates: Partial) => { - set((state) => ({ - logs: state.logs.map((log) => - log.id === id ? { ...log, ...updates } : log, - ), - })); - }, - - clearLogs: () => set({ logs: [] }), - - // Tool call actions - setCurrentToolCall: (toolCall: ToolCall | null) => - set({ currentToolCall: toolCall }), - - updateToolCall: (updates: Partial) => { - set((state) => ({ - currentToolCall: state.currentToolCall - ? { ...state.currentToolCall, ...updates } - : null, - })); - }, - - // Permission actions - setPermissionRequest: (request: PermissionRequest | null) => - set({ permissionRequest: request }), - - // Learning prompt actions - setLearningPrompt: (prompt: LearningPrompt | null) => - set({ learningPrompt: prompt }), - - // Thinking message - setThinkingMessage: (message: string | null) => - set({ thinkingMessage: message }), - - // Session info - setSessionInfo: (sessionId: string, provider: string, model: string) => - set({ sessionId, provider, model }), - - setVersion: (version: string) => set({ version }), - - // Command menu actions - openCommandMenu: () => - set({ - mode: "command_menu", - commandMenu: { isOpen: true, filter: "", selectedIndex: 0 }, - }), - - closeCommandMenu: () => - set({ - mode: "idle", - commandMenu: { isOpen: false, filter: "", selectedIndex: 0 }, - }), - - setCommandFilter: (filter: string) => - set((state) => ({ - commandMenu: { ...state.commandMenu, filter, selectedIndex: 0 }, - })), - - setCommandSelectedIndex: (index: number) => - set((state) => ({ - commandMenu: { ...state.commandMenu, selectedIndex: index }, - })), - - // Model actions - setAvailableModels: (models: ProviderModel[]) => - set({ availableModels: models }), - - setModel: (model: string) => set({ model }), - - // Session stats actions - startThinking: () => - set((state) => ({ - sessionStats: { - ...state.sessionStats, - thinkingStartTime: Date.now(), - }, - })), - - stopThinking: () => - set((state) => { - const duration = state.sessionStats.thinkingStartTime - ? Math.floor((Date.now() - state.sessionStats.thinkingStartTime) / 1000) - : 0; - return { - sessionStats: { - ...state.sessionStats, - thinkingStartTime: null, - lastThinkingDuration: duration, - }, - }; - }), - - addTokens: (input: number, output: number) => - set((state) => ({ - sessionStats: { - ...state.sessionStats, - inputTokens: state.sessionStats.inputTokens + input, - outputTokens: state.sessionStats.outputTokens + output, - }, - })), - - resetSessionStats: () => set({ sessionStats: createInitialSessionStats() }), - - // UI state actions - toggleTodos: () => set((state) => ({ todosVisible: !state.todosVisible })), - - setInterruptPending: (pending: boolean) => set({ interruptPending: pending }), - - setExitPending: (pending: boolean) => set({ exitPending: pending }), - - setIsCompacting: (compacting: boolean) => set({ isCompacting: compacting }), - - // Scroll actions - scrollUp: (lines = 3) => - set((state) => { - const newOffset = Math.max(0, state.scrollOffset - lines); - return { - scrollOffset: newOffset, - autoScroll: false, - userScrolled: true, - }; - }), - - scrollDown: (lines = 3) => - set((state) => { - const maxScroll = Math.max(0, state.totalLines - state.visibleHeight); - const newOffset = Math.min(maxScroll, state.scrollOffset + lines); - const distanceFromBottom = maxScroll - newOffset; - const isAtBottom = distanceFromBottom <= 3; - - return { - scrollOffset: newOffset, - autoScroll: isAtBottom, - userScrolled: !isAtBottom, - }; - }), - - scrollToTop: () => - set({ - scrollOffset: 0, - autoScroll: false, - userScrolled: true, - }), - - scrollToBottom: () => - set((state) => { - const maxScroll = Math.max(0, state.totalLines - state.visibleHeight); - return { - scrollOffset: maxScroll, - autoScroll: true, - userScrolled: false, - }; - }), - - setAutoScroll: (enabled: boolean) => set({ autoScroll: enabled }), - - setUserScrolled: (scrolled: boolean) => - set({ - userScrolled: scrolled, - autoScroll: !scrolled, - }), - - setScrollDimensions: (totalLines: number, visibleHeight: number) => - set((state) => { - const maxScroll = Math.max(0, totalLines - visibleHeight); - // If auto-scroll is enabled, keep scroll at bottom - const newOffset = state.autoScroll - ? maxScroll - : Math.min(state.scrollOffset, maxScroll); - return { - totalLines, - visibleHeight, - scrollOffset: newOffset, - }; - }), - - pauseAutoScroll: () => - set({ - autoScroll: false, - userScrolled: true, - }), - - resumeAutoScroll: () => - set((state) => { - const maxScroll = Math.max(0, state.totalLines - state.visibleHeight); - return { - autoScroll: true, - userScrolled: false, - scrollOffset: maxScroll, - }; - }), - - getEffectiveScrollOffset: () => { - const state = get(); - const maxScroll = Math.max(0, state.totalLines - state.visibleHeight); - if (state.autoScroll && !state.userScrolled) { - return maxScroll; - } - return Math.min(state.scrollOffset, maxScroll); - }, - - // Streaming actions - startStreaming: () => { - const logId = generateLogId(); - const entry: LogEntry = { - id: logId, - type: "assistant_streaming", - content: "", - timestamp: Date.now(), - metadata: { isStreaming: true, streamComplete: false }, - }; - set((state) => ({ - logs: [...state.logs, entry], - streamingLog: { - logId, - content: "", - isStreaming: true, - }, - })); - return logId; - }, - - appendStreamContent: (content: string) => { - set((state) => { - if (!state.streamingLog.logId || !state.streamingLog.isStreaming) { - return state; - } - - const newContent = state.streamingLog.content + content; - - return { - streamingLog: { - ...state.streamingLog, - content: newContent, - }, - logs: state.logs.map((log) => - log.id === state.streamingLog.logId - ? { ...log, content: newContent } - : log, - ), - }; - }); - }, - - completeStreaming: () => { - set((state) => { - if (!state.streamingLog.logId) { - return state; - } - - return { - streamingLog: createInitialStreamingState(), - logs: state.logs.map((log) => - log.id === state.streamingLog.logId - ? { - ...log, - type: "assistant" as const, - metadata: { - ...log.metadata, - isStreaming: false, - streamComplete: true, - }, - } - : log, - ), - }; - }); - }, - - cancelStreaming: () => { - set((state) => { - if (!state.streamingLog.logId) { - return state; - } - - // Remove the streaming log entry if cancelled - return { - streamingLog: createInitialStreamingState(), - logs: state.logs.filter((log) => log.id !== state.streamingLog.logId), - }; - }); - }, - - // Suggestion actions - setSuggestions: (newSuggestions: SuggestionPrompt[]) => - set({ - suggestions: { - suggestions: newSuggestions, - selectedIndex: 0, - visible: newSuggestions.length > 0, - }, - }), - - clearSuggestions: () => set({ suggestions: createInitialSuggestionState() }), - - selectSuggestion: (index: number) => - set((state) => ({ - suggestions: { - ...state.suggestions, - selectedIndex: Math.max( - 0, - Math.min(index, state.suggestions.suggestions.length - 1), - ), - }, - })), - - nextSuggestion: () => - set((state) => ({ - suggestions: { - ...state.suggestions, - selectedIndex: - (state.suggestions.selectedIndex + 1) % - Math.max(1, state.suggestions.suggestions.length), - }, - })), - - prevSuggestion: () => - set((state) => ({ - suggestions: { - ...state.suggestions, - selectedIndex: - state.suggestions.selectedIndex === 0 - ? Math.max(0, state.suggestions.suggestions.length - 1) - : state.suggestions.selectedIndex - 1, - }, - })), - - hideSuggestions: () => - set((state) => ({ - suggestions: { ...state.suggestions, visible: false }, - })), - - showSuggestions: () => - set((state) => ({ - suggestions: { - ...state.suggestions, - visible: state.suggestions.suggestions.length > 0, - }, - })), - - // Brain actions - setBrainStatus: (status: BrainConnectionStatus) => - set((state) => ({ - brain: { ...state.brain, status }, - })), - - setBrainUser: (user: BrainUser | null) => - set((state) => ({ - brain: { ...state.brain, user }, - })), - - setBrainCounts: (knowledgeCount: number, memoryCount: number) => - set((state) => ({ - brain: { ...state.brain, knowledgeCount, memoryCount }, - })), - - setBrainShowBanner: (showBanner: boolean) => - set((state) => ({ - brain: { ...state.brain, showBanner }, - })), - - dismissBrainBanner: () => - set((state) => ({ - brain: { ...state.brain, showBanner: false }, - })), - - // Computed - check if input should be locked - isInputLocked: () => { - const { mode } = get(); - return ( - mode === "thinking" || - mode === "tool_execution" || - mode === "permission_prompt" - ); - }, -})); - -// Non-React access to store (for use in agent callbacks) -export const appStore = { - getState: () => useAppStore.getState(), - - addLog: (entry: Omit) => { - return useAppStore.getState().addLog(entry); - }, - - updateLog: (id: string, updates: Partial) => { - useAppStore.getState().updateLog(id, updates); - }, - - setMode: (mode: AppMode) => { - useAppStore.getState().setMode(mode); - }, - - setCurrentToolCall: (toolCall: ToolCall | null) => { - useAppStore.getState().setCurrentToolCall(toolCall); - }, - - updateToolCall: (updates: Partial) => { - useAppStore.getState().updateToolCall(updates); - }, - - setThinkingMessage: (message: string | null) => { - useAppStore.getState().setThinkingMessage(message); - }, - - setPermissionRequest: (request: PermissionRequest | null) => { - useAppStore.getState().setPermissionRequest(request); - }, - - setLearningPrompt: (prompt: LearningPrompt | null) => { - useAppStore.getState().setLearningPrompt(prompt); - }, - - clearInput: () => { - useAppStore.getState().clearInput(); - }, - - clearLogs: () => { - useAppStore.getState().clearLogs(); - }, - - openCommandMenu: () => { - useAppStore.getState().openCommandMenu(); - }, - - closeCommandMenu: () => { - useAppStore.getState().closeCommandMenu(); - }, - - setAvailableModels: (models: ProviderModel[]) => { - useAppStore.getState().setAvailableModels(models); - }, - - setModel: (model: string) => { - useAppStore.getState().setModel(model); - }, - - // Session stats - startThinking: () => { - useAppStore.getState().startThinking(); - }, - - stopThinking: () => { - useAppStore.getState().stopThinking(); - }, - - addTokens: (input: number, output: number) => { - useAppStore.getState().addTokens(input, output); - }, - - resetSessionStats: () => { - useAppStore.getState().resetSessionStats(); - }, - - // UI state - toggleTodos: () => { - useAppStore.getState().toggleTodos(); - }, - - toggleInteractionMode: () => { - useAppStore.getState().toggleInteractionMode(); - }, - - setInteractionMode: (mode: InteractionMode) => { - useAppStore.getState().setInteractionMode(mode); - }, - - setInterruptPending: (pending: boolean) => { - useAppStore.getState().setInterruptPending(pending); - }, - - setIsCompacting: (compacting: boolean) => { - useAppStore.getState().setIsCompacting(compacting); - }, - - // Streaming - startStreaming: () => { - return useAppStore.getState().startStreaming(); - }, - - appendStreamContent: (content: string) => { - useAppStore.getState().appendStreamContent(content); - }, - - completeStreaming: () => { - useAppStore.getState().completeStreaming(); - }, - - cancelStreaming: () => { - useAppStore.getState().cancelStreaming(); - }, - - // Suggestions - setSuggestions: (suggestions: SuggestionPrompt[]) => { - useAppStore.getState().setSuggestions(suggestions); - }, - - clearSuggestions: () => { - useAppStore.getState().clearSuggestions(); - }, - - hideSuggestions: () => { - useAppStore.getState().hideSuggestions(); - }, - - // Scroll - scrollUp: (lines?: number) => { - useAppStore.getState().scrollUp(lines); - }, - - scrollDown: (lines?: number) => { - useAppStore.getState().scrollDown(lines); - }, - - scrollToTop: () => { - useAppStore.getState().scrollToTop(); - }, - - scrollToBottom: () => { - useAppStore.getState().scrollToBottom(); - }, - - pauseAutoScroll: () => { - useAppStore.getState().pauseAutoScroll(); - }, - - resumeAutoScroll: () => { - useAppStore.getState().resumeAutoScroll(); - }, - - // Brain - setBrainStatus: (status: BrainConnectionStatus) => { - useAppStore.getState().setBrainStatus(status); - }, - - setBrainUser: (user: BrainUser | null) => { - useAppStore.getState().setBrainUser(user); - }, - - setBrainCounts: (knowledge: number, memory: number) => { - useAppStore.getState().setBrainCounts(knowledge, memory); - }, - - setBrainShowBanner: (show: boolean) => { - useAppStore.getState().setBrainShowBanner(show); - }, - - dismissBrainBanner: () => { - useAppStore.getState().dismissBrainBanner(); - }, -}; diff --git a/src/tui/types.ts b/src/tui/types.ts deleted file mode 100644 index 56ad5b9..0000000 --- a/src/tui/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * TUI Types - Re-exports from centralized types - */ - -export type { - AppMode, - CommandCategory, - SlashCommand, - CommandMenuState, - SelectOption, - DiffData, - DiffLineType, - DiffLineData, - DiffViewProps, - DiffLineProps, - LogEntryType, - ToolStatus, - LogEntryMetadata, - LogEntry, - LogEntryProps, - ThinkingIndicatorProps, - ToolCall, - PermissionType, - PermissionScope, - PermissionRequest, - PermissionResponse, - LearningScope, - LearningPrompt, - LearningResponse, - SessionStats, - HeaderProps, - CommandMenuProps, - AppState, -} from "@/types/tui"; diff --git a/src/types/brain-mcp.ts b/src/types/brain-mcp.ts index f7a0f07..df1ac2a 100644 --- a/src/types/brain-mcp.ts +++ b/src/types/brain-mcp.ts @@ -143,9 +143,9 @@ export const BRAIN_MCP_TOOLS: ReadonlyArray = [ properties: { name: { type: "string", description: "The name of the concept" }, whatItDoes: { type: "string", description: "Description of what the concept does" }, - keywords: { type: "array", items: { type: "string" }, description: "Keywords for the concept" }, - patterns: { type: "array", items: { type: "string" }, description: "Code patterns related to the concept" }, - files: { type: "array", items: { type: "string" }, description: "Files related to the concept" }, + keywords: { type: "array", items: { type: "string", description: "Keyword item" }, description: "Keywords for the concept" }, + patterns: { type: "array", items: { type: "string", description: "Pattern item" }, description: "Code patterns related to the concept" }, + files: { type: "array", items: { type: "string", description: "File path item" }, description: "Files related to the concept" }, }, required: ["name", "whatItDoes"], }, diff --git a/src/types/parallel.ts b/src/types/parallel.ts index 0e21d0a..d17536d 100644 --- a/src/types/parallel.ts +++ b/src/types/parallel.ts @@ -46,7 +46,7 @@ export interface ParallelAgentConfig { * Parallel task definition */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -export interface ParallelTask { +export interface ParallelTask { id: string; type: ParallelTaskType; agent: ParallelAgentConfig; diff --git a/src/utils/progress-bar.ts b/src/utils/progress-bar.ts index b90a838..7fab2a7 100644 --- a/src/utils/progress-bar.ts +++ b/src/utils/progress-bar.ts @@ -4,7 +4,7 @@ import chalk from "chalk"; import { DEFAULT_BAR_WIDTH } from "@constants/hooks.js"; -import type { ProgressBarOptions } from "@interfactes/ProgressBar"; +import type { ProgressBarOptions } from "@interfaces/ProgressBar"; const defaultOptions: Required = { width: DEFAULT_BAR_WIDTH, diff --git a/tsconfig.json b/tsconfig.json index 9705e77..50fbc90 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -44,5 +44,5 @@ "jsxImportSource": "@opentui/solid" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "src/tui/App.tsx", "src/tui/components", "src/tui/hooks", "src/tui/store.ts"] + "exclude": ["node_modules", "dist", "**/*.test.ts"] }