Roo-Code/apps/cli/src/ui/App.tsx
2026-01-07 02:13:01 -08:00

1426 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 only message-utils to avoid custom-tools dependencies (execa/child_process)
import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils"
import type { ClineMessage, TodoItem } from "@roo-code/types"
import { useCLIStore } from "./store.js"
import { getContextWindow } from "../utils/getContextWindow.js"
import Header from "./components/Header.js"
import ChatHistoryItem from "./components/ChatHistoryItem.js"
import LoadingText from "./components/LoadingText.js"
import {
AutocompleteInput,
PickerSelect,
createFileTrigger,
createSlashCommandTrigger,
createModeTrigger,
createHelpTrigger,
toFileResult,
toSlashCommandResult,
toModeResult,
type AutocompleteInputHandle,
type AutocompletePickerState,
type AutocompleteTrigger,
type FileResult,
type SlashCommandResult as SlashCommandItem,
type ModeResult as ModeItem,
} from "./components/autocomplete/index.js"
import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js"
import ScrollIndicator from "./components/ScrollIndicator.js"
import { TerminalSizeProvider, useTerminalSize } from "./hooks/TerminalSizeContext.js"
import * as theme from "./utils/theme.js"
import type {
AppProps,
TUIMessage,
PendingAsk,
SayType,
AskType,
View,
FileSearchResult,
SlashCommandResult,
ModeResult,
} from "./types.js"
import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../globalCommands.js"
// Layout constants
const PICKER_HEIGHT = 10 // Max height for picker when open
/**
* Interface for the extension host that the TUI interacts with
*/
interface ExtensionHostInterface extends EventEmitter {
activate(): Promise<void>
runTask(prompt: string): Promise<void>
sendToExtension(message: unknown): void
dispose(): Promise<void>
}
export interface TUIAppProps extends AppProps {
/** Extension host factory - allows dependency injection for testing */
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
}
interface ExtensionHostOptions {
mode: string
reasoningEffort?: string
apiProvider: string
apiKey: string
model: string
workspacePath: string
extensionPath: string
verbose: boolean
quiet: boolean
nonInteractive: boolean
disableOutput: 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 <Text color={color}>{"─".repeat(columns)}</Text>
}
/**
* Inner App component that uses the terminal size context
*/
function AppInner({
initialPrompt,
workspacePath,
extensionPath,
apiProvider,
apiKey,
model,
mode,
nonInteractive,
verbose,
debug,
exitOnComplete,
reasoningEffort,
createExtensionHost,
version,
}: TUIAppProps) {
const { exit } = useApp()
const {
messages,
pendingAsk,
isLoading,
isComplete,
hasStartedTask,
error,
addMessage,
setPendingAsk,
setLoading,
setComplete,
setHasStartedTask,
setError,
fileSearchResults,
allSlashCommands,
availableModes,
setFileSearchResults,
setAllSlashCommands,
setAvailableModes,
currentMode,
setCurrentMode,
tokenUsage,
routerModels,
apiConfiguration,
setTokenUsage,
setRouterModels,
setApiConfiguration,
currentTodos,
setTodos,
} = useCLIStore()
// Compute context window from router models and API configuration
const contextWindow = useMemo(
() => getContextWindow(routerModels, apiConfiguration),
[routerModels, apiConfiguration],
)
const hostRef = useRef<ExtensionHostInterface | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const autocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const followupAutocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
// Stable refs for autocomplete data - prevents useMemo from recreating triggers on every data change
const fileSearchResultsRef = useRef(fileSearchResults)
const allSlashCommandsRef = useRef(allSlashCommands)
const availableModesRef = useRef(availableModes)
// Keep refs in sync with current state
useEffect(() => {
fileSearchResultsRef.current = fileSearchResults
}, [fileSearchResults])
useEffect(() => {
allSlashCommandsRef.current = allSlashCommands
}, [allSlashCommands])
useEffect(() => {
availableModesRef.current = availableModes
}, [availableModes])
// Track seen message timestamps to filter duplicates and the prompt echo
const seenMessageIds = useRef<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
// Track Ctrl+C presses for "press again to exit" behavior
const [showExitHint, setShowExitHint] = useState(false)
const exitHintTimeout = useRef<NodeJS.Timeout | null>(null)
const pendingExit = useRef(false)
// 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)
// Autocomplete picker state (received from AutocompleteInput via callback)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [pickerState, setPickerState] = useState<AutocompletePickerState<any>>({
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 })
const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom()
// 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
}, [messages])
// Scroll to bottom when new messages arrive (if auto-scroll is enabled)
const prevMessageCount = useRef(messages.length)
useEffect(() => {
if (messages.length > prevMessageCount.current && scrollState.isAtBottom) {
scrollToBottom()
}
prevMessageCount.current = messages.length
}, [messages.length, scrollState.isAtBottom, scrollToBottom])
// Handle scroll state changes from ScrollArea
const handleScroll = useCallback((scrollTop: number, maxScroll: number, isAtBottom: boolean) => {
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,
})
}, [])
// Create autocomplete triggers
// Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult)
// IMPORTANT: We use refs here to avoid recreating triggers every time data changes.
// This prevents the UI flash caused by: data change -> memo recreation -> re-render with stale state
// The getResults/getCommands/getModes callbacks always read from refs to get fresh data.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const autocompleteTriggers = useMemo((): AutocompleteTrigger<any>[] => {
const fileTrigger = createFileTrigger({
onSearch: handleFileSearch,
getResults: () => {
const results = fileSearchResultsRef.current
return results.map(toFileResult)
},
})
const slashCommandTrigger = createSlashCommandTrigger({
getCommands: () => {
// Merge CLI global commands with extension commands
const extensionCommands = allSlashCommandsRef.current.map(toSlashCommandResult)
const globalCommands = getGlobalCommandsForAutocomplete().map(toSlashCommandResult)
// Global commands appear first, then extension commands
return [...globalCommands, ...extensionCommands]
},
})
const modeTrigger = createModeTrigger({
getModes: () => availableModesRef.current.map(toModeResult),
})
const helpTrigger = createHelpTrigger()
return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger]
}, [handleFileSearch]) // Only depend on handleFileSearch - data accessed via refs
// Handle Ctrl+C, Tab for focus switching, and Escape to cancel task
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
}
// Escape key to cancel/pause task when loading (streaming)
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)
}
}
}, [])
// Refresh search results when fileSearchResults changes while file picker is open
// This handles the async timing where API results arrive after initial search
// IMPORTANT: Only run when fileSearchResults array identity changes (new API response)
// We use a ref to track this and avoid depending on pickerState in the effect
const prevFileSearchResultsRef = useRef(fileSearchResults)
const pickerStateRef = useRef(pickerState)
pickerStateRef.current = pickerState
useEffect(() => {
// Only run if fileSearchResults actually changed (different array reference)
if (fileSearchResults === prevFileSearchResultsRef.current) {
return
}
prevFileSearchResultsRef.current = fileSearchResults
// Read pickerState from ref to avoid dependency
const currentPickerState = pickerStateRef.current
// Only refresh when file picker is open and we have new results
if (
currentPickerState.isOpen &&
currentPickerState.activeTrigger?.id === "file" &&
fileSearchResults.length > 0
) {
autocompleteRef.current?.refreshSearch()
followupAutocompleteRef.current?.refreshSearch()
}
}, [fileSearchResults]) // Only depend on fileSearchResults - read pickerState from ref
// Map extension say messages to TUI messages
const handleSayMessage = useCallback(
(ts: number, say: SayType, text: string, partial: boolean) => {
const messageId = ts.toString()
if (say === "checkpoint_saved") {
return
}
if (say === "api_req_started" && !verbose) {
return
}
if (say === "user_feedback") {
seenMessageIds.current.add(messageId)
return
}
if (say === "text" && !firstTextMessageSkipped.current) {
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
if (say === "command_output") {
role = "tool"
toolName = "execute_command"
toolDisplayName = "bash"
toolDisplayOutput = text
} else if (say === "tool") {
role = "tool"
try {
const toolInfo = JSON.parse(text)
toolName = toolInfo.tool
toolDisplayName = toolInfo.tool
toolDisplayOutput = formatToolOutput(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,
})
return
}
}
} catch {
toolDisplayOutput = text
}
} else if (say === "reasoning" || say === "thinking") {
role = "thinking"
}
seenMessageIds.current.add(messageId)
addMessage({
id: messageId,
role,
content: text || "",
toolName,
toolDisplayName,
toolDisplayOutput,
partial,
originalType: say,
})
},
[addMessage, verbose, currentTodos, setTodos],
)
// Handle extension ask messages
const handleAskMessage = useCallback(
(ts: number, ask: AskType, 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)
// Do not set pendingAsk - let the normal text input appear
return
}
if (ask === "completion_result") {
seenMessageIds.current.add(messageId)
setComplete(true)
setLoading(false)
return
}
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 || ""
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = toolInfo.tool as string
toolDisplayName = toolInfo.tool as string
toolDisplayOutput = formatToolOutput(toolInfo)
formattedContent = formatToolAskMessage(toolInfo)
} catch {
// Use raw text if not valid JSON
}
addMessage({
id: messageId,
role: "tool",
content: formattedContent,
toolName,
toolDisplayName,
toolDisplayOutput,
originalType: ask,
})
} 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<string, unknown>
questionText = formatToolAskMessage(toolInfo)
} catch {
// Use raw text if not valid JSON
}
}
seenMessageIds.current.add(messageId)
setPendingAsk({
id: messageId,
type: ask,
content: questionText,
suggestions,
})
},
[addMessage, setPendingAsk, setComplete, setLoading, nonInteractive],
)
// Handle extension messages
const handleExtensionMessage = useCallback(
(message: unknown) => {
const msg = message as Record<string, unknown>
if (msg.type === "state") {
const state = msg.state as Record<string, unknown>
if (!state) return
// Extract and update current mode from state
const newMode = state.mode as string | undefined
if (newMode) {
setCurrentMode(newMode)
}
const clineMessages = state.clineMessages as Array<Record<string, unknown>> | undefined
if (clineMessages) {
for (const clineMsg of clineMessages) {
const ts = clineMsg.ts as number
const type = clineMsg.type as string
const say = clineMsg.say as SayType | undefined
const ask = clineMsg.ask as AskType | undefined
const text = (clineMsg.text as string) || ""
const partial = (clineMsg.partial as boolean) || 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)
}
}
} else if (msg.type === "messageUpdated") {
const clineMessage = msg.clineMessage as Record<string, unknown>
if (!clineMessage) return
const ts = clineMessage.ts as number
const type = clineMessage.type as string
const say = clineMessage.say as SayType | undefined
const ask = clineMessage.ask as AskType | undefined
const text = (clineMessage.text as string) || ""
const partial = (clineMessage.partial as boolean) || 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") {
const results = (msg.results as FileSearchResult[]) || []
setFileSearchResults(results)
} else if (msg.type === "commands") {
const commands =
(msg.commands as Array<{
name: string
description?: string
argumentHint?: string
source: "global" | "project" | "built-in"
}>) || []
const slashCommands: SlashCommandResult[] = commands.map((cmd) => ({
name: cmd.name,
description: cmd.description,
argumentHint: cmd.argumentHint,
source: cmd.source,
}))
setAllSlashCommands(slashCommands)
} else if (msg.type === "modes") {
const modes =
(msg.modes as Array<{
slug: string
name: string
description?: string
}>) || []
const modeResults: ModeResult[] = modes.map((mode) => ({
slug: mode.slug,
name: mode.name,
description: mode.description,
}))
setAvailableModes(modeResults)
} else if (msg.type === "routerModels") {
// Handle router models for context window lookup
const models = msg.models as Record<string, Record<string, { contextWindow?: number }>> | undefined
if (models) {
setRouterModels(models)
}
} else if (msg.type === "apiConfiguration") {
// Handle API configuration for model identification
const config = msg.configuration as unknown
if (config) {
setApiConfiguration(config as import("@roo-code/types").ProviderSettings)
}
}
},
[
handleSayMessage,
handleAskMessage,
setFileSearchResults,
setAllSlashCommands,
setAvailableModes,
setCurrentMode,
setTokenUsage,
setRouterModels,
setApiConfiguration,
],
)
// Initialize extension host
useEffect(() => {
const init = async () => {
try {
const host = createExtensionHost({
mode,
reasoningEffort: reasoningEffort === "unspecified" ? undefined : reasoningEffort,
apiProvider,
apiKey,
model,
workspacePath,
extensionPath,
verbose: debug,
quiet: !verbose && !debug,
nonInteractive,
disableOutput: true,
})
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()
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" })
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") {
const lower = input.toLowerCase()
if (lower === "y") {
handleApprove()
} else if (lower === "n") {
handleReject()
}
}
})
// Handle picker state changes from AutocompleteInput
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePickerStateChange = useCallback((state: AutocompletePickerState<any>) => 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 ModeItem
// 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()
} else {
// Handle other item selections normally
autocompleteRef.current?.handleItemSelect(item)
followupAutocompleteRef.current?.handleItemSelect(item)
}
},
[pickerState.activeTrigger],
)
// 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 (
<Box flexDirection="column" padding={1}>
<Text color="red" bold>
Error: {error}
</Text>
<Text color="gray" dimColor>
Press Ctrl+C to exit
</Text>
</Box>
)
}
// Status bar content
const statusBarMessage = showExitHint ? (
<Text color="yellow">Press Ctrl+C again to exit</Text>
) : isLoading ? (
<Box>
<LoadingText>{view === "ToolUse" ? "Using tool" : "Thinking"}</LoadingText>
<Text color={theme.dimText}> • </Text>
<Text color={theme.dimText}>Esc to cancel</Text>
{isScrollAreaActive && (
<>
<Text color={theme.dimText}> • </Text>
<ScrollIndicator
scrollTop={scrollState.scrollTop}
maxScroll={scrollState.maxScroll}
isScrollFocused={true}
/>
</>
)}
</Box>
) : isScrollAreaActive ? (
<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
) : isInputAreaActive ? (
<Text color={theme.dimText}>? for shortcuts</Text>
) : null
// Get render function for picker items based on active trigger
const getPickerRenderItem = () => {
if (pickerState.activeTrigger) {
return pickerState.activeTrigger.renderItem
}
// Default render
return (item: FileResult | SlashCommandItem, isSelected: boolean) => (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>{item.key}</Text>
</Box>
)
}
return (
<Box flexDirection="column" height={rows - 1}>
{/* Header - fixed size */}
<Box flexShrink={0}>
<Header
model={model}
mode={currentMode || mode}
cwd={workspacePath}
reasoningEffort={reasoningEffort}
version={version}
tokenUsage={tokenUsage}
contextWindow={contextWindow}
/>
</Box>
{/* Scrollable message history area - fills remaining space via flexGrow */}
<ScrollArea
isActive={isScrollAreaActive}
onScroll={handleScroll}
scrollToBottomTrigger={scrollToBottomTrigger}>
{displayMessages.map((message) => (
<ChatHistoryItem key={message.id} message={message} />
))}
</ScrollArea>
{/* Input area - with borders like Claude Code - fixed size */}
<Box flexDirection="column" flexShrink={0}>
{pendingAsk?.type === "followup" ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk.content}</Text>
{pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine active={true} />
<Select
options={[
...pendingAsk.suggestions.map((s) => ({
label: s.answer,
value: s.answer,
})),
{ label: "Type something...", value: "__CUSTOM__" },
]}
onChange={(value) => {
if (!value || typeof value !== "string") return
if (showCustomInput || isTransitioningToCustomInput.current) return
if (value === "__CUSTOM__") {
isTransitioningToCustomInput.current = true
setShowCustomInput(true)
} else if (value.trim()) {
handleSubmit(value)
}
}}
/>
<HorizontalLine active={true} />
<Text color={theme.dimText}>↑↓ navigate • Enter select</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine active={isInputAreaActive} />
<AutocompleteInput
ref={followupAutocompleteRef}
placeholder="Type your response..."
onSubmit={(text: string) => {
if (text && text.trim()) {
handleSubmit(text)
setShowCustomInput(false)
isTransitioningToCustomInput.current = false
}
}}
isActive={true}
triggers={autocompleteTriggers}
onPickerStateChange={handlePickerStateChange}
prompt="> "
/>
<HorizontalLine active={isInputAreaActive} />
{pickerState.isOpen ? (
<Box flexDirection="column" height={PICKER_HEIGHT}>
<PickerSelect
results={pickerState.results}
selectedIndex={pickerState.selectedIndex}
maxVisible={PICKER_HEIGHT - 1}
onSelect={handlePickerSelect}
onEscape={handlePickerClose}
onIndexChange={handlePickerIndexChange}
renderItem={getPickerRenderItem()}
emptyMessage={pickerState.activeTrigger?.emptyMessage}
isActive={isInputAreaActive && pickerState.isOpen}
isLoading={pickerState.isLoading}
/>
</Box>
) : (
<Box height={1}>{statusBarMessage}</Box>
)}
</Box>
)}
</Box>
) : showApprovalPrompt ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk?.content}</Text>
<Text color={theme.dimText}>
Press <Text color={theme.successColor}>Y</Text> to approve,{" "}
<Text color={theme.errorColor}>N</Text> to reject
</Text>
<Box height={1}>{statusBarMessage}</Box>
</Box>
) : (
<Box flexDirection="column">
<HorizontalLine active={isInputAreaActive} />
<AutocompleteInput
ref={autocompleteRef}
placeholder={isComplete ? "Type to continue..." : ""}
onSubmit={handleSubmit}
isActive={isInputAreaActive}
triggers={autocompleteTriggers}
onPickerStateChange={handlePickerStateChange}
prompt="› "
/>
<HorizontalLine active={isInputAreaActive} />
{pickerState.isOpen ? (
<Box flexDirection="column" height={PICKER_HEIGHT}>
<PickerSelect
results={pickerState.results}
selectedIndex={pickerState.selectedIndex}
maxVisible={PICKER_HEIGHT - 1}
onSelect={handlePickerSelect}
onEscape={handlePickerClose}
onIndexChange={handlePickerIndexChange}
renderItem={getPickerRenderItem()}
emptyMessage={pickerState.activeTrigger?.emptyMessage}
isActive={isInputAreaActive && pickerState.isOpen}
isLoading={pickerState.isLoading}
/>
</Box>
) : (
<Box height={1}>{statusBarMessage}</Box>
)}
</Box>
)}
</Box>
</Box>
)
}
/**
* Main TUI Application Component - wraps with TerminalSizeProvider
*/
export function App(props: TUIAppProps) {
return (
<TerminalSizeProvider>
<AppInner {...props} />
</TerminalSizeProvider>
)
}
/**
* Format tool output for display (used in the message body, header shows tool name separately)
*/
function formatToolOutput(toolInfo: Record<string, unknown>): 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, unknown>): 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<string, unknown>): 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<string, unknown>
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
}