import { Box, Text, useApp, useInput } from "ink" import { Select } from "@inkjs/ui" import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js" import { getGlobalCommandsForAutocomplete } from "@/lib/utils/commands.js" import { arePathsEqual } from "@/lib/utils/path.js" import { getContextWindow } from "@/lib/utils/context-window.js" import * as theme from "./theme.js" import { useCLIStore } from "./store.js" import { useUIStateStore } from "./stores/uiStateStore.js" // Import extracted hooks. import { TerminalSizeProvider, useTerminalSize, useToast, useExtensionHost, useMessageHandlers, useTaskSubmit, useGlobalInput, useFollowupCountdown, useFocusManagement, usePickerHandlers, } from "./hooks/index.js" // Import extracted utilities. import { getView } from "./utils/index.js" // Import components. import Header from "./components/Header.js" import ChatHistoryItem from "./components/ChatHistoryItem.js" import LoadingText from "./components/LoadingText.js" import ToastDisplay from "./components/ToastDisplay.js" import TodoDisplay from "./components/TodoDisplay.js" import { HorizontalLine } from "./components/HorizontalLine.js" import { type AutocompleteInputHandle, type AutocompleteTrigger, type FileResult, type SlashCommandResult, AutocompleteInput, PickerSelect, createFileTrigger, createSlashCommandTrigger, createModeTrigger, createHelpTrigger, createHistoryTrigger, toFileResult, toSlashCommandResult, toModeResult, toHistoryResult, } from "./components/autocomplete/index.js" import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js" import ScrollIndicator from "./components/ScrollIndicator.js" const PICKER_HEIGHT = 10 export interface TUIAppProps extends ExtensionHostOptions { initialPrompt?: string initialSessionId?: string continueSession?: boolean version: string // Create extension host factory for dependency injection. createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface } /** * Inner App component that uses the terminal size context */ function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) { const { initialPrompt, initialSessionId, continueSession, workspacePath, extensionPath, user, provider, apiKey, model, mode, nonInteractive = false, debug, exitOnComplete, reasoningEffort, ephemeral, version, } = extensionHostOptions const { exit } = useApp() const { messages, pendingAsk, isLoading, isComplete, hasStartedTask: _hasStartedTask, error, fileSearchResults, allSlashCommands, availableModes, taskHistory, currentMode, tokenUsage, routerModels, apiConfiguration, currentTodos, } = useCLIStore() // Access UI state from the UI store const { showExitHint, countdownSeconds, showCustomInput, isTransitioningToCustomInput, showTodoViewer, pickerState, setIsTransitioningToCustomInput, } = useUIStateStore() // Compute context window from router models and API configuration const contextWindow = useMemo(() => { return getContextWindow(routerModels, apiConfiguration) }, [routerModels, apiConfiguration]) // eslint-disable-next-line @typescript-eslint/no-explicit-any const autocompleteRef = useRef>(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const followupAutocompleteRef = useRef>(null) // Stable refs for autocomplete data - prevents useMemo from recreating triggers on every data change const fileSearchResultsRef = useRef(fileSearchResults) const allSlashCommandsRef = useRef(allSlashCommands) const availableModesRef = useRef(availableModes) const taskHistoryRef = useRef(taskHistory) // Keep refs in sync with current state useEffect(() => { fileSearchResultsRef.current = fileSearchResults }, [fileSearchResults]) useEffect(() => { allSlashCommandsRef.current = allSlashCommands }, [allSlashCommands]) useEffect(() => { availableModesRef.current = availableModes }, [availableModes]) useEffect(() => { taskHistoryRef.current = taskHistory }, [taskHistory]) // Scroll area state const { rows } = useTerminalSize() const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true }) const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom() // RAF-style throttle refs for scroll updates (prevents multiple state updates per event loop tick). const rafIdRef = useRef(null) const pendingScrollRef = useRef<{ scrollTop: number; maxScroll: number; isAtBottom: boolean } | null>(null) // Toast notifications for ephemeral messages (e.g., mode changes). const { currentToast, showInfo } = useToast() const { handleExtensionMessage, seenMessageIds, pendingCommandRef: _pendingCommandRef, firstTextMessageSkipped, } = useMessageHandlers({ nonInteractive, }) const { sendToExtension, runTask, cleanup } = useExtensionHost({ initialPrompt, initialSessionId, continueSession, mode, reasoningEffort, user, provider, apiKey, model, workspacePath, extensionPath, debug, nonInteractive, ephemeral, exitOnComplete, onExtensionMessage: handleExtensionMessage, createExtensionHost, }) // Initialize task submit hook const { handleSubmit, handleApprove, handleReject } = useTaskSubmit({ sendToExtension, runTask, seenMessageIds, firstTextMessageSkipped, }) // Initialize focus management hook const { canToggleFocus, isScrollAreaActive, isInputAreaActive, toggleFocus } = useFocusManagement({ showApprovalPrompt: Boolean(pendingAsk && pendingAsk.type !== "followup"), pendingAsk, }) // Initialize countdown hook for followup auto-accept const { cancelCountdown } = useFollowupCountdown({ pendingAsk, onAutoSubmit: handleSubmit, }) // Initialize picker handlers hook const { handlePickerStateChange, handlePickerSelect, handlePickerClose, handlePickerIndexChange } = usePickerHandlers({ autocompleteRef, followupAutocompleteRef, sendToExtension, showInfo, seenMessageIds, firstTextMessageSkipped, }) // Initialize global input hook useGlobalInput({ canToggleFocus, isScrollAreaActive, pickerIsOpen: pickerState.isOpen, availableModes, currentMode, mode, sendToExtension, showInfo, exit, cleanup, toggleFocus, closePicker: handlePickerClose, }) // Determine current view const view = getView(messages, pendingAsk, isLoading) // Determine if we should show the approval prompt (Y/N) instead of text input const showApprovalPrompt = pendingAsk && pendingAsk.type !== "followup" // Display all messages including partial (streaming) ones const displayMessages = useMemo(() => { return messages }, [messages]) // Scroll to bottom when new messages arrive (if auto-scroll is enabled) const prevMessageCount = useRef(messages.length) useEffect(() => { if (messages.length > prevMessageCount.current && scrollState.isAtBottom) { scrollToBottom() } prevMessageCount.current = messages.length }, [messages.length, scrollState.isAtBottom, scrollToBottom]) // Handle scroll state changes from ScrollArea (RAF-throttled to coalesce rapid updates) const handleScroll = useCallback((scrollTop: number, maxScroll: number, isAtBottom: boolean) => { // Store the latest scroll values in ref pendingScrollRef.current = { scrollTop, maxScroll, isAtBottom } // Only schedule one update per event loop tick if (rafIdRef.current === null) { rafIdRef.current = setImmediate(() => { rafIdRef.current = null const pending = pendingScrollRef.current if (pending) { setScrollState(pending) pendingScrollRef.current = null } }) } }, []) // Cleanup RAF-style timer on unmount useEffect(() => { return () => { if (rafIdRef.current !== null) { clearImmediate(rafIdRef.current) } } }, []) // File search handler for the file trigger const handleFileSearch = useCallback( (query: string) => { if (!sendToExtension) { return } sendToExtension({ type: "searchFiles", query }) }, [sendToExtension], ) // Create autocomplete triggers // Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult, HistoryResult) // IMPORTANT: We use refs here to avoid recreating triggers every time data changes. // This prevents the UI flash caused by: data change -> memo recreation -> re-render with stale state // The getResults/getCommands/getModes/getHistory callbacks always read from refs to get fresh data. // eslint-disable-next-line @typescript-eslint/no-explicit-any const autocompleteTriggers = useMemo((): AutocompleteTrigger[] => { const fileTrigger = createFileTrigger({ onSearch: handleFileSearch, getResults: () => { const results = fileSearchResultsRef.current return results.map(toFileResult) }, }) const slashCommandTrigger = createSlashCommandTrigger({ getCommands: () => { // Merge CLI global commands with extension commands const extensionCommands = allSlashCommandsRef.current.map(toSlashCommandResult) const globalCommands = getGlobalCommandsForAutocomplete().map(toSlashCommandResult) // Global commands appear first, then extension commands return [...globalCommands, ...extensionCommands] }, }) const modeTrigger = createModeTrigger({ getModes: () => availableModesRef.current.map(toModeResult), }) const helpTrigger = createHelpTrigger() // History trigger - type # to search and resume previous tasks const historyTrigger = createHistoryTrigger({ getHistory: () => { // Filter to only show tasks for the current workspace // Use arePathsEqual for proper cross-platform path comparison // (handles trailing slashes, separators, and case sensitivity) const history = taskHistoryRef.current const filtered = history.filter((item) => arePathsEqual(item.workspace, workspacePath)) return filtered.map(toHistoryResult) }, }) return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger, historyTrigger] }, [handleFileSearch, workspacePath]) // Only depend on handleFileSearch and workspacePath - data accessed via refs // Refresh search results when fileSearchResults changes while file picker is open // This handles the async timing where API results arrive after initial search // IMPORTANT: Only run when fileSearchResults array identity changes (new API response) // We use a ref to track this and avoid depending on pickerState in the effect const prevFileSearchResultsRef = useRef(fileSearchResults) const pickerStateRef = useRef(pickerState) pickerStateRef.current = pickerState useEffect(() => { // Only run if fileSearchResults actually changed (different array reference) if (fileSearchResults === prevFileSearchResultsRef.current) { return } const currentPickerState = pickerStateRef.current const willRefresh = currentPickerState.isOpen && currentPickerState.activeTrigger?.id === "file" && fileSearchResults.length > 0 prevFileSearchResultsRef.current = fileSearchResults // Only refresh when file picker is open and we have new results if (willRefresh) { autocompleteRef.current?.refreshSearch() followupAutocompleteRef.current?.refreshSearch() } }, [fileSearchResults]) // Only depend on fileSearchResults - read pickerState from ref // Handle Y/N input for approval prompts useInput((input) => { if (pendingAsk && pendingAsk.type !== "followup") { const lower = input.toLowerCase() if (lower === "y") { handleApprove() } else if (lower === "n") { handleReject() } } }) // Cancel countdown timer when user navigates in the followup suggestion menu // This provides better UX - any user interaction cancels the auto-accept timer const showFollowupSuggestions = pendingAsk?.type === "followup" && pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput useInput((_input, key) => { // Only handle when followup suggestions are shown and countdown is active if (showFollowupSuggestions && countdownSeconds !== null) { // Cancel countdown on any arrow key navigation if (key.upArrow || key.downArrow) { cancelCountdown() } } }) // Error display if (error) { return ( Error: {error} Press Ctrl+C to exit ) } // Status bar content // Priority: Toast > Exit hint > Loading > Scroll indicator > Input hint // Don't show spinner when waiting for user input (pendingAsk is set) const statusBarMessage = currentToast ? ( ) : showExitHint ? ( Press Ctrl+C again to exit ) : isLoading && !pendingAsk ? ( {view === "ToolUse" ? "Using tool" : "Thinking"} Esc to cancel {isScrollAreaActive && ( <> )} ) : isScrollAreaActive ? ( ) : isInputAreaActive ? ( ? for shortcuts ) : null const getPickerRenderItem = () => { if (pickerState.activeTrigger) { return pickerState.activeTrigger.renderItem } return (item: FileResult | SlashCommandResult, isSelected: boolean) => ( {item.key} ) } return ( {/* Header - fixed size */}
{/* Scrollable message history area - fills remaining space via flexGrow */} {displayMessages.map((message) => ( ))} {/* Input area - with borders like Claude Code - fixed size */} {pendingAsk?.type === "followup" ? ( {pendingAsk.content} {pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? (