mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
75cade755e
commit
8d3a6acb72
14 changed files with 2017 additions and 1399 deletions
File diff suppressed because it is too large
Load diff
16
apps/cli/src/ui/components/HorizontalLine.tsx
Normal file
16
apps/cli/src/ui/components/HorizontalLine.tsx
Normal 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>
|
||||
}
|
||||
22
apps/cli/src/ui/hooks/index.ts
Normal file
22
apps/cli/src/ui/hooks/index.ts
Normal 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"
|
||||
205
apps/cli/src/ui/hooks/useExtensionHost.ts
Normal file
205
apps/cli/src/ui/hooks/useExtensionHost.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
85
apps/cli/src/ui/hooks/useFocusManagement.ts
Normal file
85
apps/cli/src/ui/hooks/useFocusManagement.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
101
apps/cli/src/ui/hooks/useFollowupCountdown.ts
Normal file
101
apps/cli/src/ui/hooks/useFollowupCountdown.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
170
apps/cli/src/ui/hooks/useGlobalInput.ts
Normal file
170
apps/cli/src/ui/hooks/useGlobalInput.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
437
apps/cli/src/ui/hooks/useMessageHandlers.ts
Normal file
437
apps/cli/src/ui/hooks/useMessageHandlers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
171
apps/cli/src/ui/hooks/usePickerHandlers.ts
Normal file
171
apps/cli/src/ui/hooks/usePickerHandlers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
181
apps/cli/src/ui/hooks/useTaskSubmit.ts
Normal file
181
apps/cli/src/ui/hooks/useTaskSubmit.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
87
apps/cli/src/ui/stores/uiStateStore.ts
Normal file
87
apps/cli/src/ui/stores/uiStateStore.ts
Normal 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),
|
||||
}))
|
||||
9
apps/cli/src/ui/utils/index.ts
Normal file
9
apps/cli/src/ui/utils/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
extractToolData,
|
||||
formatToolOutput,
|
||||
formatToolAskMessage,
|
||||
parseTodosFromToolInfo,
|
||||
parseMarkdownChecklist,
|
||||
} from "./toolDataUtils.js"
|
||||
|
||||
export { getView } from "./viewUtils.js"
|
||||
345
apps/cli/src/ui/utils/toolDataUtils.ts
Normal file
345
apps/cli/src/ui/utils/toolDataUtils.ts
Normal 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
|
||||
}
|
||||
52
apps/cli/src/ui/utils/viewUtils.ts
Normal file
52
apps/cli/src/ui/utils/viewUtils.ts
Normal 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"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue