refactor(cli): extract hooks, stores, and utils from TUI App component

- Extract extension host logic into useExtensionHost hook
- Extract message handling into useMessageHandlers hook
- Extract task submission into useTaskSubmit hook
- Extract global input handling into useGlobalInput hook
- Extract followup countdown into useFollowupCountdown hook
- Extract focus management into useFocusManagement hook
- Extract picker handlers into usePickerHandlers hook
- Create uiStateStore for UI-specific state (showExitHint, countdown, etc.)
- Extract tool data utilities (extractToolData, formatToolOutput, etc.)
- Extract view utilities (getView function)
- Extract HorizontalLine component
- Reduce App.tsx by ~1400 lines for better maintainability
This commit is contained in:
cte 2026-01-08 02:36:40 -08:00
parent 75cade755e
commit 8d3a6acb72
14 changed files with 2017 additions and 1399 deletions

File diff suppressed because it is too large Load diff

View file

@ -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 <Text color={color}>{"─".repeat(columns)}</Text>
}

View file

@ -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"

View file

@ -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<void>
runTask(prompt: string): Promise<void>
sendToExtension(message: WebviewMessage): void
dispose(): Promise<void>
}
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<void>) | null
cleanup: () => Promise<void>
}
/**
* 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<ExtensionHostInterface | null>(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,
}
}

View file

@ -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,
}
}

View file

@ -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<NodeJS.Timeout | null>(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,
}
}

View file

@ -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<void>
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<NodeJS.Timeout | null>(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)
}
}
})
}

View file

@ -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<Set<string>>
pendingCommandRef: React.MutableRefObject<string | null>
firstTextMessageSkipped: React.MutableRefObject<boolean>
}
/**
* 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<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
// Track pending command for injecting into command_output toolData
const pendingCommandRef = useRef<string | null>(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<string, unknown>
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<string, unknown>
// 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<string, unknown>
// 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,
}
}

View file

@ -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<AutocompleteInputHandle<any>>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
followupAutocompleteRef: React.RefObject<AutocompleteInputHandle<any>>
sendToExtension: ((msg: WebviewMessage) => void) | null
showInfo: (msg: string, duration?: number) => void
seenMessageIds: React.MutableRefObject<Set<string>>
firstTextMessageSkipped: React.MutableRefObject<boolean>
}
export interface UsePickerHandlersReturn {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handlePickerStateChange: (state: AutocompletePickerState<any>) => 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<any>) => {
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,
}
}

View file

@ -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<void>) | null
seenMessageIds: React.MutableRefObject<Set<string>>
firstTextMessageSkipped: React.MutableRefObject<boolean>
}
export interface UseTaskSubmitReturn {
handleSubmit: (text: string) => Promise<void>
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,
}
}

View file

@ -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<any>
}
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<any>) => 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<UIState & UIActions>((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),
}))

View file

@ -0,0 +1,9 @@
export {
extractToolData,
formatToolOutput,
formatToolAskMessage,
parseTodosFromToolInfo,
parseMarkdownChecklist,
} from "./toolDataUtils.js"
export { getView } from "./viewUtils.js"

View file

@ -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<string, unknown>): 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<Record<string, unknown>>).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<Record<string, unknown>>).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, 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
*/
export 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
*/
export 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
*/
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
}

View file

@ -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"
}