diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 0e0109fe88..a1a3c0db9c 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -1,41 +1,47 @@ import { Box, Text, useApp, useInput } from "ink" import { Select } from "@inkjs/ui" import { useState, useEffect, useCallback, useRef, useMemo } from "react" -import { EventEmitter } from "events" -import { randomUUID } from "crypto" +import type { WebviewMessage } from "@roo-code/types" -import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem, WebviewMessage } from "@roo-code/types" -import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils" - -import { FOLLOWUP_TIMEOUT_SECONDS } from "../constants.js" -import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../utils/globalCommands.js" -import { toolInspectorLog, clearToolInspectorLog } from "../utils/toolInspectorLogger.js" +import { getGlobalCommandsForAutocomplete } from "../utils/globalCommands.js" import { arePathsEqual } from "../utils/pathUtils.js" import { getContextWindow } from "../utils/getContextWindow.js" -import type { AppProps, TUIMessage, PendingAsk, View, ToolData } from "./types.js" - +import type { AppProps } from "./types.js" import * as theme from "./theme.js" -import { matchesGlobalSequence } from "../utils/globalInputSequences.js" import { useCLIStore } from "./store.js" +import { useUIStateStore } from "./stores/uiStateStore.js" -import { TerminalSizeProvider, useTerminalSize } from "./hooks/TerminalSizeContext.js" -import { useToast } from "./hooks/useToast.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 AutocompletePickerState, type AutocompleteTrigger, - type HistoryResult, type FileResult, type SlashCommandResult, - type ModeResult, AutocompleteInput, PickerSelect, createFileTrigger, @@ -53,19 +59,16 @@ import ScrollIndicator from "./components/ScrollIndicator.js" const PICKER_HEIGHT = 10 -interface ExtensionHostInterface extends EventEmitter { +interface ExtensionHostInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, handler: (...args: any[]) => void): void activate(): Promise runTask(prompt: string): Promise sendToExtension(message: WebviewMessage): void dispose(): Promise } -export interface TUIAppProps extends AppProps { - /** Extension host factory - allows dependency injection for testing. */ - createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface -} - -interface ExtensionHostOptions { +interface ExtensionHostFactoryOptions { mode: string reasoningEffort?: string apiProvider: string @@ -80,64 +83,9 @@ interface ExtensionHostOptions { ephemeral?: boolean } -/** - * Determine the current view state based on messages and pending asks - */ -function getView(messages: TUIMessage[], pendingAsk: PendingAsk | null, isLoading: boolean): View { - // If there's a pending ask requiring text input, show input - if (pendingAsk?.type === "followup") { - return "UserInput" - } - - // If there's any pending ask (approval), don't show thinking - if (pendingAsk) { - return "UserInput" - } - - // Initial state or empty - awaiting user input - if (messages.length === 0) { - return "UserInput" - } - - const lastMessage = messages.at(-1) - if (!lastMessage) { - return "UserInput" - } - - // User just sent a message, waiting for response - if (lastMessage.role === "user") { - return "AgentResponse" - } - - // Assistant replied - if (lastMessage.role === "assistant") { - if (lastMessage.hasPendingToolCalls) { - return "ToolUse" - } - - // If loading, still waiting for more - if (isLoading) { - return "AgentResponse" - } - - return "UserInput" - } - - // Tool result received, waiting for next assistant response - if (lastMessage.role === "tool") { - return "AgentResponse" - } - - return "Default" -} - -/** - * Full-width horizontal line component - uses terminal size from context - */ -function HorizontalLine({ active = false }: { active?: boolean }) { - const { columns } = useTerminalSize() - const color = active ? theme.borderColorActive : theme.borderColor - return {"─".repeat(columns)} +export interface TUIAppProps extends AppProps { + /** Extension host factory - allows dependency injection for testing. */ + createExtensionHost: (options: ExtensionHostFactoryOptions) => ExtensionHostInterface } /** @@ -167,43 +115,36 @@ function AppInner({ pendingAsk, isLoading, isComplete, - hasStartedTask, + hasStartedTask: _hasStartedTask, error, - addMessage, - setPendingAsk, - setLoading, - setComplete, - setHasStartedTask, - setError, fileSearchResults, allSlashCommands, availableModes, taskHistory, - setFileSearchResults, - setAllSlashCommands, - setAvailableModes, - setTaskHistory, - currentTaskId, - setCurrentTaskId, currentMode, - setCurrentMode, tokenUsage, routerModels, apiConfiguration, - setTokenUsage, - setRouterModels, - setApiConfiguration, currentTodos, - setTodos, } = 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( () => getContextWindow(routerModels, apiConfiguration), [routerModels, apiConfiguration], ) - const hostRef = useRef(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const autocompleteRef = useRef>(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -229,44 +170,6 @@ function AppInner({ taskHistoryRef.current = taskHistory }, [taskHistory]) - // Track seen message timestamps to filter duplicates and the prompt echo - const seenMessageIds = useRef>(new Set()) - const firstTextMessageSkipped = useRef(false) - - // Track pending command for injecting into command_output toolData - const pendingCommandRef = useRef(null) - - // Track Ctrl+C presses for "press again to exit" behavior - const [showExitHint, setShowExitHint] = useState(false) - const exitHintTimeout = useRef(null) - const pendingExit = useRef(false) - - // Countdown timer for auto-accepting followup questions - const [countdownSeconds, setCountdownSeconds] = useState(null) - const countdownIntervalRef = useRef(null) - - // Track whether user wants to type custom response for followup questions - const [showCustomInput, setShowCustomInput] = useState(false) - // Ref to track transition state (handles async state update timing) - const isTransitioningToCustomInput = useRef(false) - - // Manual focus override: 'scroll' | 'input' | null (null = auto-determine) - const [manualFocus, setManualFocus] = useState<"scroll" | "input" | null>(null) - - // State for TODO list viewer (shown via Ctrl+T shortcut) - const [showTodoViewer, setShowTodoViewer] = useState(false) - - // Autocomplete picker state (received from AutocompleteInput via callback) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [pickerState, setPickerState] = useState>({ - activeTrigger: null, - results: [], - selectedIndex: 0, - isOpen: false, - isLoading: false, - triggerInfo: null, - }) - // Scroll area state const { rows } = useTerminalSize() const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true }) @@ -275,34 +178,89 @@ function AppInner({ // Toast notifications for ephemeral messages (e.g., mode changes) const { currentToast, showInfo } = useToast() + // Initialize message handlers hook - provides refs and handler + const { + handleExtensionMessage, + seenMessageIds, + pendingCommandRef: _pendingCommandRef, + firstTextMessageSkipped, + } = useMessageHandlers({ + verbose, + nonInteractive, + }) + + // Initialize extension host hook + const { sendToExtension, runTask, cleanup } = useExtensionHost({ + initialPrompt, + mode, + reasoningEffort, + apiProvider, + apiKey, + model, + workspacePath, + extensionPath, + verbose, + 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" - // Determine if we're in a mode where focus can be toggled (text input is available) - const canToggleFocus = - !showApprovalPrompt && - (!pendingAsk || // Initial input or task complete or loading - pendingAsk.type === "followup" || // Followup question with suggestions or custom input - showCustomInput) // Custom input mode - - // Determine if scroll area should capture keyboard input - const isScrollAreaActive: boolean = - manualFocus === "scroll" ? true : manualFocus === "input" ? false : Boolean(showApprovalPrompt) - - // Determine if input area is active (for visual focus indicator) - const isInputAreaActive: boolean = - manualFocus === "input" ? true : manualFocus === "scroll" ? false : !showApprovalPrompt - - // Reset manual focus when view changes (e.g., agent starts responding) - useEffect(() => { - if (!canToggleFocus) { - setManualFocus(null) - } - }, [canToggleFocus]) - // Display all messages including partial (streaming) ones const displayMessages = useMemo(() => { return messages @@ -322,22 +280,16 @@ function AppInner({ setScrollState({ scrollTop, maxScroll, isAtBottom }) }, []) - // Cleanup function - const cleanup = useCallback(async () => { - if (hostRef.current) { - await hostRef.current.dispose() - hostRef.current = null - } - }, []) - - // File search handler for the file trigger. - const handleFileSearch = useCallback((query: string) => { - if (!hostRef.current) { - return - } - - hostRef.current.sendToExtension({ type: "searchFiles", query }) - }, []) + // 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) @@ -385,178 +337,6 @@ function AppInner({ return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger, historyTrigger] }, [handleFileSearch, workspacePath]) // Only depend on handleFileSearch and workspacePath - data accessed via refs - // Handle Ctrl+C, Tab for focus switching, Escape to cancel task, and Ctrl+M for mode cycling - useInput((input, key) => { - // Tab to toggle focus between scroll area and input (only when input is available) - if (key.tab && canToggleFocus && !pickerState.isOpen) { - setManualFocus((prev) => { - if (prev === "scroll") return "input" - if (prev === "input") return "scroll" - return isScrollAreaActive ? "input" : "scroll" - }) - return - } - - // Ctrl+M to cycle through modes (only when not loading and we have available modes) - // Uses centralized global input sequence detection - if (matchesGlobalSequence(input, key, "ctrl-m")) { - // Don't allow mode switching while a task is in progress (loading) - if (isLoading) { - showInfo("Cannot switch modes while task is in progress", 2000) - return - } - - // Need at least 2 modes to cycle - if (availableModes.length < 2) { - return - } - - // Find current mode index - const currentModeSlug = currentMode || mode - const currentIndex = availableModes.findIndex((m) => m.slug === currentModeSlug) - const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % availableModes.length - const nextMode = availableModes[nextIndex] - - if (nextMode && hostRef.current) { - // Send mode change to extension - hostRef.current.sendToExtension({ type: "switchMode", mode: nextMode.slug }) - // Show toast notification with the mode name - showInfo(`Switched to ${nextMode.name}`, 2000) - } - return - } - - // Ctrl+T to toggle TODO list viewer - if (matchesGlobalSequence(input, key, "ctrl-t")) { - // Close picker if open - if (pickerState.isOpen) { - autocompleteRef.current?.closePicker() - followupAutocompleteRef.current?.closePicker() - } - // Toggle TODO viewer - setShowTodoViewer((prev) => { - const newValue = !prev - if (newValue && currentTodos.length === 0) { - showInfo("No TODO list available", 2000) - return false - } - return newValue - }) - return - } - - // Escape key to cancel/pause task when loading (streaming) - // Escape key to close TODO viewer - if (key.escape && showTodoViewer) { - setShowTodoViewer(false) - return - } - - if (key.escape && isLoading && hostRef.current) { - // If picker is open, let the picker handle escape first - if (pickerState.isOpen) { - return - } - // Send cancel message to extension (same as webview-ui Cancel button) - hostRef.current.sendToExtension({ type: "cancelTask" }) - return - } - - if (key.ctrl && input === "c") { - // If picker is open, close it first - if (pickerState.isOpen) { - autocompleteRef.current?.closePicker() - followupAutocompleteRef.current?.closePicker() - return - } - - if (pendingExit.current) { - // Second press - exit immediately - if (exitHintTimeout.current) { - clearTimeout(exitHintTimeout.current) - } - cleanup().finally(() => { - exit() - process.exit(0) - }) - } else { - // First press - show hint and wait for second press - pendingExit.current = true - setShowExitHint(true) - - exitHintTimeout.current = setTimeout(() => { - pendingExit.current = false - setShowExitHint(false) - exitHintTimeout.current = null - }, 2000) - } - } - }) - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (exitHintTimeout.current) { - clearTimeout(exitHintTimeout.current) - } - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current) - } - } - }, []) - - // Countdown timer for auto-accepting followup questions - // Start countdown when a followup question with suggestions appears - useEffect(() => { - // Clear any existing countdown - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current) - countdownIntervalRef.current = null - } - - // Only start countdown for followup questions with suggestions (not custom input mode) - if ( - pendingAsk?.type === "followup" && - pendingAsk.suggestions && - pendingAsk.suggestions.length > 0 && - !showCustomInput - ) { - // Start countdown - setCountdownSeconds(FOLLOWUP_TIMEOUT_SECONDS) - - countdownIntervalRef.current = setInterval(() => { - setCountdownSeconds((prev) => { - if (prev === null || prev <= 1) { - // Time's up! Auto-select first option - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current) - countdownIntervalRef.current = null - } - // Auto-submit the first suggestion - if (pendingAsk?.suggestions && pendingAsk.suggestions.length > 0) { - const firstSuggestion = pendingAsk.suggestions[0] - if (firstSuggestion) { - handleSubmit(firstSuggestion.answer) - } - } - return null - } - return prev - 1 - }) - }, 1000) - } else { - // No countdown needed - setCountdownSeconds(null) - } - - return () => { - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current) - countdownIntervalRef.current = null - } - } - }, [pendingAsk?.id, pendingAsk?.type, showCustomInput]) // Re-run when pendingAsk changes or user switches to custom input - // 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) @@ -586,606 +366,6 @@ function AppInner({ } }, [fileSearchResults]) // Only depend on fileSearchResults - read pickerState from ref - // Map extension say messages to TUI messages - const handleSayMessage = useCallback( - (ts: number, say: ClineSay, text: string, partial: boolean) => { - const messageId = ts.toString() - const isResuming = useCLIStore.getState().isResumingTask - - if (say === "checkpoint_saved") { - return - } - - if (say === "api_req_started" && !verbose) { - return - } - - if (say === "user_feedback") { - seenMessageIds.current.add(messageId) - return - } - - // Skip first text message ONLY for new tasks, not resumed tasks - // When resuming, we want to show all historical messages including the first one - if (say === "text" && !firstTextMessageSkipped.current && !isResuming) { - firstTextMessageSkipped.current = true - seenMessageIds.current.add(messageId) - return - } - - if (seenMessageIds.current.has(messageId) && !partial) { - return - } - - let role: TUIMessage["role"] = "assistant" - let toolName: string | undefined - let toolDisplayName: string | undefined - let toolDisplayOutput: string | undefined - let toolData: ToolData | undefined - - if (say === "command_output") { - role = "tool" - toolName = "execute_command" - toolDisplayName = "bash" - toolDisplayOutput = text - const trackedCommand = pendingCommandRef.current - toolInspectorLog("say:command_output", { ts, trackedCommand, outputLength: text?.length }) - toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text } - pendingCommandRef.current = null - // } else if (say === "tool") { - // role = "tool" - - // try { - // const toolInfo = JSON.parse(text) - - // // Log tool payload for inspection - // toolInspectorLog("say:tool", { - // ts, - // rawText: text, - // parsedToolInfo: toolInfo, - // partial, - // }) - - // toolName = toolInfo.tool - // toolDisplayName = toolInfo.tool - // toolDisplayOutput = formatToolOutput(toolInfo) - // // Extract structured toolData for rich rendering - // toolData = extractToolData(toolInfo) - - // // Special handling for update_todo_list tool - // if (toolName === "update_todo_list" || toolName === "updateTodoList") { - // const todos = parseTodosFromToolInfo(toolInfo) - // if (todos && todos.length > 0) { - // // Capture previous todos before updating - // const prevTodos = [...currentTodos] - // setTodos(todos) - - // seenMessageIds.current.add(messageId) - - // addMessage({ - // id: messageId, - // role: "tool", - // content: text || "", - // toolName, - // toolDisplayName, - // toolDisplayOutput, - // partial, - // originalType: say, - // todos, - // previousTodos: prevTodos, - // toolData, - // }) - // return - // } - // } - // } catch { - // toolDisplayOutput = text - // } - } else if (say === "reasoning") { - role = "thinking" - } - - seenMessageIds.current.add(messageId) - - addMessage({ - id: messageId, - role, - content: text || "", - toolName, - toolDisplayName, - toolDisplayOutput, - partial, - originalType: say, - toolData, - }) - }, - [addMessage, verbose, currentTodos, setTodos], - ) - - // Handle extension ask messages - const handleAskMessage = useCallback( - (ts: number, ask: ClineAsk, text: string, partial: boolean) => { - const messageId = ts.toString() - - if (partial) { - return - } - - if (seenMessageIds.current.has(messageId)) { - return - } - - if (ask === "command_output") { - seenMessageIds.current.add(messageId) - return - } - - // Handle resume_task and resume_completed_task - stop loading and show text input - // Do not set pendingAsk - just stop loading so user sees normal input to type new message - if (ask === "resume_task" || ask === "resume_completed_task") { - seenMessageIds.current.add(messageId) - setLoading(false) - // Mark that a task has been started so subsequent messages continue the task - // (instead of starting a brand new task via runTask) - setHasStartedTask(true) - // Clear the resuming flag since we're now ready for interaction - // Historical messages should already be displayed from state processing - useCLIStore.getState().setIsResumingTask(false) - // Do not set pendingAsk - let the normal text input appear - return - } - - if (ask === "completion_result") { - seenMessageIds.current.add(messageId) - setComplete(true) - setLoading(false) - - // Parse the completion result and add a message for CompletionTool to render - try { - const completionInfo = JSON.parse(text) as Record - const toolData: ToolData = { - tool: "attempt_completion", - result: completionInfo.result as string | undefined, - content: completionInfo.result as string | undefined, - } - - addMessage({ - id: messageId, - role: "tool", - content: text, - toolName: "attempt_completion", - toolDisplayName: "Task Complete", - toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }), - originalType: ask, - toolData, - }) - } catch { - // If parsing fails, still add a basic completion message - addMessage({ - id: messageId, - role: "tool", - content: text || "Task completed", - toolName: "attempt_completion", - toolDisplayName: "Task Complete", - toolDisplayOutput: "✅ Task completed", - originalType: ask, - toolData: { - tool: "attempt_completion", - content: text, - }, - }) - } - return - } - - // Track pending command BEFORE nonInteractive handling - // This ensures we capture the command text for later injection into command_output toolData - if (ask === "command") { - toolInspectorLog("ask:command:tracking", { ts, text }) - pendingCommandRef.current = text - } - - if (nonInteractive && ask !== "followup") { - seenMessageIds.current.add(messageId) - - if (ask === "tool") { - let toolName: string | undefined - let toolDisplayName: string | undefined - let toolDisplayOutput: string | undefined - let formattedContent = text || "" - let toolData: ToolData | undefined - let todos: TodoItem[] | undefined - let previousTodos: TodoItem[] | undefined - - try { - const toolInfo = JSON.parse(text) as Record - - // Log tool payload for inspection (nonInteractive ask) - toolInspectorLog("ask:tool:nonInteractive", { - ts, - rawText: text, - parsedToolInfo: toolInfo, - partial, - }) - - toolName = toolInfo.tool as string - toolDisplayName = toolInfo.tool as string - toolDisplayOutput = formatToolOutput(toolInfo) - formattedContent = formatToolAskMessage(toolInfo) - // Extract structured toolData for rich rendering - toolData = extractToolData(toolInfo) - - // Special handling for update_todo_list tool - extract todos - if (toolName === "update_todo_list" || toolName === "updateTodoList") { - const parsedTodos = parseTodosFromToolInfo(toolInfo) - if (parsedTodos && parsedTodos.length > 0) { - todos = parsedTodos - // Capture previous todos before updating global state - previousTodos = [...currentTodos] - setTodos(parsedTodos) - } - } - } catch { - // Use raw text if not valid JSON - } - - addMessage({ - id: messageId, - role: "tool", - content: formattedContent, - toolName, - toolDisplayName, - toolDisplayOutput, - originalType: ask, - toolData, - todos, - previousTodos, - }) - } else { - addMessage({ - id: messageId, - role: "assistant", - content: text || "", - originalType: ask, - }) - } - return - } - - let suggestions: Array<{ answer: string; mode?: string | null }> | undefined - let questionText = text - - if (ask === "followup") { - try { - const data = JSON.parse(text) - questionText = data.question || text - suggestions = Array.isArray(data.suggest) ? data.suggest : undefined - } catch { - // Use raw text - } - } else if (ask === "tool") { - try { - const toolInfo = JSON.parse(text) as Record - - // Log tool payload for inspection (interactive ask) - toolInspectorLog("ask:tool:interactive", { - ts, - rawText: text, - parsedToolInfo: toolInfo, - partial, - }) - - questionText = formatToolAskMessage(toolInfo) - } catch { - // Use raw text if not valid JSON - } - } - // Note: ask === "command" is handled above before the nonInteractive block - - seenMessageIds.current.add(messageId) - - setPendingAsk({ - id: messageId, - type: ask, - content: questionText, - suggestions, - }) - }, - [addMessage, setPendingAsk, setComplete, setLoading, nonInteractive, currentTodos, setTodos], - ) - - // Handle extension messages - const handleExtensionMessage = useCallback( - (msg: ExtensionMessage) => { - if (msg.type === "state") { - const state = msg.state - - if (!state) { - return - } - - // Extract and update current mode from state. - const newMode = state.mode - - if (newMode) { - setCurrentMode(newMode) - } - - // Extract and update task history from state. - const newTaskHistory = state.taskHistory - - if (newTaskHistory && Array.isArray(newTaskHistory)) { - setTaskHistory(newTaskHistory) - } - - const clineMessages = state.clineMessages - - if (clineMessages) { - for (const clineMsg of clineMessages) { - const ts = clineMsg.ts - const type = clineMsg.type - const say = clineMsg.say - const ask = clineMsg.ask - const text = clineMsg.text || "" - const partial = clineMsg.partial || false - - if (type === "say" && say) { - handleSayMessage(ts, say, text, partial) - } else if (type === "ask" && ask) { - handleAskMessage(ts, ask, text, partial) - } - } - - // Compute token usage metrics from clineMessages. - // Skip first message (task prompt) as per webview UI pattern. - if (clineMessages.length > 1) { - const processed = consolidateApiRequests( - consolidateCommands(clineMessages.slice(1) as ClineMessage[]), - ) - - const metrics = consolidateTokenUsage(processed) - setTokenUsage(metrics) - } - } - - // After processing state, clear the resuming flag if it was set - // This ensures the flag is cleared even if no resume_task ask message is received - if (useCLIStore.getState().isResumingTask) { - useCLIStore.getState().setIsResumingTask(false) - } - } else if (msg.type === "messageUpdated") { - const clineMessage = msg.clineMessage - - if (!clineMessage) { - return - } - - const ts = clineMessage.ts - const type = clineMessage.type - const say = clineMessage.say - const ask = clineMessage.ask - const text = clineMessage.text || "" - const partial = clineMessage.partial || false - - if (type === "say" && say) { - handleSayMessage(ts, say, text, partial) - } else if (type === "ask" && ask) { - handleAskMessage(ts, ask, text, partial) - } - } else if (msg.type === "fileSearchResults") { - setFileSearchResults((msg.results as FileResult[]) || []) - } else if (msg.type === "commands") { - setAllSlashCommands((msg.commands as SlashCommandResult[]) || []) - } else if (msg.type === "modes") { - setAvailableModes((msg.modes as ModeResult[]) || []) - } else if (msg.type === "routerModels") { - if (msg.routerModels) { - setRouterModels(msg.routerModels) - } - } - }, - [ - handleSayMessage, - handleAskMessage, - setFileSearchResults, - setAllSlashCommands, - setAvailableModes, - setCurrentMode, - setTokenUsage, - setRouterModels, - setApiConfiguration, - setTaskHistory, - ], - ) - - // Initialize extension host - useEffect(() => { - const init = async () => { - // Clear tool inspector log for fresh session - clearToolInspectorLog() - - toolInspectorLog("session:start", { - timestamp: new Date().toISOString(), - mode, - nonInteractive, - }) - - try { - const host = createExtensionHost({ - mode, - reasoningEffort: reasoningEffort === "unspecified" ? undefined : reasoningEffort, - apiProvider, - apiKey, - model, - workspacePath, - extensionPath, - verbose: debug, - quiet: !verbose && !debug, - nonInteractive, - disableOutput: true, - ephemeral, - }) - - hostRef.current = host - - host.on("extensionWebviewMessage", handleExtensionMessage) - - host.on("taskComplete", async () => { - setComplete(true) - setLoading(false) - if (exitOnComplete) { - await cleanup() - exit() - setTimeout(() => process.exit(0), 100) - } - }) - - host.on("taskError", (err: string) => { - setError(err) - setLoading(false) - }) - - await host.activate() - - // Request initial state from extension (triggers postStateToWebview which includes taskHistory) - host.sendToExtension({ type: "webviewDidLaunch" }) - host.sendToExtension({ type: "requestCommands" }) - host.sendToExtension({ type: "requestModes" }) - - setLoading(false) - - if (initialPrompt) { - setHasStartedTask(true) - setLoading(true) - addMessage({ - id: randomUUID(), - role: "user", - content: initialPrompt, - }) - await host.runTask(initialPrompt) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - setLoading(false) - } - } - - init() - - return () => { - cleanup() - } - }, []) // Run once on mount - - const handleSubmit = useCallback( - async (text: string) => { - if (!hostRef.current || !text.trim()) { - return - } - - const trimmedText = text.trim() - - if (trimmedText === "__CUSTOM__") { - return - } - - // Check for CLI global action commands (e.g., /new). - if (trimmedText.startsWith("/")) { - const commandMatch = trimmedText.match(/^\/(\w+)(?:\s|$)/) - - if (commandMatch && commandMatch[1]) { - const globalCommand = getGlobalCommand(commandMatch[1]) - - if (globalCommand?.action === "clearTask") { - // Reset CLI state and send clearTask to extension. - useCLIStore.getState().reset() - // Reset component-level refs to avoid stale message tracking. - seenMessageIds.current.clear() - firstTextMessageSkipped.current = false - hostRef.current.sendToExtension({ type: "clearTask" }) - // Re-request state, commands and modes since reset() cleared them. - hostRef.current.sendToExtension({ type: "webviewDidLaunch" }) - hostRef.current.sendToExtension({ type: "requestCommands" }) - hostRef.current.sendToExtension({ type: "requestModes" }) - return - } - } - } - - if (pendingAsk) { - addMessage({ id: randomUUID(), role: "user", content: trimmedText }) - - hostRef.current.sendToExtension({ - type: "askResponse", - askResponse: "messageResponse", - text: trimmedText, - }) - - setPendingAsk(null) - setShowCustomInput(false) - isTransitioningToCustomInput.current = false - setLoading(true) - } else if (!hasStartedTask) { - setHasStartedTask(true) - setLoading(true) - addMessage({ id: randomUUID(), role: "user", content: trimmedText }) - - try { - await hostRef.current.runTask(trimmedText) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - setLoading(false) - } - } else { - if (isComplete) { - setComplete(false) - } - - setLoading(true) - addMessage({ id: randomUUID(), role: "user", content: trimmedText }) - - hostRef.current.sendToExtension({ - type: "askResponse", - askResponse: "messageResponse", - text: trimmedText, - }) - } - }, - [ - pendingAsk, - hasStartedTask, - isComplete, - addMessage, - setPendingAsk, - setHasStartedTask, - setLoading, - setComplete, - setError, - ], - ) - - // Handle approval (Y key) - const handleApprove = useCallback(() => { - if (!hostRef.current) { - return - } - - hostRef.current.sendToExtension({ type: "askResponse", askResponse: "yesButtonClicked" }) - setPendingAsk(null) - setLoading(true) - }, [setPendingAsk, setLoading]) - - // Handle rejection (N key) - const handleReject = useCallback(() => { - if (!hostRef.current) { - return - } - - hostRef.current.sendToExtension({ type: "askResponse", askResponse: "noButtonClicked" }) - setPendingAsk(null) - setLoading(true) - }, [setPendingAsk, setLoading]) - // Handle Y/N input for approval prompts useInput((input) => { if (pendingAsk && pendingAsk.type !== "followup") { @@ -1212,106 +392,11 @@ function AppInner({ if (showFollowupSuggestions && countdownSeconds !== null) { // Cancel countdown on any arrow key navigation if (key.upArrow || key.downArrow) { - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current) - countdownIntervalRef.current = null - } - setCountdownSeconds(null) + cancelCountdown() } } }) - // Handle picker state changes from AutocompleteInput - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handlePickerStateChange = useCallback((state: AutocompletePickerState) => setPickerState(state), []) - - // Handle item selection from external PickerSelect - const handlePickerSelect = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (item: any) => { - // Check if this is a mode selection - if (pickerState.activeTrigger?.id === "mode" && item && typeof item === "object" && "slug" in item) { - const modeItem = item as ModeResult - - // Send mode change message to extension - if (hostRef.current) { - hostRef.current.sendToExtension({ type: "switchMode", mode: modeItem.slug }) - } - - // Close the picker - autocompleteRef.current?.closePicker() - followupAutocompleteRef.current?.closePicker() - } - // Check if this is a history item selection - else if (pickerState.activeTrigger?.id === "history" && item && typeof item === "object" && "id" in item) { - const historyItem = item as HistoryResult - - // Don't allow task switching while a task is in progress (loading) - if (isLoading) { - showInfo("Cannot switch tasks while task is in progress", 2000) - // Close the picker - autocompleteRef.current?.closePicker() - followupAutocompleteRef.current?.closePicker() - return - } - - // If selecting the same task that's already loaded, just close the picker - if (historyItem.id === currentTaskId) { - autocompleteRef.current?.closePicker() - followupAutocompleteRef.current?.closePicker() - return - } - - // Send showTaskWithId message to extension to resume the task - if (hostRef.current) { - // Use selective reset that preserves global state (taskHistory, modes, commands) - useCLIStore.getState().resetForTaskSwitch() - // Set the resuming flag so message handlers know we're resuming - // This prevents skipping the first text message (which is historical) - useCLIStore.getState().setIsResumingTask(true) - // Track which task we're switching to - setCurrentTaskId(historyItem.id) - // Reset refs to avoid stale state across task switches - seenMessageIds.current.clear() - firstTextMessageSkipped.current = false - - // Send message to resume the selected task - // This triggers createTaskWithHistoryItem -> postStateToWebview - // which includes clineMessages and handles mode restoration - hostRef.current.sendToExtension({ type: "showTaskWithId", text: historyItem.id }) - - // DON'T send these redundant requests - they cause race conditions: - // - showTaskWithId already triggers postStateToWebview which includes everything - // - resetForTaskSwitch preserves taskHistory, modes, and commands - // hostRef.current.sendToExtension({ type: "webviewDidLaunch" }) - // hostRef.current.sendToExtension({ type: "requestCommands" }) - // hostRef.current.sendToExtension({ type: "requestModes" }) - } - - // Close the picker - autocompleteRef.current?.closePicker() - followupAutocompleteRef.current?.closePicker() - } else { - // Handle other item selections normally - autocompleteRef.current?.handleItemSelect(item) - followupAutocompleteRef.current?.handleItemSelect(item) - } - }, - [pickerState.activeTrigger, isLoading, showInfo, currentTaskId, setCurrentTaskId], - ) - - // Handle picker close from external PickerSelect - const handlePickerClose = useCallback(() => { - autocompleteRef.current?.closePicker() - followupAutocompleteRef.current?.closePicker() - }, []) - - // Handle picker index change from external PickerSelect - const handlePickerIndexChange = useCallback((index: number) => { - autocompleteRef.current?.handleIndexChange(index) - followupAutocompleteRef.current?.handleIndexChange(index) - }, []) - // Error display if (error) { return ( @@ -1411,18 +496,13 @@ function AppInner({ ]} onChange={(value) => { if (!value || typeof value !== "string") return - if (showCustomInput || isTransitioningToCustomInput.current) return + if (showCustomInput || isTransitioningToCustomInput) return if (value === "__CUSTOM__") { - // Clear countdown timer synchronously BEFORE state update - // This prevents race condition where interval fires before useEffect cleanup - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current) - countdownIntervalRef.current = null - } - setCountdownSeconds(null) - isTransitioningToCustomInput.current = true - setShowCustomInput(true) + // Clear countdown timer and switch to custom input + cancelCountdown() + setIsTransitioningToCustomInput(true) + useUIStateStore.getState().setShowCustomInput(true) } else if (value.trim()) { handleSubmit(value) } @@ -1445,8 +525,8 @@ function AppInner({ onSubmit={(text: string) => { if (text && text.trim()) { handleSubmit(text) - setShowCustomInput(false) - isTransitioningToCustomInput.current = false + useUIStateStore.getState().setShowCustomInput(false) + setIsTransitioningToCustomInput(false) } }} isActive={true} @@ -1540,346 +620,3 @@ export function App(props: TUIAppProps) { ) } - -/** - * Extract structured ToolData from parsed tool JSON - * This provides rich data for tool-specific renderers - */ -function extractToolData(toolInfo: Record): ToolData { - const toolName = (toolInfo.tool as string) || "unknown" - - // Base tool data with common fields - const toolData: ToolData = { - tool: toolName, - path: toolInfo.path as string | undefined, - isOutsideWorkspace: toolInfo.isOutsideWorkspace as boolean | undefined, - isProtected: toolInfo.isProtected as boolean | undefined, - content: toolInfo.content as string | undefined, - reason: toolInfo.reason as string | undefined, - } - - // Extract diff-related fields - if (toolInfo.diff !== undefined) { - toolData.diff = toolInfo.diff as string - } - if (toolInfo.diffStats !== undefined) { - const stats = toolInfo.diffStats as { added?: number; removed?: number } - if (typeof stats.added === "number" && typeof stats.removed === "number") { - toolData.diffStats = { added: stats.added, removed: stats.removed } - } - } - - // Extract search-related fields - if (toolInfo.regex !== undefined) { - toolData.regex = toolInfo.regex as string - } - if (toolInfo.filePattern !== undefined) { - toolData.filePattern = toolInfo.filePattern as string - } - if (toolInfo.query !== undefined) { - toolData.query = toolInfo.query as string - } - - // Extract mode-related fields - if (toolInfo.mode !== undefined) { - toolData.mode = toolInfo.mode as string - } - if (toolInfo.mode_slug !== undefined) { - toolData.mode = toolInfo.mode_slug as string - } - - // Extract command-related fields - if (toolInfo.command !== undefined) { - toolData.command = toolInfo.command as string - } - if (toolInfo.output !== undefined) { - toolData.output = toolInfo.output as string - } - - // Extract browser-related fields - if (toolInfo.action !== undefined) { - toolData.action = toolInfo.action as string - } - if (toolInfo.url !== undefined) { - toolData.url = toolInfo.url as string - } - if (toolInfo.coordinate !== undefined) { - toolData.coordinate = toolInfo.coordinate as string - } - - // Extract batch file operations - if (Array.isArray(toolInfo.files)) { - toolData.batchFiles = (toolInfo.files as Array>).map((f) => ({ - path: (f.path as string) || "", - lineSnippet: f.lineSnippet as string | undefined, - isOutsideWorkspace: f.isOutsideWorkspace as boolean | undefined, - key: f.key as string | undefined, - content: f.content as string | undefined, - })) - } - - // Extract batch diff operations - if (Array.isArray(toolInfo.batchDiffs)) { - toolData.batchDiffs = (toolInfo.batchDiffs as Array>).map((d) => ({ - path: (d.path as string) || "", - changeCount: d.changeCount as number | undefined, - key: d.key as string | undefined, - content: d.content as string | undefined, - diffStats: d.diffStats as { added: number; removed: number } | undefined, - diffs: d.diffs as Array<{ content: string; startLine?: number }> | undefined, - })) - } - - // Extract question/completion fields - if (toolInfo.question !== undefined) { - toolData.question = toolInfo.question as string - } - if (toolInfo.result !== undefined) { - toolData.result = toolInfo.result as string - } - - // Extract additional display hints - if (toolInfo.lineNumber !== undefined) { - toolData.lineNumber = toolInfo.lineNumber as number - } - if (toolInfo.additionalFileCount !== undefined) { - toolData.additionalFileCount = toolInfo.additionalFileCount as number - } - - return toolData -} - -/** - * Format tool output for display (used in the message body, header shows tool name separately) - */ -function formatToolOutput(toolInfo: Record): string { - const toolName = (toolInfo.tool as string) || "unknown" - - switch (toolName) { - case "switchMode": { - const mode = (toolInfo.mode as string) || "unknown" - const reason = toolInfo.reason as string - return `→ ${mode} mode${reason ? `\n ${reason}` : ""}` - } - - case "switch_mode": { - const mode = (toolInfo.mode_slug as string) || (toolInfo.mode as string) || "unknown" - const reason = toolInfo.reason as string - return `→ ${mode} mode${reason ? `\n ${reason}` : ""}` - } - - case "execute_command": { - const command = toolInfo.command as string - return `$ ${command || "(no command)"}` - } - - case "read_file": { - const files = toolInfo.files as Array<{ path: string }> | undefined - const path = toolInfo.path as string - if (files && files.length > 0) { - return files.map((f) => `📄 ${f.path}`).join("\n") - } - return `📄 ${path || "(no path)"}` - } - - case "write_to_file": { - const writePath = toolInfo.path as string - return `📝 ${writePath || "(no path)"}` - } - - case "apply_diff": { - const diffPath = toolInfo.path as string - return `✏️ ${diffPath || "(no path)"}` - } - - case "search_files": { - const searchPath = toolInfo.path as string - const regex = toolInfo.regex as string - return `🔍 "${regex}" in ${searchPath || "."}` - } - - case "list_files": { - const listPath = toolInfo.path as string - const recursive = toolInfo.recursive as boolean - return `📁 ${listPath || "."}${recursive ? " (recursive)" : ""}` - } - - case "browser_action": { - const action = toolInfo.action as string - const url = toolInfo.url as string - return `🌐 ${action || "action"}${url ? `: ${url}` : ""}` - } - - case "attempt_completion": { - const result = toolInfo.result as string - if (result) { - const truncated = result.length > 100 ? result.substring(0, 100) + "..." : result - return `✅ ${truncated}` - } - return "✅ Task completed" - } - - case "ask_followup_question": { - const question = toolInfo.question as string - return `❓ ${question || "(no question)"}` - } - - case "new_task": { - const taskMode = toolInfo.mode as string - return `📋 Creating subtask${taskMode ? ` in ${taskMode} mode` : ""}` - } - - case "update_todo_list": - case "updateTodoList": { - // Special marker - actual rendering is handled by TodoChangeDisplay component - return "☑ TODO list updated" - } - - default: { - const params = Object.entries(toolInfo) - .filter(([key]) => key !== "tool") - .map(([key, value]) => { - const displayValue = typeof value === "string" ? value : JSON.stringify(value) - const truncated = displayValue.length > 100 ? displayValue.substring(0, 100) + "..." : displayValue - return `${key}: ${truncated}` - }) - .join("\n") - return params || "(no parameters)" - } - } -} - -/** - * Format tool ask message for user approval prompt - */ -function formatToolAskMessage(toolInfo: Record): string { - const toolName = (toolInfo.tool as string) || "unknown" - - switch (toolName) { - case "switchMode": - case "switch_mode": { - const mode = (toolInfo.mode as string) || (toolInfo.mode_slug as string) || "unknown" - const reason = toolInfo.reason as string - return `Switch to ${mode} mode?${reason ? `\nReason: ${reason}` : ""}` - } - - case "execute_command": { - const command = toolInfo.command as string - return `Run command?\n$ ${command || "(no command)"}` - } - - case "read_file": { - const files = toolInfo.files as Array<{ path: string }> | undefined - const path = toolInfo.path as string - if (files && files.length > 0) { - return `Read ${files.length} file(s)?\n${files.map((f) => ` ${f.path}`).join("\n")}` - } - return `Read file: ${path || "(no path)"}` - } - - case "write_to_file": { - const writePath = toolInfo.path as string - return `Write to file: ${writePath || "(no path)"}` - } - - case "apply_diff": { - const diffPath = toolInfo.path as string - return `Apply changes to: ${diffPath || "(no path)"}` - } - - case "browser_action": { - const action = toolInfo.action as string - const url = toolInfo.url as string - return `Browser: ${action || "action"}${url ? ` - ${url}` : ""}` - } - - default: { - const params = Object.entries(toolInfo) - .filter(([key]) => key !== "tool") - .map(([key, value]) => { - const displayValue = typeof value === "string" ? value : JSON.stringify(value) - const truncated = displayValue.length > 80 ? displayValue.substring(0, 80) + "..." : displayValue - return ` ${key}: ${truncated}` - }) - .join("\n") - return `${toolName}${params ? `\n${params}` : ""}` - } - } -} - -/** - * Parse TODO items from tool info - * Handles both array format and markdown checklist string format - */ -function parseTodosFromToolInfo(toolInfo: Record): TodoItem[] | null { - // Try to get todos directly as an array - const todosArray = toolInfo.todos as unknown[] | undefined - if (Array.isArray(todosArray)) { - return todosArray - .map((item, index) => { - if (typeof item === "object" && item !== null) { - const todo = item as Record - return { - id: (todo.id as string) || `todo-${index}`, - content: (todo.content as string) || "", - status: ((todo.status as string) || "pending") as TodoItem["status"], - } - } - return null - }) - .filter((item): item is TodoItem => item !== null) - } - - // Try to parse markdown checklist format from todos string - const todosString = toolInfo.todos as string | undefined - if (typeof todosString === "string") { - return parseMarkdownChecklist(todosString) - } - - return null -} - -/** - * Parse a markdown checklist string into TodoItem array - * Format: - * [ ] pending item - * [-] in progress item - * [x] completed item - */ -function parseMarkdownChecklist(markdown: string): TodoItem[] { - const lines = markdown.split("\n") - const todos: TodoItem[] = [] - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - - if (!line) { - continue - } - - const trimmedLine = line.trim() - - if (!trimmedLine) { - continue - } - - // Match markdown checkbox patterns - const checkboxMatch = trimmedLine.match(/^\[([x\-\s])\]\s*(.+)$/i) - - if (checkboxMatch) { - const statusChar = checkboxMatch[1] ?? " " - const content = checkboxMatch[2] ?? "" - let status: TodoItem["status"] = "pending" - - if (statusChar.toLowerCase() === "x") { - status = "completed" - } else if (statusChar === "-") { - status = "in_progress" - } - - todos.push({ id: `todo-${i}`, content: content.trim(), status }) - } - } - - return todos -} diff --git a/apps/cli/src/ui/components/HorizontalLine.tsx b/apps/cli/src/ui/components/HorizontalLine.tsx new file mode 100644 index 0000000000..50b16aea00 --- /dev/null +++ b/apps/cli/src/ui/components/HorizontalLine.tsx @@ -0,0 +1,16 @@ +import { Text } from "ink" +import { useTerminalSize } from "../hooks/TerminalSizeContext.js" +import * as theme from "../theme.js" + +interface HorizontalLineProps { + active?: boolean +} + +/** + * Full-width horizontal line component - uses terminal size from context + */ +export function HorizontalLine({ active = false }: HorizontalLineProps) { + const { columns } = useTerminalSize() + const color = active ? theme.borderColorActive : theme.borderColor + return {"─".repeat(columns)} +} diff --git a/apps/cli/src/ui/hooks/index.ts b/apps/cli/src/ui/hooks/index.ts new file mode 100644 index 0000000000..9e12cd9b0e --- /dev/null +++ b/apps/cli/src/ui/hooks/index.ts @@ -0,0 +1,22 @@ +// Export existing hooks +export { TerminalSizeProvider, useTerminalSize } from "./TerminalSizeContext.js" +export { useToast, useToastStore } from "./useToast.js" +export { useInputHistory } from "./useInputHistory.js" + +// Export new extracted hooks +export { useFollowupCountdown } from "./useFollowupCountdown.js" +export { useFocusManagement } from "./useFocusManagement.js" +export { useMessageHandlers } from "./useMessageHandlers.js" +export { useExtensionHost } from "./useExtensionHost.js" +export { useTaskSubmit } from "./useTaskSubmit.js" +export { useGlobalInput } from "./useGlobalInput.js" +export { usePickerHandlers } from "./usePickerHandlers.js" + +// Export types +export type { UseFollowupCountdownOptions } from "./useFollowupCountdown.js" +export type { UseFocusManagementOptions, UseFocusManagementReturn } from "./useFocusManagement.js" +export type { UseMessageHandlersOptions, UseMessageHandlersReturn } from "./useMessageHandlers.js" +export type { UseExtensionHostOptions, UseExtensionHostReturn } from "./useExtensionHost.js" +export type { UseTaskSubmitOptions, UseTaskSubmitReturn } from "./useTaskSubmit.js" +export type { UseGlobalInputOptions } from "./useGlobalInput.js" +export type { UsePickerHandlersOptions, UsePickerHandlersReturn } from "./usePickerHandlers.js" diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts new file mode 100644 index 0000000000..7317181068 --- /dev/null +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -0,0 +1,205 @@ +import { useEffect, useRef, useCallback } from "react" +import { useApp } from "ink" +import { randomUUID } from "crypto" +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +import { toolInspectorLog, clearToolInspectorLog } from "../../utils/toolInspectorLogger.js" +import { useCLIStore } from "../store.js" + +interface ExtensionHostInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, handler: (...args: any[]) => void): void + activate(): Promise + runTask(prompt: string): Promise + sendToExtension(message: WebviewMessage): void + dispose(): Promise +} + +export interface ExtensionHostOptions { + mode: string + reasoningEffort?: string + apiProvider: string + apiKey: string + model: string + workspacePath: string + extensionPath: string + verbose: boolean + debug: boolean + nonInteractive: boolean + ephemeral?: boolean +} + +export interface UseExtensionHostOptions extends ExtensionHostOptions { + initialPrompt?: string + exitOnComplete?: boolean + onExtensionMessage: (msg: ExtensionMessage) => void + createExtensionHost: (options: ExtensionHostFactoryOptions) => ExtensionHostInterface +} + +interface ExtensionHostFactoryOptions { + mode: string + reasoningEffort?: string + apiProvider: string + apiKey: string + model: string + workspacePath: string + extensionPath: string + verbose: boolean + quiet: boolean + nonInteractive: boolean + disableOutput: boolean + ephemeral?: boolean +} + +export interface UseExtensionHostReturn { + isReady: boolean + sendToExtension: ((msg: WebviewMessage) => void) | null + runTask: ((prompt: string) => Promise) | null + cleanup: () => Promise +} + +/** + * Hook to manage the extension host lifecycle. + * + * Responsibilities: + * - Initialize the extension host + * - Set up event listeners for messages, task completion, and errors + * - Handle cleanup/disposal + * - Expose methods for sending messages and running tasks + */ +export function useExtensionHost({ + initialPrompt, + mode, + reasoningEffort, + apiProvider, + apiKey, + model, + workspacePath, + extensionPath, + verbose, + debug, + nonInteractive, + ephemeral, + exitOnComplete, + onExtensionMessage, + createExtensionHost, +}: UseExtensionHostOptions): UseExtensionHostReturn { + const { exit } = useApp() + const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore() + + const hostRef = useRef(null) + const isReadyRef = useRef(false) + + // Cleanup function + const cleanup = useCallback(async () => { + if (hostRef.current) { + await hostRef.current.dispose() + hostRef.current = null + isReadyRef.current = false + } + }, []) + + // Initialize extension host + useEffect(() => { + const init = async () => { + // Clear tool inspector log for fresh session + clearToolInspectorLog() + + toolInspectorLog("session:start", { + timestamp: new Date().toISOString(), + mode, + nonInteractive, + }) + + try { + const host = createExtensionHost({ + mode, + reasoningEffort: reasoningEffort === "unspecified" ? undefined : reasoningEffort, + apiProvider, + apiKey, + model, + workspacePath, + extensionPath, + verbose: debug, + quiet: !verbose && !debug, + nonInteractive, + disableOutput: true, + ephemeral, + }) + + hostRef.current = host + isReadyRef.current = true + + host.on("extensionWebviewMessage", onExtensionMessage) + + host.on("taskComplete", async () => { + setComplete(true) + setLoading(false) + if (exitOnComplete) { + await cleanup() + exit() + setTimeout(() => process.exit(0), 100) + } + }) + + host.on("taskError", (err: string) => { + setError(err) + setLoading(false) + }) + + await host.activate() + + // Request initial state from extension (triggers postStateToWebview which includes taskHistory) + host.sendToExtension({ type: "webviewDidLaunch" }) + host.sendToExtension({ type: "requestCommands" }) + host.sendToExtension({ type: "requestModes" }) + + setLoading(false) + + if (initialPrompt) { + setHasStartedTask(true) + setLoading(true) + addMessage({ + id: randomUUID(), + role: "user", + content: initialPrompt, + }) + await host.runTask(initialPrompt) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setLoading(false) + } + } + + init() + + return () => { + cleanup() + } + }, []) // Run once on mount + + // Expose sendToExtension method + const sendToExtension = hostRef.current + ? (msg: WebviewMessage) => { + hostRef.current?.sendToExtension(msg) + } + : null + + // Expose runTask method + const runTask = hostRef.current + ? (prompt: string) => { + if (!hostRef.current) { + return Promise.reject(new Error("Extension host not ready")) + } + return hostRef.current.runTask(prompt) + } + : null + + return { + isReady: isReadyRef.current, + sendToExtension, + runTask, + cleanup, + } +} diff --git a/apps/cli/src/ui/hooks/useFocusManagement.ts b/apps/cli/src/ui/hooks/useFocusManagement.ts new file mode 100644 index 0000000000..dd0b30c61c --- /dev/null +++ b/apps/cli/src/ui/hooks/useFocusManagement.ts @@ -0,0 +1,85 @@ +import { useEffect } from "react" +import { useUIStateStore } from "../stores/uiStateStore.js" +import type { PendingAsk } from "../types.js" + +export interface UseFocusManagementOptions { + showApprovalPrompt: boolean + pendingAsk: PendingAsk | null +} + +export interface UseFocusManagementReturn { + /** Whether focus can be toggled between scroll and input areas */ + canToggleFocus: boolean + /** Whether scroll area should capture keyboard input */ + isScrollAreaActive: boolean + /** Whether input area is active (for visual focus indicator) */ + isInputAreaActive: boolean + /** Manual focus override */ + manualFocus: "scroll" | "input" | null + /** Set manual focus override */ + setManualFocus: (focus: "scroll" | "input" | null) => void + /** Toggle focus between scroll and input */ + toggleFocus: () => void +} + +/** + * Hook to manage focus state between scroll area and input area. + * + * Focus can be toggled when text input is available (not showing approval prompt). + * The hook automatically resets manual focus when the view changes. + */ +export function useFocusManagement({ + showApprovalPrompt, + pendingAsk, +}: UseFocusManagementOptions): UseFocusManagementReturn { + const { showCustomInput, manualFocus, setManualFocus } = useUIStateStore() + + // Determine if we're in a mode where focus can be toggled (text input is available) + const canToggleFocus = + !showApprovalPrompt && + (!pendingAsk || // Initial input or task complete or loading + pendingAsk.type === "followup" || // Followup question with suggestions or custom input + showCustomInput) // Custom input mode + + // Determine if scroll area should capture keyboard input + const isScrollAreaActive: boolean = + manualFocus === "scroll" ? true : manualFocus === "input" ? false : Boolean(showApprovalPrompt) + + // Determine if input area is active (for visual focus indicator) + const isInputAreaActive: boolean = + manualFocus === "input" ? true : manualFocus === "scroll" ? false : !showApprovalPrompt + + // Reset manual focus when view changes (e.g., agent starts responding) + useEffect(() => { + if (!canToggleFocus) { + setManualFocus(null) + } + }, [canToggleFocus, setManualFocus]) + + /** + * Toggle focus between scroll and input areas + */ + const toggleFocus = () => { + if (!canToggleFocus) { + return + } + + const prev = manualFocus + if (prev === "scroll") { + setManualFocus("input") + } else if (prev === "input") { + setManualFocus("scroll") + } else { + setManualFocus(isScrollAreaActive ? "input" : "scroll") + } + } + + return { + canToggleFocus, + isScrollAreaActive, + isInputAreaActive, + manualFocus, + setManualFocus, + toggleFocus, + } +} diff --git a/apps/cli/src/ui/hooks/useFollowupCountdown.ts b/apps/cli/src/ui/hooks/useFollowupCountdown.ts new file mode 100644 index 0000000000..b5270795d5 --- /dev/null +++ b/apps/cli/src/ui/hooks/useFollowupCountdown.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef } from "react" +import { FOLLOWUP_TIMEOUT_SECONDS } from "../../constants.js" +import { useUIStateStore } from "../stores/uiStateStore.js" +import type { PendingAsk } from "../types.js" + +export interface UseFollowupCountdownOptions { + pendingAsk: PendingAsk | null + onAutoSubmit: (text: string) => void +} + +/** + * Hook to manage auto-accept countdown timer for followup questions with suggestions. + * + * When a followup question appears with suggestions (and not in custom input mode), + * starts a countdown timer that auto-submits the first suggestion when it reaches zero. + * + * The countdown can be canceled by: + * - User navigating with arrow keys + * - User switching to custom input mode + * - Followup question changing/disappearing + */ +export function useFollowupCountdown({ pendingAsk, onAutoSubmit }: UseFollowupCountdownOptions) { + const { showCustomInput, countdownSeconds, setCountdownSeconds } = useUIStateStore() + const countdownIntervalRef = useRef(null) + + // Cleanup interval on unmount + useEffect(() => { + return () => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + } + } + }, []) + + // Start countdown when a followup question with suggestions appears + useEffect(() => { + // Clear any existing countdown + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + + // Only start countdown for followup questions with suggestions (not custom input mode) + if ( + pendingAsk?.type === "followup" && + pendingAsk.suggestions && + pendingAsk.suggestions.length > 0 && + !showCustomInput + ) { + // Start countdown + setCountdownSeconds(FOLLOWUP_TIMEOUT_SECONDS) + + countdownIntervalRef.current = setInterval(() => { + const currentSeconds = useUIStateStore.getState().countdownSeconds + if (currentSeconds === null || currentSeconds <= 1) { + // Time's up! Auto-select first option + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + setCountdownSeconds(null) + // Auto-submit the first suggestion + if (pendingAsk?.suggestions && pendingAsk.suggestions.length > 0) { + const firstSuggestion = pendingAsk.suggestions[0] + if (firstSuggestion) { + onAutoSubmit(firstSuggestion.answer) + } + } + } else { + setCountdownSeconds(currentSeconds - 1) + } + }, 1000) + } else { + // No countdown needed + setCountdownSeconds(null) + } + + return () => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + } + }, [pendingAsk?.id, pendingAsk?.type, showCustomInput, onAutoSubmit, setCountdownSeconds, pendingAsk?.suggestions]) + + /** + * Cancel the countdown timer (called when user interacts with the menu) + */ + const cancelCountdown = () => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + setCountdownSeconds(null) + } + + return { + countdownSeconds, + cancelCountdown, + } +} diff --git a/apps/cli/src/ui/hooks/useGlobalInput.ts b/apps/cli/src/ui/hooks/useGlobalInput.ts new file mode 100644 index 0000000000..4997c452d2 --- /dev/null +++ b/apps/cli/src/ui/hooks/useGlobalInput.ts @@ -0,0 +1,170 @@ +import { useEffect, useRef } from "react" +import { useInput } from "ink" +import type { WebviewMessage } from "@roo-code/types" + +import { matchesGlobalSequence } from "../../utils/globalInputSequences.js" +import type { ModeResult } from "../components/autocomplete/index.js" +import { useUIStateStore } from "../stores/uiStateStore.js" +import { useCLIStore } from "../store.js" + +export interface UseGlobalInputOptions { + canToggleFocus: boolean + isScrollAreaActive: boolean + pickerIsOpen: boolean + availableModes: ModeResult[] + currentMode: string | null + mode: string + sendToExtension: ((msg: WebviewMessage) => void) | null + showInfo: (msg: string, duration?: number) => void + exit: () => void + cleanup: () => Promise + toggleFocus: () => void + closePicker: () => void +} + +/** + * Hook to handle global keyboard shortcuts. + * + * Shortcuts: + * - Ctrl+C: Double-press to exit + * - Tab: Toggle focus between scroll area and input + * - Ctrl+M: Cycle through available modes + * - Ctrl+T: Toggle TODO list viewer + * - Escape: Cancel task (when loading) or close TODO viewer + */ +export function useGlobalInput({ + canToggleFocus, + isScrollAreaActive: _isScrollAreaActive, + pickerIsOpen, + availableModes, + currentMode, + mode, + sendToExtension, + showInfo, + exit, + cleanup, + toggleFocus, + closePicker, +}: UseGlobalInputOptions): void { + const { isLoading, currentTodos } = useCLIStore() + const { + showTodoViewer, + setShowTodoViewer, + showExitHint: _showExitHint, + setShowExitHint, + pendingExit, + setPendingExit, + } = useUIStateStore() + + // Track Ctrl+C presses for "press again to exit" behavior + const exitHintTimeout = useRef(null) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (exitHintTimeout.current) { + clearTimeout(exitHintTimeout.current) + } + } + }, []) + + // Handle global keyboard shortcuts + useInput((input, key) => { + // Tab to toggle focus between scroll area and input (only when input is available) + if (key.tab && canToggleFocus && !pickerIsOpen) { + toggleFocus() + return + } + + // Ctrl+M to cycle through modes (only when not loading and we have available modes) + // Uses centralized global input sequence detection + if (matchesGlobalSequence(input, key, "ctrl-m")) { + // Don't allow mode switching while a task is in progress (loading) + if (isLoading) { + showInfo("Cannot switch modes while task is in progress", 2000) + return + } + + // Need at least 2 modes to cycle + if (availableModes.length < 2) { + return + } + + // Find current mode index + const currentModeSlug = currentMode || mode + const currentIndex = availableModes.findIndex((m) => m.slug === currentModeSlug) + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % availableModes.length + const nextMode = availableModes[nextIndex] + + if (nextMode && sendToExtension) { + // Send mode change to extension + sendToExtension({ type: "switchMode", mode: nextMode.slug }) + // Show toast notification with the mode name + showInfo(`Switched to ${nextMode.name}`, 2000) + } + return + } + + // Ctrl+T to toggle TODO list viewer + if (matchesGlobalSequence(input, key, "ctrl-t")) { + // Close picker if open + if (pickerIsOpen) { + closePicker() + } + // Toggle TODO viewer + setShowTodoViewer(!showTodoViewer) + if (!showTodoViewer && currentTodos.length === 0) { + showInfo("No TODO list available", 2000) + setShowTodoViewer(false) + } + return + } + + // Escape key to close TODO viewer + if (key.escape && showTodoViewer) { + setShowTodoViewer(false) + return + } + + // Escape key to cancel/pause task when loading (streaming) + if (key.escape && isLoading && sendToExtension) { + // If picker is open, let the picker handle escape first + if (pickerIsOpen) { + return + } + // Send cancel message to extension (same as webview-ui Cancel button) + sendToExtension({ type: "cancelTask" }) + return + } + + // Ctrl+C to exit + if (key.ctrl && input === "c") { + // If picker is open, close it first + if (pickerIsOpen) { + closePicker() + return + } + + if (pendingExit) { + // Second press - exit immediately + if (exitHintTimeout.current) { + clearTimeout(exitHintTimeout.current) + } + cleanup().finally(() => { + exit() + process.exit(0) + }) + } else { + // First press - show hint and wait for second press + setPendingExit(true) + setShowExitHint(true) + + exitHintTimeout.current = setTimeout(() => { + setPendingExit(false) + setShowExitHint(false) + exitHintTimeout.current = null + }, 2000) + } + } + }) +} diff --git a/apps/cli/src/ui/hooks/useMessageHandlers.ts b/apps/cli/src/ui/hooks/useMessageHandlers.ts new file mode 100644 index 0000000000..29abee13a1 --- /dev/null +++ b/apps/cli/src/ui/hooks/useMessageHandlers.ts @@ -0,0 +1,437 @@ +import { useCallback, useRef } from "react" +import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types" +import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils" + +import { toolInspectorLog } from "../../utils/toolInspectorLogger.js" +import type { TUIMessage, ToolData } from "../types.js" +import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js" +import { useCLIStore } from "../store.js" +import { + extractToolData, + formatToolOutput, + formatToolAskMessage, + parseTodosFromToolInfo, +} from "../utils/toolDataUtils.js" + +export interface UseMessageHandlersOptions { + verbose: boolean + nonInteractive: boolean +} + +export interface UseMessageHandlersReturn { + handleExtensionMessage: (msg: ExtensionMessage) => void + seenMessageIds: React.MutableRefObject> + pendingCommandRef: React.MutableRefObject + firstTextMessageSkipped: React.MutableRefObject +} + +/** + * Hook to handle messages from the extension. + * + * Processes three types of messages: + * 1. "say" messages - Information from the agent (text, tool output, reasoning) + * 2. "ask" messages - Requests for user input (approvals, followup questions) + * 3. Extension state updates - Mode changes, task history, file search results + * + * Transforms ClineMessage format to TUIMessage format and updates the store. + */ +export function useMessageHandlers({ verbose, nonInteractive }: UseMessageHandlersOptions): UseMessageHandlersReturn { + const { + addMessage, + setPendingAsk, + setComplete, + setLoading, + setHasStartedTask, + setFileSearchResults, + setAllSlashCommands, + setAvailableModes, + setCurrentMode, + setTokenUsage, + setRouterModels, + setTaskHistory, + currentTodos, + setTodos, + } = useCLIStore() + + // Track seen message timestamps to filter duplicates and the prompt echo + const seenMessageIds = useRef>(new Set()) + const firstTextMessageSkipped = useRef(false) + + // Track pending command for injecting into command_output toolData + const pendingCommandRef = useRef(null) + + /** + * Map extension "say" messages to TUI messages + */ + const handleSayMessage = useCallback( + (ts: number, say: ClineSay, text: string, partial: boolean) => { + const messageId = ts.toString() + const isResuming = useCLIStore.getState().isResumingTask + + if (say === "checkpoint_saved") { + return + } + + if (say === "api_req_started" && !verbose) { + return + } + + if (say === "user_feedback") { + seenMessageIds.current.add(messageId) + return + } + + // Skip first text message ONLY for new tasks, not resumed tasks + // When resuming, we want to show all historical messages including the first one + if (say === "text" && !firstTextMessageSkipped.current && !isResuming) { + firstTextMessageSkipped.current = true + seenMessageIds.current.add(messageId) + return + } + + if (seenMessageIds.current.has(messageId) && !partial) { + return + } + + let role: TUIMessage["role"] = "assistant" + let toolName: string | undefined + let toolDisplayName: string | undefined + let toolDisplayOutput: string | undefined + let toolData: ToolData | undefined + + if (say === "command_output") { + role = "tool" + toolName = "execute_command" + toolDisplayName = "bash" + toolDisplayOutput = text + const trackedCommand = pendingCommandRef.current + toolInspectorLog("say:command_output", { ts, trackedCommand, outputLength: text?.length }) + toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text } + pendingCommandRef.current = null + } else if (say === "reasoning") { + role = "thinking" + } + + seenMessageIds.current.add(messageId) + + addMessage({ + id: messageId, + role, + content: text || "", + toolName, + toolDisplayName, + toolDisplayOutput, + partial, + originalType: say, + toolData, + }) + }, + [addMessage, verbose], + ) + + /** + * Handle extension "ask" messages + */ + const handleAskMessage = useCallback( + (ts: number, ask: ClineAsk, text: string, partial: boolean) => { + const messageId = ts.toString() + + if (partial) { + return + } + + if (seenMessageIds.current.has(messageId)) { + return + } + + if (ask === "command_output") { + seenMessageIds.current.add(messageId) + return + } + + // Handle resume_task and resume_completed_task - stop loading and show text input + // Do not set pendingAsk - just stop loading so user sees normal input to type new message + if (ask === "resume_task" || ask === "resume_completed_task") { + seenMessageIds.current.add(messageId) + setLoading(false) + // Mark that a task has been started so subsequent messages continue the task + // (instead of starting a brand new task via runTask) + setHasStartedTask(true) + // Clear the resuming flag since we're now ready for interaction + // Historical messages should already be displayed from state processing + useCLIStore.getState().setIsResumingTask(false) + // Do not set pendingAsk - let the normal text input appear + return + } + + if (ask === "completion_result") { + seenMessageIds.current.add(messageId) + setComplete(true) + setLoading(false) + + // Parse the completion result and add a message for CompletionTool to render + try { + const completionInfo = JSON.parse(text) as Record + const toolData: ToolData = { + tool: "attempt_completion", + result: completionInfo.result as string | undefined, + content: completionInfo.result as string | undefined, + } + + addMessage({ + id: messageId, + role: "tool", + content: text, + toolName: "attempt_completion", + toolDisplayName: "Task Complete", + toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }), + originalType: ask, + toolData, + }) + } catch { + // If parsing fails, still add a basic completion message + addMessage({ + id: messageId, + role: "tool", + content: text || "Task completed", + toolName: "attempt_completion", + toolDisplayName: "Task Complete", + toolDisplayOutput: "✅ Task completed", + originalType: ask, + toolData: { + tool: "attempt_completion", + content: text, + }, + }) + } + return + } + + // Track pending command BEFORE nonInteractive handling + // This ensures we capture the command text for later injection into command_output toolData + if (ask === "command") { + toolInspectorLog("ask:command:tracking", { ts, text }) + pendingCommandRef.current = text + } + + if (nonInteractive && ask !== "followup") { + seenMessageIds.current.add(messageId) + + if (ask === "tool") { + let toolName: string | undefined + let toolDisplayName: string | undefined + let toolDisplayOutput: string | undefined + let formattedContent = text || "" + let toolData: ToolData | undefined + let todos: TodoItem[] | undefined + let previousTodos: TodoItem[] | undefined + + try { + const toolInfo = JSON.parse(text) as Record + + // Log tool payload for inspection (nonInteractive ask) + toolInspectorLog("ask:tool:nonInteractive", { + ts, + rawText: text, + parsedToolInfo: toolInfo, + partial, + }) + + toolName = toolInfo.tool as string + toolDisplayName = toolInfo.tool as string + toolDisplayOutput = formatToolOutput(toolInfo) + formattedContent = formatToolAskMessage(toolInfo) + // Extract structured toolData for rich rendering + toolData = extractToolData(toolInfo) + + // Special handling for update_todo_list tool - extract todos + if (toolName === "update_todo_list" || toolName === "updateTodoList") { + const parsedTodos = parseTodosFromToolInfo(toolInfo) + if (parsedTodos && parsedTodos.length > 0) { + todos = parsedTodos + // Capture previous todos before updating global state + previousTodos = [...currentTodos] + setTodos(parsedTodos) + } + } + } catch { + // Use raw text if not valid JSON + } + + addMessage({ + id: messageId, + role: "tool", + content: formattedContent, + toolName, + toolDisplayName, + toolDisplayOutput, + originalType: ask, + toolData, + todos, + previousTodos, + }) + } else { + addMessage({ + id: messageId, + role: "assistant", + content: text || "", + originalType: ask, + }) + } + return + } + + let suggestions: Array<{ answer: string; mode?: string | null }> | undefined + let questionText = text + + if (ask === "followup") { + try { + const data = JSON.parse(text) + questionText = data.question || text + suggestions = Array.isArray(data.suggest) ? data.suggest : undefined + } catch { + // Use raw text + } + } else if (ask === "tool") { + try { + const toolInfo = JSON.parse(text) as Record + + // Log tool payload for inspection (interactive ask) + toolInspectorLog("ask:tool:interactive", { + ts, + rawText: text, + parsedToolInfo: toolInfo, + partial, + }) + + questionText = formatToolAskMessage(toolInfo) + } catch { + // Use raw text if not valid JSON + } + } + // Note: ask === "command" is handled above before the nonInteractive block + + seenMessageIds.current.add(messageId) + + setPendingAsk({ + id: messageId, + type: ask, + content: questionText, + suggestions, + }) + }, + [addMessage, setPendingAsk, setComplete, setLoading, setHasStartedTask, nonInteractive, currentTodos, setTodos], + ) + + /** + * Handle all extension messages + */ + const handleExtensionMessage = useCallback( + (msg: ExtensionMessage) => { + if (msg.type === "state") { + const state = msg.state + + if (!state) { + return + } + + // Extract and update current mode from state + const newMode = state.mode + + if (newMode) { + setCurrentMode(newMode) + } + + // Extract and update task history from state + const newTaskHistory = state.taskHistory + + if (newTaskHistory && Array.isArray(newTaskHistory)) { + setTaskHistory(newTaskHistory) + } + + const clineMessages = state.clineMessages + + if (clineMessages) { + for (const clineMsg of clineMessages) { + const ts = clineMsg.ts + const type = clineMsg.type + const say = clineMsg.say + const ask = clineMsg.ask + const text = clineMsg.text || "" + const partial = clineMsg.partial || false + + if (type === "say" && say) { + handleSayMessage(ts, say, text, partial) + } else if (type === "ask" && ask) { + handleAskMessage(ts, ask, text, partial) + } + } + + // Compute token usage metrics from clineMessages + // Skip first message (task prompt) as per webview UI pattern + if (clineMessages.length > 1) { + const processed = consolidateApiRequests( + consolidateCommands(clineMessages.slice(1) as ClineMessage[]), + ) + + const metrics = consolidateTokenUsage(processed) + setTokenUsage(metrics) + } + } + + // After processing state, clear the resuming flag if it was set + // This ensures the flag is cleared even if no resume_task ask message is received + if (useCLIStore.getState().isResumingTask) { + useCLIStore.getState().setIsResumingTask(false) + } + } else if (msg.type === "messageUpdated") { + const clineMessage = msg.clineMessage + + if (!clineMessage) { + return + } + + const ts = clineMessage.ts + const type = clineMessage.type + const say = clineMessage.say + const ask = clineMessage.ask + const text = clineMessage.text || "" + const partial = clineMessage.partial || false + + if (type === "say" && say) { + handleSayMessage(ts, say, text, partial) + } else if (type === "ask" && ask) { + handleAskMessage(ts, ask, text, partial) + } + } else if (msg.type === "fileSearchResults") { + setFileSearchResults((msg.results as FileResult[]) || []) + } else if (msg.type === "commands") { + setAllSlashCommands((msg.commands as SlashCommandResult[]) || []) + } else if (msg.type === "modes") { + setAvailableModes((msg.modes as ModeResult[]) || []) + } else if (msg.type === "routerModels") { + if (msg.routerModels) { + setRouterModels(msg.routerModels) + } + } + }, + [ + handleSayMessage, + handleAskMessage, + setFileSearchResults, + setAllSlashCommands, + setAvailableModes, + setCurrentMode, + setTokenUsage, + setRouterModels, + setTaskHistory, + ], + ) + + return { + handleExtensionMessage, + seenMessageIds, + pendingCommandRef, + firstTextMessageSkipped, + } +} diff --git a/apps/cli/src/ui/hooks/usePickerHandlers.ts b/apps/cli/src/ui/hooks/usePickerHandlers.ts new file mode 100644 index 0000000000..65b13bc608 --- /dev/null +++ b/apps/cli/src/ui/hooks/usePickerHandlers.ts @@ -0,0 +1,171 @@ +import { useCallback } from "react" +import type { WebviewMessage } from "@roo-code/types" + +import type { + AutocompletePickerState, + AutocompleteInputHandle, + ModeResult, + HistoryResult, +} from "../components/autocomplete/index.js" +import { useCLIStore } from "../store.js" +import { useUIStateStore } from "../stores/uiStateStore.js" + +export interface UsePickerHandlersOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + autocompleteRef: React.RefObject> + // eslint-disable-next-line @typescript-eslint/no-explicit-any + followupAutocompleteRef: React.RefObject> + sendToExtension: ((msg: WebviewMessage) => void) | null + showInfo: (msg: string, duration?: number) => void + seenMessageIds: React.MutableRefObject> + firstTextMessageSkipped: React.MutableRefObject +} + +export interface UsePickerHandlersReturn { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handlePickerStateChange: (state: AutocompletePickerState) => void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handlePickerSelect: (item: any) => void + handlePickerClose: () => void + handlePickerIndexChange: (index: number) => void +} + +/** + * Hook to handle autocomplete picker interactions. + * + * Responsibilities: + * - Handle picker state changes from AutocompleteInput + * - Handle item selection (special handling for modes and history items) + * - Handle mode switching via picker + * - Handle task switching via history picker + * - Handle picker close and index change + */ +export function usePickerHandlers({ + autocompleteRef, + followupAutocompleteRef, + sendToExtension, + showInfo, + seenMessageIds, + firstTextMessageSkipped, +}: UsePickerHandlersOptions): UsePickerHandlersReturn { + const { isLoading, currentTaskId, setCurrentTaskId } = useCLIStore() + const { pickerState, setPickerState } = useUIStateStore() + + /** + * Handle picker state changes from AutocompleteInput + */ + const handlePickerStateChange = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (state: AutocompletePickerState) => { + setPickerState(state) + }, + [setPickerState], + ) + + /** + * Handle item selection from external PickerSelect + */ + const handlePickerSelect = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (item: any) => { + // Check if this is a mode selection + if (pickerState.activeTrigger?.id === "mode" && item && typeof item === "object" && "slug" in item) { + const modeItem = item as ModeResult + + // Send mode change message to extension + if (sendToExtension) { + sendToExtension({ type: "switchMode", mode: modeItem.slug }) + } + + // Close the picker + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + } + // Check if this is a history item selection + else if (pickerState.activeTrigger?.id === "history" && item && typeof item === "object" && "id" in item) { + const historyItem = item as HistoryResult + + // Don't allow task switching while a task is in progress (loading) + if (isLoading) { + showInfo("Cannot switch tasks while task is in progress", 2000) + // Close the picker + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + return + } + + // If selecting the same task that's already loaded, just close the picker + if (historyItem.id === currentTaskId) { + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + return + } + + // Send showTaskWithId message to extension to resume the task + if (sendToExtension) { + // Use selective reset that preserves global state (taskHistory, modes, commands) + useCLIStore.getState().resetForTaskSwitch() + // Set the resuming flag so message handlers know we're resuming + // This prevents skipping the first text message (which is historical) + useCLIStore.getState().setIsResumingTask(true) + // Track which task we're switching to + setCurrentTaskId(historyItem.id) + // Reset refs to avoid stale state across task switches + seenMessageIds.current.clear() + firstTextMessageSkipped.current = false + + // Send message to resume the selected task + // This triggers createTaskWithHistoryItem -> postStateToWebview + // which includes clineMessages and handles mode restoration + sendToExtension({ type: "showTaskWithId", text: historyItem.id }) + } + + // Close the picker + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + } else { + // Handle other item selections normally + autocompleteRef.current?.handleItemSelect(item) + followupAutocompleteRef.current?.handleItemSelect(item) + } + }, + [ + pickerState.activeTrigger, + isLoading, + showInfo, + currentTaskId, + setCurrentTaskId, + sendToExtension, + autocompleteRef, + followupAutocompleteRef, + seenMessageIds, + firstTextMessageSkipped, + ], + ) + + /** + * Handle picker close from external PickerSelect + */ + const handlePickerClose = useCallback(() => { + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + }, [autocompleteRef, followupAutocompleteRef]) + + /** + * Handle picker index change from external PickerSelect + */ + const handlePickerIndexChange = useCallback( + (index: number) => { + autocompleteRef.current?.handleIndexChange(index) + followupAutocompleteRef.current?.handleIndexChange(index) + }, + [autocompleteRef, followupAutocompleteRef], + ) + + return { + handlePickerStateChange, + handlePickerSelect, + handlePickerClose, + handlePickerIndexChange, + } +} diff --git a/apps/cli/src/ui/hooks/useTaskSubmit.ts b/apps/cli/src/ui/hooks/useTaskSubmit.ts new file mode 100644 index 0000000000..3dd8ac3e7c --- /dev/null +++ b/apps/cli/src/ui/hooks/useTaskSubmit.ts @@ -0,0 +1,181 @@ +import { useCallback } from "react" +import { randomUUID } from "crypto" +import type { WebviewMessage } from "@roo-code/types" + +import { getGlobalCommand } from "../../utils/globalCommands.js" +import { useCLIStore } from "../store.js" +import { useUIStateStore } from "../stores/uiStateStore.js" + +export interface UseTaskSubmitOptions { + sendToExtension: ((msg: WebviewMessage) => void) | null + runTask: ((prompt: string) => Promise) | null + seenMessageIds: React.MutableRefObject> + firstTextMessageSkipped: React.MutableRefObject +} + +export interface UseTaskSubmitReturn { + handleSubmit: (text: string) => Promise + handleApprove: () => void + handleReject: () => void +} + +/** + * Hook to handle task submission, user responses, and approvals. + * + * Responsibilities: + * - Process user message submissions + * - Detect and handle global commands (like /new) + * - Handle pending ask responses + * - Start new tasks or continue existing ones + * - Handle Y/N approval responses + */ +export function useTaskSubmit({ + sendToExtension, + runTask, + seenMessageIds, + firstTextMessageSkipped, +}: UseTaskSubmitOptions): UseTaskSubmitReturn { + const { + pendingAsk, + hasStartedTask, + isComplete, + addMessage, + setPendingAsk, + setHasStartedTask, + setLoading, + setComplete, + setError, + } = useCLIStore() + + const { setShowCustomInput, setIsTransitioningToCustomInput } = useUIStateStore() + + /** + * Handle user text submission (from input or followup question) + */ + const handleSubmit = useCallback( + async (text: string) => { + if (!sendToExtension || !text.trim()) { + return + } + + const trimmedText = text.trim() + + if (trimmedText === "__CUSTOM__") { + return + } + + // Check for CLI global action commands (e.g., /new) + if (trimmedText.startsWith("/")) { + const commandMatch = trimmedText.match(/^\/(\w+)(?:\s|$)/) + + if (commandMatch && commandMatch[1]) { + const globalCommand = getGlobalCommand(commandMatch[1]) + + if (globalCommand?.action === "clearTask") { + // Reset CLI state and send clearTask to extension + useCLIStore.getState().reset() + // Reset component-level refs to avoid stale message tracking + seenMessageIds.current.clear() + firstTextMessageSkipped.current = false + sendToExtension({ type: "clearTask" }) + // Re-request state, commands and modes since reset() cleared them + sendToExtension({ type: "webviewDidLaunch" }) + sendToExtension({ type: "requestCommands" }) + sendToExtension({ type: "requestModes" }) + return + } + } + } + + if (pendingAsk) { + addMessage({ id: randomUUID(), role: "user", content: trimmedText }) + + sendToExtension({ + type: "askResponse", + askResponse: "messageResponse", + text: trimmedText, + }) + + setPendingAsk(null) + setShowCustomInput(false) + setIsTransitioningToCustomInput(false) + setLoading(true) + } else if (!hasStartedTask) { + setHasStartedTask(true) + setLoading(true) + addMessage({ id: randomUUID(), role: "user", content: trimmedText }) + + try { + if (runTask) { + await runTask(trimmedText) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setLoading(false) + } + } else { + if (isComplete) { + setComplete(false) + } + + setLoading(true) + addMessage({ id: randomUUID(), role: "user", content: trimmedText }) + + sendToExtension({ + type: "askResponse", + askResponse: "messageResponse", + text: trimmedText, + }) + } + }, + [ + sendToExtension, + runTask, + pendingAsk, + hasStartedTask, + isComplete, + addMessage, + setPendingAsk, + setHasStartedTask, + setLoading, + setComplete, + setError, + setShowCustomInput, + setIsTransitioningToCustomInput, + seenMessageIds, + firstTextMessageSkipped, + ], + ) + + /** + * Handle approval (Y key) + */ + const handleApprove = useCallback(() => { + if (!sendToExtension) { + return + } + + sendToExtension({ type: "askResponse", askResponse: "yesButtonClicked" }) + setPendingAsk(null) + setLoading(true) + }, [sendToExtension, setPendingAsk, setLoading]) + + /** + * Handle rejection (N key) + */ + const handleReject = useCallback(() => { + if (!sendToExtension) { + return + } + + sendToExtension({ type: "askResponse", askResponse: "noButtonClicked" }) + setPendingAsk(null) + setLoading(true) + }, [sendToExtension, setPendingAsk, setLoading]) + + return { + handleSubmit, + handleApprove, + handleReject, + } +} diff --git a/apps/cli/src/ui/stores/uiStateStore.ts b/apps/cli/src/ui/stores/uiStateStore.ts new file mode 100644 index 0000000000..d7abe59845 --- /dev/null +++ b/apps/cli/src/ui/stores/uiStateStore.ts @@ -0,0 +1,87 @@ +import { create } from "zustand" +import type { AutocompletePickerState } from "../components/autocomplete/types.js" + +/** + * UI-specific state that doesn't need to persist across task switches. + * This separates UI state from task/message state in the main CLI store. + */ +interface UIState { + // Exit handling state + showExitHint: boolean + pendingExit: boolean + + // Countdown timer for auto-accepting followup questions + countdownSeconds: number | null + + // Custom input mode for followup questions + showCustomInput: boolean + isTransitioningToCustomInput: boolean + + // Focus management for scroll area vs input + manualFocus: "scroll" | "input" | null + + // TODO viewer overlay + showTodoViewer: boolean + + // Autocomplete picker state + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pickerState: AutocompletePickerState +} + +interface UIActions { + // Exit handling actions + setShowExitHint: (show: boolean) => void + setPendingExit: (pending: boolean) => void + + // Countdown timer actions + setCountdownSeconds: (seconds: number | null) => void + + // Custom input mode actions + setShowCustomInput: (show: boolean) => void + setIsTransitioningToCustomInput: (transitioning: boolean) => void + + // Focus management actions + setManualFocus: (focus: "scroll" | "input" | null) => void + + // TODO viewer actions + setShowTodoViewer: (show: boolean) => void + + // Picker state actions + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setPickerState: (state: AutocompletePickerState) => void + + // Reset all UI state to defaults + resetUIState: () => void +} + +const initialState: UIState = { + showExitHint: false, + pendingExit: false, + countdownSeconds: null, + showCustomInput: false, + isTransitioningToCustomInput: false, + manualFocus: null, + showTodoViewer: false, + pickerState: { + activeTrigger: null, + results: [], + selectedIndex: 0, + isOpen: false, + isLoading: false, + triggerInfo: null, + }, +} + +export const useUIStateStore = create((set) => ({ + ...initialState, + + setShowExitHint: (show) => set({ showExitHint: show }), + setPendingExit: (pending) => set({ pendingExit: pending }), + setCountdownSeconds: (seconds) => set({ countdownSeconds: seconds }), + setShowCustomInput: (show) => set({ showCustomInput: show }), + setIsTransitioningToCustomInput: (transitioning) => set({ isTransitioningToCustomInput: transitioning }), + setManualFocus: (focus) => set({ manualFocus: focus }), + setShowTodoViewer: (show) => set({ showTodoViewer: show }), + setPickerState: (state) => set({ pickerState: state }), + resetUIState: () => set(initialState), +})) diff --git a/apps/cli/src/ui/utils/index.ts b/apps/cli/src/ui/utils/index.ts new file mode 100644 index 0000000000..4f30c1fe5c --- /dev/null +++ b/apps/cli/src/ui/utils/index.ts @@ -0,0 +1,9 @@ +export { + extractToolData, + formatToolOutput, + formatToolAskMessage, + parseTodosFromToolInfo, + parseMarkdownChecklist, +} from "./toolDataUtils.js" + +export { getView } from "./viewUtils.js" diff --git a/apps/cli/src/ui/utils/toolDataUtils.ts b/apps/cli/src/ui/utils/toolDataUtils.ts new file mode 100644 index 0000000000..f4d7ec9792 --- /dev/null +++ b/apps/cli/src/ui/utils/toolDataUtils.ts @@ -0,0 +1,345 @@ +import type { TodoItem } from "@roo-code/types" +import type { ToolData } from "../types.js" + +/** + * Extract structured ToolData from parsed tool JSON + * This provides rich data for tool-specific renderers + */ +export function extractToolData(toolInfo: Record): ToolData { + const toolName = (toolInfo.tool as string) || "unknown" + + // Base tool data with common fields + const toolData: ToolData = { + tool: toolName, + path: toolInfo.path as string | undefined, + isOutsideWorkspace: toolInfo.isOutsideWorkspace as boolean | undefined, + isProtected: toolInfo.isProtected as boolean | undefined, + content: toolInfo.content as string | undefined, + reason: toolInfo.reason as string | undefined, + } + + // Extract diff-related fields + if (toolInfo.diff !== undefined) { + toolData.diff = toolInfo.diff as string + } + if (toolInfo.diffStats !== undefined) { + const stats = toolInfo.diffStats as { added?: number; removed?: number } + if (typeof stats.added === "number" && typeof stats.removed === "number") { + toolData.diffStats = { added: stats.added, removed: stats.removed } + } + } + + // Extract search-related fields + if (toolInfo.regex !== undefined) { + toolData.regex = toolInfo.regex as string + } + if (toolInfo.filePattern !== undefined) { + toolData.filePattern = toolInfo.filePattern as string + } + if (toolInfo.query !== undefined) { + toolData.query = toolInfo.query as string + } + + // Extract mode-related fields + if (toolInfo.mode !== undefined) { + toolData.mode = toolInfo.mode as string + } + if (toolInfo.mode_slug !== undefined) { + toolData.mode = toolInfo.mode_slug as string + } + + // Extract command-related fields + if (toolInfo.command !== undefined) { + toolData.command = toolInfo.command as string + } + if (toolInfo.output !== undefined) { + toolData.output = toolInfo.output as string + } + + // Extract browser-related fields + if (toolInfo.action !== undefined) { + toolData.action = toolInfo.action as string + } + if (toolInfo.url !== undefined) { + toolData.url = toolInfo.url as string + } + if (toolInfo.coordinate !== undefined) { + toolData.coordinate = toolInfo.coordinate as string + } + + // Extract batch file operations + if (Array.isArray(toolInfo.files)) { + toolData.batchFiles = (toolInfo.files as Array>).map((f) => ({ + path: (f.path as string) || "", + lineSnippet: f.lineSnippet as string | undefined, + isOutsideWorkspace: f.isOutsideWorkspace as boolean | undefined, + key: f.key as string | undefined, + content: f.content as string | undefined, + })) + } + + // Extract batch diff operations + if (Array.isArray(toolInfo.batchDiffs)) { + toolData.batchDiffs = (toolInfo.batchDiffs as Array>).map((d) => ({ + path: (d.path as string) || "", + changeCount: d.changeCount as number | undefined, + key: d.key as string | undefined, + content: d.content as string | undefined, + diffStats: d.diffStats as { added: number; removed: number } | undefined, + diffs: d.diffs as Array<{ content: string; startLine?: number }> | undefined, + })) + } + + // Extract question/completion fields + if (toolInfo.question !== undefined) { + toolData.question = toolInfo.question as string + } + if (toolInfo.result !== undefined) { + toolData.result = toolInfo.result as string + } + + // Extract additional display hints + if (toolInfo.lineNumber !== undefined) { + toolData.lineNumber = toolInfo.lineNumber as number + } + if (toolInfo.additionalFileCount !== undefined) { + toolData.additionalFileCount = toolInfo.additionalFileCount as number + } + + return toolData +} + +/** + * Format tool output for display (used in the message body, header shows tool name separately) + */ +export function formatToolOutput(toolInfo: Record): string { + const toolName = (toolInfo.tool as string) || "unknown" + + switch (toolName) { + case "switchMode": { + const mode = (toolInfo.mode as string) || "unknown" + const reason = toolInfo.reason as string + return `→ ${mode} mode${reason ? `\n ${reason}` : ""}` + } + + case "switch_mode": { + const mode = (toolInfo.mode_slug as string) || (toolInfo.mode as string) || "unknown" + const reason = toolInfo.reason as string + return `→ ${mode} mode${reason ? `\n ${reason}` : ""}` + } + + case "execute_command": { + const command = toolInfo.command as string + return `$ ${command || "(no command)"}` + } + + case "read_file": { + const files = toolInfo.files as Array<{ path: string }> | undefined + const path = toolInfo.path as string + if (files && files.length > 0) { + return files.map((f) => `📄 ${f.path}`).join("\n") + } + return `📄 ${path || "(no path)"}` + } + + case "write_to_file": { + const writePath = toolInfo.path as string + return `📝 ${writePath || "(no path)"}` + } + + case "apply_diff": { + const diffPath = toolInfo.path as string + return `✏️ ${diffPath || "(no path)"}` + } + + case "search_files": { + const searchPath = toolInfo.path as string + const regex = toolInfo.regex as string + return `🔍 "${regex}" in ${searchPath || "."}` + } + + case "list_files": { + const listPath = toolInfo.path as string + const recursive = toolInfo.recursive as boolean + return `📁 ${listPath || "."}${recursive ? " (recursive)" : ""}` + } + + case "browser_action": { + const action = toolInfo.action as string + const url = toolInfo.url as string + return `🌐 ${action || "action"}${url ? `: ${url}` : ""}` + } + + case "attempt_completion": { + const result = toolInfo.result as string + if (result) { + const truncated = result.length > 100 ? result.substring(0, 100) + "..." : result + return `✅ ${truncated}` + } + return "✅ Task completed" + } + + case "ask_followup_question": { + const question = toolInfo.question as string + return `❓ ${question || "(no question)"}` + } + + case "new_task": { + const taskMode = toolInfo.mode as string + return `📋 Creating subtask${taskMode ? ` in ${taskMode} mode` : ""}` + } + + case "update_todo_list": + case "updateTodoList": { + // Special marker - actual rendering is handled by TodoChangeDisplay component + return "☑ TODO list updated" + } + + default: { + const params = Object.entries(toolInfo) + .filter(([key]) => key !== "tool") + .map(([key, value]) => { + const displayValue = typeof value === "string" ? value : JSON.stringify(value) + const truncated = displayValue.length > 100 ? displayValue.substring(0, 100) + "..." : displayValue + return `${key}: ${truncated}` + }) + .join("\n") + return params || "(no parameters)" + } + } +} + +/** + * Format tool ask message for user approval prompt + */ +export function formatToolAskMessage(toolInfo: Record): string { + const toolName = (toolInfo.tool as string) || "unknown" + + switch (toolName) { + case "switchMode": + case "switch_mode": { + const mode = (toolInfo.mode as string) || (toolInfo.mode_slug as string) || "unknown" + const reason = toolInfo.reason as string + return `Switch to ${mode} mode?${reason ? `\nReason: ${reason}` : ""}` + } + + case "execute_command": { + const command = toolInfo.command as string + return `Run command?\n$ ${command || "(no command)"}` + } + + case "read_file": { + const files = toolInfo.files as Array<{ path: string }> | undefined + const path = toolInfo.path as string + if (files && files.length > 0) { + return `Read ${files.length} file(s)?\n${files.map((f) => ` ${f.path}`).join("\n")}` + } + return `Read file: ${path || "(no path)"}` + } + + case "write_to_file": { + const writePath = toolInfo.path as string + return `Write to file: ${writePath || "(no path)"}` + } + + case "apply_diff": { + const diffPath = toolInfo.path as string + return `Apply changes to: ${diffPath || "(no path)"}` + } + + case "browser_action": { + const action = toolInfo.action as string + const url = toolInfo.url as string + return `Browser: ${action || "action"}${url ? ` - ${url}` : ""}` + } + + default: { + const params = Object.entries(toolInfo) + .filter(([key]) => key !== "tool") + .map(([key, value]) => { + const displayValue = typeof value === "string" ? value : JSON.stringify(value) + const truncated = displayValue.length > 80 ? displayValue.substring(0, 80) + "..." : displayValue + return ` ${key}: ${truncated}` + }) + .join("\n") + return `${toolName}${params ? `\n${params}` : ""}` + } + } +} + +/** + * Parse TODO items from tool info + * Handles both array format and markdown checklist string format + */ +export function parseTodosFromToolInfo(toolInfo: Record): TodoItem[] | null { + // Try to get todos directly as an array + const todosArray = toolInfo.todos as unknown[] | undefined + if (Array.isArray(todosArray)) { + return todosArray + .map((item, index) => { + if (typeof item === "object" && item !== null) { + const todo = item as Record + return { + id: (todo.id as string) || `todo-${index}`, + content: (todo.content as string) || "", + status: ((todo.status as string) || "pending") as TodoItem["status"], + } + } + return null + }) + .filter((item): item is TodoItem => item !== null) + } + + // Try to parse markdown checklist format from todos string + const todosString = toolInfo.todos as string | undefined + if (typeof todosString === "string") { + return parseMarkdownChecklist(todosString) + } + + return null +} + +/** + * Parse a markdown checklist string into TodoItem array + * Format: + * [ ] pending item + * [-] in progress item + * [x] completed item + */ +export function parseMarkdownChecklist(markdown: string): TodoItem[] { + const lines = markdown.split("\n") + const todos: TodoItem[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (!line) { + continue + } + + const trimmedLine = line.trim() + + if (!trimmedLine) { + continue + } + + // Match markdown checkbox patterns + const checkboxMatch = trimmedLine.match(/^\[([x\-\s])\]\s*(.+)$/i) + + if (checkboxMatch) { + const statusChar = checkboxMatch[1] ?? " " + const content = checkboxMatch[2] ?? "" + let status: TodoItem["status"] = "pending" + + if (statusChar.toLowerCase() === "x") { + status = "completed" + } else if (statusChar === "-") { + status = "in_progress" + } + + todos.push({ id: `todo-${i}`, content: content.trim(), status }) + } + } + + return todos +} diff --git a/apps/cli/src/ui/utils/viewUtils.ts b/apps/cli/src/ui/utils/viewUtils.ts new file mode 100644 index 0000000000..0e2ea07c8b --- /dev/null +++ b/apps/cli/src/ui/utils/viewUtils.ts @@ -0,0 +1,52 @@ +import type { TUIMessage, PendingAsk, View } from "../types.js" + +/** + * Determine the current view state based on messages and pending asks + */ +export function getView(messages: TUIMessage[], pendingAsk: PendingAsk | null, isLoading: boolean): View { + // If there's a pending ask requiring text input, show input + if (pendingAsk?.type === "followup") { + return "UserInput" + } + + // If there's any pending ask (approval), don't show thinking + if (pendingAsk) { + return "UserInput" + } + + // Initial state or empty - awaiting user input + if (messages.length === 0) { + return "UserInput" + } + + const lastMessage = messages.at(-1) + if (!lastMessage) { + return "UserInput" + } + + // User just sent a message, waiting for response + if (lastMessage.role === "user") { + return "AgentResponse" + } + + // Assistant replied + if (lastMessage.role === "assistant") { + if (lastMessage.hasPendingToolCalls) { + return "ToolUse" + } + + // If loading, still waiting for more + if (isLoading) { + return "AgentResponse" + } + + return "UserInput" + } + + // Tool result received, waiting for next assistant response + if (lastMessage.role === "tool") { + return "AgentResponse" + } + + return "Default" +}