mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
More progress
This commit is contained in:
parent
c95706e345
commit
b6f571cadf
10 changed files with 563 additions and 115 deletions
187
apps/cli/src/__tests__/EscapeKeyCancel.test.ts
Normal file
187
apps/cli/src/__tests__/EscapeKeyCancel.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Tests for Escape key cancel/pause functionality
|
||||
*
|
||||
* When the CLI is in a loading state (streaming LLM API calls),
|
||||
* pressing Escape should send a "cancelTask" message to the extension,
|
||||
* similar to the Cancel button in the webview-ui.
|
||||
*/
|
||||
|
||||
describe("Escape key cancel behavior", () => {
|
||||
describe("escape key detection logic", () => {
|
||||
/**
|
||||
* Simulates the escape key handling logic from App.tsx
|
||||
*
|
||||
* @param key - The key object from ink's useInput
|
||||
* @param isLoading - Whether the app is currently loading (streaming)
|
||||
* @param hasHostRef - Whether the extension host reference is available
|
||||
* @param isPickerOpen - Whether an autocomplete picker is currently open
|
||||
* @returns An object describing what action should be taken
|
||||
*/
|
||||
const handleEscapeKey = (
|
||||
key: { escape: boolean },
|
||||
isLoading: boolean,
|
||||
hasHostRef: boolean,
|
||||
isPickerOpen: boolean,
|
||||
): { shouldCancel: boolean; reason?: string } => {
|
||||
if (!key.escape) {
|
||||
return { shouldCancel: false, reason: "Not escape key" }
|
||||
}
|
||||
|
||||
if (!isLoading) {
|
||||
return { shouldCancel: false, reason: "Not in loading state" }
|
||||
}
|
||||
|
||||
if (!hasHostRef) {
|
||||
return { shouldCancel: false, reason: "No host reference" }
|
||||
}
|
||||
|
||||
if (isPickerOpen) {
|
||||
// Let picker handle escape first
|
||||
return { shouldCancel: false, reason: "Picker is open" }
|
||||
}
|
||||
|
||||
return { shouldCancel: true }
|
||||
}
|
||||
|
||||
it("should cancel task when escape is pressed during loading", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
true, // isLoading
|
||||
true, // hasHostRef
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(true)
|
||||
})
|
||||
|
||||
it("should not cancel when not loading", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
false, // isLoading - not loading
|
||||
true, // hasHostRef
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("Not in loading state")
|
||||
})
|
||||
|
||||
it("should not cancel when host reference is not available", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
true, // isLoading
|
||||
false, // hasHostRef - no host reference
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("No host reference")
|
||||
})
|
||||
|
||||
it("should not cancel when picker is open", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
true, // isLoading
|
||||
true, // hasHostRef
|
||||
true, // isPickerOpen - picker is open
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("Picker is open")
|
||||
})
|
||||
|
||||
it("should not do anything for non-escape keys", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: false }, // Not escape key
|
||||
true, // isLoading
|
||||
true, // hasHostRef
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("Not escape key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancel message format", () => {
|
||||
it("should create the correct message format for cancelTask", () => {
|
||||
// The message sent to extension should match webview-ui format
|
||||
const cancelMessage = { type: "cancelTask" }
|
||||
|
||||
expect(cancelMessage).toEqual({ type: "cancelTask" })
|
||||
expect(cancelMessage.type).toBe("cancelTask")
|
||||
})
|
||||
|
||||
it("should match the webview-ui cancel message format", () => {
|
||||
// From webview-ui/src/components/chat/ChatView.tsx line 750:
|
||||
// vscode.postMessage({ type: "cancelTask" })
|
||||
const webviewCancelMessage = { type: "cancelTask" }
|
||||
const cliCancelMessage = { type: "cancelTask" }
|
||||
|
||||
expect(cliCancelMessage).toEqual(webviewCancelMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("loading state scenarios", () => {
|
||||
/**
|
||||
* The isLoading state in the CLI store represents:
|
||||
* - Active API request in progress
|
||||
* - Task is streaming responses
|
||||
* - Agent is "thinking" or processing
|
||||
*/
|
||||
|
||||
it("should identify loading state during agent response", () => {
|
||||
const view = "AgentResponse"
|
||||
const isLoading = true
|
||||
|
||||
// During agent response, cancel should be available
|
||||
expect(view).toBe("AgentResponse")
|
||||
expect(isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it("should identify loading state during tool use", () => {
|
||||
const view = "ToolUse"
|
||||
const isLoading = true
|
||||
|
||||
// During tool use, cancel should be available
|
||||
expect(view).toBe("ToolUse")
|
||||
expect(isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it("should not identify loading state during user input", () => {
|
||||
const view = "UserInput"
|
||||
const isLoading = false
|
||||
|
||||
// During user input, no need for cancel
|
||||
expect(view).toBe("UserInput")
|
||||
expect(isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancel behavior expectations", () => {
|
||||
it("should pause the task (not terminate)", () => {
|
||||
// The cancelTask message pauses the task, allowing the user to:
|
||||
// 1. Review the current state
|
||||
// 2. Provide additional input
|
||||
// 3. Resume the task by typing something
|
||||
const cancelBehavior = {
|
||||
action: "pause",
|
||||
terminates: false,
|
||||
allowsResume: true,
|
||||
resumeMethod: "user provides input",
|
||||
}
|
||||
|
||||
expect(cancelBehavior.action).toBe("pause")
|
||||
expect(cancelBehavior.terminates).toBe(false)
|
||||
expect(cancelBehavior.allowsResume).toBe(true)
|
||||
})
|
||||
|
||||
it("should allow resuming by typing after cancel", () => {
|
||||
// After cancel, the user can resume by typing a message
|
||||
const postCancelState = {
|
||||
isLoading: false, // Loading stops
|
||||
canTypeMessage: true, // User can type
|
||||
messageResumesTask: true, // Typing resumes the task
|
||||
}
|
||||
|
||||
expect(postCancelState.isLoading).toBe(false)
|
||||
expect(postCancelState.canTypeMessage).toBe(true)
|
||||
expect(postCancelState.messageResumesTask).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -201,6 +201,22 @@ function AppInner({
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const followupAutocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
|
||||
|
||||
// Stable refs for autocomplete data - prevents useMemo from recreating triggers on every data change
|
||||
const fileSearchResultsRef = useRef(fileSearchResults)
|
||||
const allSlashCommandsRef = useRef(allSlashCommands)
|
||||
const availableModesRef = useRef(availableModes)
|
||||
|
||||
// Keep refs in sync with current state
|
||||
useEffect(() => {
|
||||
fileSearchResultsRef.current = fileSearchResults
|
||||
}, [fileSearchResults])
|
||||
useEffect(() => {
|
||||
allSlashCommandsRef.current = allSlashCommands
|
||||
}, [allSlashCommands])
|
||||
useEffect(() => {
|
||||
availableModesRef.current = availableModes
|
||||
}, [availableModes])
|
||||
|
||||
// Track seen message timestamps to filter duplicates and the prompt echo
|
||||
const seenMessageIds = useRef<Set<string>>(new Set())
|
||||
const firstTextMessageSkipped = useRef(false)
|
||||
|
|
@ -303,25 +319,31 @@ function AppInner({
|
|||
|
||||
// Create autocomplete triggers
|
||||
// Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult)
|
||||
// IMPORTANT: We use refs here to avoid recreating triggers every time data changes.
|
||||
// This prevents the UI flash caused by: data change -> memo recreation -> re-render with stale state
|
||||
// The getResults/getCommands/getModes callbacks always read from refs to get fresh data.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const autocompleteTriggers = useMemo((): AutocompleteTrigger<any>[] => {
|
||||
const fileTrigger = createFileTrigger({
|
||||
onSearch: handleFileSearch,
|
||||
getResults: () => fileSearchResults.map(toFileResult),
|
||||
getResults: () => {
|
||||
const results = fileSearchResultsRef.current
|
||||
return results.map(toFileResult)
|
||||
},
|
||||
})
|
||||
|
||||
const slashCommandTrigger = createSlashCommandTrigger({
|
||||
getCommands: () => allSlashCommands.map(toSlashCommandResult),
|
||||
getCommands: () => allSlashCommandsRef.current.map(toSlashCommandResult),
|
||||
})
|
||||
|
||||
const modeTrigger = createModeTrigger({
|
||||
getModes: () => availableModes.map(toModeResult),
|
||||
getModes: () => availableModesRef.current.map(toModeResult),
|
||||
})
|
||||
|
||||
return [fileTrigger, slashCommandTrigger, modeTrigger]
|
||||
}, [handleFileSearch, fileSearchResults, allSlashCommands, availableModes])
|
||||
}, [handleFileSearch]) // Only depend on handleFileSearch - data accessed via refs
|
||||
|
||||
// Handle Ctrl+C and Tab for focus switching
|
||||
// Handle Ctrl+C, Tab for focus switching, and Escape to cancel task
|
||||
useInput((input, key) => {
|
||||
// Tab to toggle focus between scroll area and input (only when input is available)
|
||||
if (key.tab && canToggleFocus && !pickerState.isOpen) {
|
||||
|
|
@ -333,6 +355,17 @@ function AppInner({
|
|||
return
|
||||
}
|
||||
|
||||
// Escape key to cancel/pause task when loading (streaming)
|
||||
if (key.escape && isLoading && hostRef.current) {
|
||||
// If picker is open, let the picker handle escape first
|
||||
if (pickerState.isOpen) {
|
||||
return
|
||||
}
|
||||
// Send cancel message to extension (same as webview-ui Cancel button)
|
||||
hostRef.current.sendToExtension({ type: "cancelTask" })
|
||||
return
|
||||
}
|
||||
|
||||
if (key.ctrl && input === "c") {
|
||||
// If picker is open, close it first
|
||||
if (pickerState.isOpen) {
|
||||
|
|
@ -373,15 +406,34 @@ function AppInner({
|
|||
}
|
||||
}, [])
|
||||
|
||||
// FIX: Refresh search results when fileSearchResults changes while file picker is open
|
||||
// This fixes the async timing issue where getResults() returns empty before API responds
|
||||
// Only refresh when we actually have results (not on initial empty state)
|
||||
// Refresh search results when fileSearchResults changes while file picker is open
|
||||
// This handles the async timing where API results arrive after initial search
|
||||
// IMPORTANT: Only run when fileSearchResults array identity changes (new API response)
|
||||
// We use a ref to track this and avoid depending on pickerState in the effect
|
||||
const prevFileSearchResultsRef = useRef(fileSearchResults)
|
||||
const pickerStateRef = useRef(pickerState)
|
||||
pickerStateRef.current = pickerState
|
||||
|
||||
useEffect(() => {
|
||||
if (pickerState.isOpen && pickerState.activeTrigger?.id === "file" && fileSearchResults.length > 0) {
|
||||
// Only run if fileSearchResults actually changed (different array reference)
|
||||
if (fileSearchResults === prevFileSearchResultsRef.current) {
|
||||
return
|
||||
}
|
||||
prevFileSearchResultsRef.current = fileSearchResults
|
||||
|
||||
// Read pickerState from ref to avoid dependency
|
||||
const currentPickerState = pickerStateRef.current
|
||||
|
||||
// Only refresh when file picker is open and we have new results
|
||||
if (
|
||||
currentPickerState.isOpen &&
|
||||
currentPickerState.activeTrigger?.id === "file" &&
|
||||
fileSearchResults.length > 0
|
||||
) {
|
||||
autocompleteRef.current?.refreshSearch()
|
||||
followupAutocompleteRef.current?.refreshSearch()
|
||||
}
|
||||
}, [fileSearchResults, pickerState.isOpen, pickerState.activeTrigger?.id])
|
||||
}, [fileSearchResults]) // Only depend on fileSearchResults - read pickerState from ref
|
||||
|
||||
// Map extension say messages to TUI messages
|
||||
const handleSayMessage = useCallback(
|
||||
|
|
@ -494,6 +546,15 @@ function AppInner({
|
|||
return
|
||||
}
|
||||
|
||||
// Handle resume_task and resume_completed_task - stop loading and show text input
|
||||
// Do not set pendingAsk - just stop loading so user sees normal input to type new message
|
||||
if (ask === "resume_task" || ask === "resume_completed_task") {
|
||||
seenMessageIds.current.add(messageId)
|
||||
setLoading(false)
|
||||
// Do not set pendingAsk - let the normal text input appear
|
||||
return
|
||||
}
|
||||
|
||||
if (ask === "completion_result") {
|
||||
seenMessageIds.current.add(messageId)
|
||||
setComplete(true)
|
||||
|
|
@ -757,7 +818,9 @@ function AppInner({
|
|||
// Handle user input submission
|
||||
const handleSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
if (!hostRef.current || !text.trim()) return
|
||||
if (!hostRef.current || !text.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedText = text.trim()
|
||||
|
||||
|
|
@ -929,6 +992,8 @@ function AppInner({
|
|||
) : isLoading ? (
|
||||
<Box>
|
||||
<LoadingText>{view === "ToolUse" ? "Using tool" : "Thinking"}</LoadingText>
|
||||
<Text color={theme.dimText}> • </Text>
|
||||
<Text color={theme.dimText}>Esc to cancel</Text>
|
||||
{isScrollAreaActive && (
|
||||
<>
|
||||
<Text color={theme.dimText}> • </Text>
|
||||
|
|
@ -1044,6 +1109,7 @@ function AppInner({
|
|||
renderItem={getPickerRenderItem()}
|
||||
emptyMessage={pickerState.activeTrigger?.emptyMessage}
|
||||
isActive={isInputAreaActive && pickerState.isOpen}
|
||||
isLoading={pickerState.isLoading}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
|
|
@ -1086,6 +1152,7 @@ function AppInner({
|
|||
renderItem={getPickerRenderItem()}
|
||||
emptyMessage={pickerState.activeTrigger?.emptyMessage}
|
||||
isActive={isInputAreaActive && pickerState.isOpen}
|
||||
isLoading={pickerState.isLoading}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ interface ChatHistoryItemProps {
|
|||
}
|
||||
|
||||
function ChatHistoryItem({ message }: ChatHistoryItemProps) {
|
||||
const content = message.content || "<no content>"
|
||||
const content = message.content || "..."
|
||||
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text bold color={theme.userHeader}>
|
||||
user
|
||||
<Text bold color="magenta">
|
||||
You said:
|
||||
</Text>
|
||||
<Text color={theme.userText}>
|
||||
{content}
|
||||
|
|
@ -28,8 +28,8 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) {
|
|||
case "assistant":
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text bold color={theme.rooHeader}>
|
||||
roo
|
||||
<Text bold color="yellow">
|
||||
Roo said:
|
||||
</Text>
|
||||
<Text color={theme.rooText}>
|
||||
{content}
|
||||
|
|
@ -41,7 +41,7 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) {
|
|||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text bold color={theme.thinkingHeader} dimColor>
|
||||
thinking
|
||||
Roo is thinking:
|
||||
</Text>
|
||||
<Text color={theme.thinkingText} dimColor>
|
||||
{content}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function Header({ model, cwd, mode, reasoningEffort, version, tokenUsage, contex
|
|||
<Box width={columns}>
|
||||
<Box flexDirection="row">
|
||||
<Box marginY={1}>
|
||||
<Text color={theme.asciiColor}>{ASCII_ROO}</Text>
|
||||
<Text color="magenta">{ASCII_ROO}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.dimText}>Workspace: {displayCwd}</Text>
|
||||
|
|
|
|||
|
|
@ -193,6 +193,8 @@ export function ScrollArea({
|
|||
|
||||
const innerRef = useRef<DOMElement>(null)
|
||||
const lastMeasuredHeight = useRef<number>(0)
|
||||
// Track previous scrollToLineTrigger to detect actual changes (allows scrolling to index 0)
|
||||
const prevScrollToLineTriggerRef = useRef<number | undefined>(undefined)
|
||||
|
||||
// Update height when prop changes
|
||||
useEffect(() => {
|
||||
|
|
@ -232,10 +234,19 @@ export function ScrollArea({
|
|||
}, [scrollToBottomTrigger])
|
||||
|
||||
// Scroll to specific line when trigger changes
|
||||
// FIX: Use ref to detect actual changes instead of `> 0` check, which broke scrolling to index 0
|
||||
useEffect(() => {
|
||||
if (scrollToLineTrigger !== undefined && scrollToLineTrigger > 0 && scrollToLine !== undefined) {
|
||||
const prevTrigger = prevScrollToLineTriggerRef.current
|
||||
const triggerChanged = scrollToLineTrigger !== prevTrigger
|
||||
|
||||
// Only dispatch if trigger actually changed and we have valid values
|
||||
// This allows scrolling to index 0 (which was broken by the old `> 0` check)
|
||||
if (triggerChanged && scrollToLineTrigger !== undefined && scrollToLine !== undefined) {
|
||||
dispatch({ type: "SCROLL_TO_LINE", line: scrollToLine })
|
||||
}
|
||||
|
||||
// Update the ref to track the current trigger value
|
||||
prevScrollToLineTriggerRef.current = scrollToLineTrigger
|
||||
}, [scrollToLineTrigger, scrollToLine])
|
||||
|
||||
// Measure inner content height - use MutationObserver pattern for dynamic content
|
||||
|
|
@ -355,7 +366,7 @@ export function ScrollArea({
|
|||
.map((_, i) => {
|
||||
const isHandle =
|
||||
i >= scrollbar.handleStart && i < scrollbar.handleStart + scrollbar.handleHeight
|
||||
return isHandle ? "█" : "░"
|
||||
return isHandle ? "┃" : "│"
|
||||
})
|
||||
.join("\n")}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useInput } from "ink"
|
||||
import { useState, useCallback, useEffect, useImperativeHandle, forwardRef, type Ref } from "react"
|
||||
import { useState, useCallback, useEffect, useImperativeHandle, forwardRef, useRef, type Ref } from "react"
|
||||
|
||||
import { MultilineTextInput } from "../MultilineTextInput.js"
|
||||
import { useInputHistory } from "../../hooks/useInputHistory.js"
|
||||
|
|
@ -77,9 +77,35 @@ function AutocompleteInputInner<T extends AutocompleteItem>(
|
|||
|
||||
const [wasBrowsing, setWasBrowsing] = useState(false)
|
||||
|
||||
// Notify parent of picker state changes
|
||||
// Track previous picker state values to avoid unnecessary parent updates
|
||||
const prevPickerStateRef = useRef({
|
||||
isOpen: pickerState.isOpen,
|
||||
resultsLength: pickerState.results.length,
|
||||
selectedIndex: pickerState.selectedIndex,
|
||||
isLoading: pickerState.isLoading,
|
||||
})
|
||||
|
||||
// Notify parent of picker state changes only when relevant properties change
|
||||
// This prevents double renders from cascading state updates
|
||||
useEffect(() => {
|
||||
onPickerStateChange?.(pickerState)
|
||||
const prev = prevPickerStateRef.current
|
||||
const curr = {
|
||||
isOpen: pickerState.isOpen,
|
||||
resultsLength: pickerState.results.length,
|
||||
selectedIndex: pickerState.selectedIndex,
|
||||
isLoading: pickerState.isLoading,
|
||||
}
|
||||
|
||||
// Only notify if something visually relevant changed
|
||||
if (
|
||||
prev.isOpen !== curr.isOpen ||
|
||||
prev.resultsLength !== curr.resultsLength ||
|
||||
prev.selectedIndex !== curr.selectedIndex ||
|
||||
prev.isLoading !== curr.isLoading
|
||||
) {
|
||||
prevPickerStateRef.current = curr
|
||||
onPickerStateChange?.(pickerState)
|
||||
}
|
||||
}, [pickerState, onPickerStateChange])
|
||||
|
||||
// Handle history navigation
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useEffect, useReducer, type ReactNode } from "react"
|
||||
import { useRef, useMemo, type ReactNode } from "react"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
|
||||
import { ScrollArea } from "../ScrollArea.js"
|
||||
import type { AutocompleteItem } from "./types.js"
|
||||
|
||||
export interface PickerSelectProps<T extends AutocompleteItem> {
|
||||
|
|
@ -23,11 +22,64 @@ export interface PickerSelectProps<T extends AutocompleteItem> {
|
|||
emptyMessage?: string
|
||||
/** Whether the picker accepts keyboard input */
|
||||
isActive?: boolean
|
||||
/** Whether search is in progress */
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute visible window based on selected index.
|
||||
* The window "follows" the selection, keeping it visible.
|
||||
* Uses a ref to track the previous window position for smooth scrolling.
|
||||
*/
|
||||
function computeVisibleWindow(
|
||||
selectedIndex: number,
|
||||
totalItems: number,
|
||||
maxVisible: number,
|
||||
prevWindow: { from: number; to: number },
|
||||
): { from: number; to: number } {
|
||||
if (totalItems === 0) {
|
||||
return { from: 0, to: 0 }
|
||||
}
|
||||
|
||||
const visibleCount = Math.min(maxVisible, totalItems)
|
||||
|
||||
// If previous window was empty (fresh results), compute initial window
|
||||
// This handles the case when results first appear
|
||||
if (prevWindow.to === 0 || prevWindow.to <= prevWindow.from) {
|
||||
const newFrom = Math.max(0, selectedIndex)
|
||||
const newTo = Math.min(totalItems, newFrom + visibleCount)
|
||||
return { from: newFrom, to: newTo }
|
||||
}
|
||||
|
||||
// If selected index is within current window, keep the window
|
||||
if (selectedIndex >= prevWindow.from && selectedIndex < prevWindow.to) {
|
||||
// But clamp the window to valid bounds (in case totalItems changed)
|
||||
const clampedFrom = Math.max(0, Math.min(prevWindow.from, totalItems - visibleCount))
|
||||
const clampedTo = Math.min(totalItems, clampedFrom + visibleCount)
|
||||
return { from: clampedFrom, to: clampedTo }
|
||||
}
|
||||
|
||||
// If selected is below window, scroll down to show it at bottom
|
||||
if (selectedIndex >= prevWindow.to) {
|
||||
const newTo = Math.min(totalItems, selectedIndex + 1)
|
||||
const newFrom = Math.max(0, newTo - visibleCount)
|
||||
return { from: newFrom, to: newTo }
|
||||
}
|
||||
|
||||
// If selected is above window, scroll up to show it at top
|
||||
if (selectedIndex < prevWindow.from) {
|
||||
const newFrom = Math.max(0, selectedIndex)
|
||||
const newTo = Math.min(totalItems, newFrom + visibleCount)
|
||||
return { from: newFrom, to: newTo }
|
||||
}
|
||||
|
||||
return prevWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic picker dropdown component for autocomplete.
|
||||
* Handles keyboard navigation and item selection.
|
||||
* Uses windowing approach (like @inkjs/ui) - only renders visible items.
|
||||
* This eliminates flickering caused by ScrollArea's margin-based scrolling.
|
||||
*
|
||||
* @template T - The type of items to display
|
||||
*/
|
||||
|
|
@ -41,15 +93,21 @@ export function PickerSelect<T extends AutocompleteItem>({
|
|||
renderItem,
|
||||
emptyMessage = "No results found",
|
||||
isActive = true,
|
||||
isLoading = false,
|
||||
}: PickerSelectProps<T>) {
|
||||
// Trigger for scrolling to the selected line
|
||||
const [scrollTrigger, incrementScrollTrigger] = useReducer((x: number) => x + 1, 0)
|
||||
// Track previous window position for smooth scrolling
|
||||
const prevWindowRef = useRef({ from: 0, to: Math.min(maxVisible, results.length) })
|
||||
|
||||
// Scroll to selected item when selection changes
|
||||
useEffect(() => {
|
||||
incrementScrollTrigger()
|
||||
}, [selectedIndex])
|
||||
// Compute visible window SYNCHRONOUSLY during render (no state, no useEffect)
|
||||
// This ensures the correct items are rendered in a single pass
|
||||
const visibleWindow = useMemo(() => {
|
||||
const window = computeVisibleWindow(selectedIndex, results.length, maxVisible, prevWindowRef.current)
|
||||
// Update ref for next render
|
||||
prevWindowRef.current = window
|
||||
return window
|
||||
}, [selectedIndex, results.length, maxVisible])
|
||||
|
||||
// Handle keyboard input
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!isActive) {
|
||||
|
|
@ -63,11 +121,9 @@ export function PickerSelect<T extends AutocompleteItem>({
|
|||
|
||||
if (key.return) {
|
||||
const selected = results[selectedIndex]
|
||||
|
||||
if (selected) {
|
||||
onSelect(selected)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -86,29 +142,48 @@ export function PickerSelect<T extends AutocompleteItem>({
|
|||
{ isActive },
|
||||
)
|
||||
|
||||
// Compute visible items (the key optimization - only render what's visible)
|
||||
const visibleItems = useMemo(() => {
|
||||
return results.slice(visibleWindow.from, visibleWindow.to)
|
||||
}, [results, visibleWindow.from, visibleWindow.to])
|
||||
|
||||
// Empty state - maintain consistent height
|
||||
if (results.length === 0) {
|
||||
const message = isLoading ? "Searching..." : emptyMessage
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>{emptyMessage}</Text>
|
||||
<Box paddingLeft={2} height={maxVisible}>
|
||||
<Text dimColor>{message}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Height for the scroll area - use maxVisible as the viewport height
|
||||
const scrollHeight = Math.min(results.length, maxVisible)
|
||||
// Calculate if we need scroll indicators
|
||||
const hasMoreAbove = visibleWindow.from > 0
|
||||
const hasMoreBelow = visibleWindow.to < results.length
|
||||
|
||||
// Render only visible items (windowing approach)
|
||||
return (
|
||||
<ScrollArea
|
||||
height={scrollHeight}
|
||||
isActive={false}
|
||||
showScrollbar={true}
|
||||
scrollToLine={selectedIndex}
|
||||
scrollToLineTrigger={scrollTrigger}
|
||||
autoScroll={false}>
|
||||
{results.map((result, index) => {
|
||||
const isSelected = index === selectedIndex
|
||||
<Box flexDirection="column" height={maxVisible}>
|
||||
{/* Scroll indicator - more items above */}
|
||||
{hasMoreAbove && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>↑ {visibleWindow.from} more</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Visible items */}
|
||||
{visibleItems.map((result, visibleIndex) => {
|
||||
const actualIndex = visibleWindow.from + visibleIndex
|
||||
const isSelected = actualIndex === selectedIndex
|
||||
return <Box key={result.key}>{renderItem(result, isSelected)}</Box>
|
||||
})}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Scroll indicator - more items below */}
|
||||
{hasMoreBelow && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>↓ {results.length - visibleWindow.to} more</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ const DEFAULT_DEBOUNCE_MS = 150
|
|||
/**
|
||||
* Hook that manages autocomplete picker state and logic.
|
||||
*
|
||||
* This hook supports two types of triggers:
|
||||
* 1. **Sync triggers** (e.g., slash commands, modes): `search()` returns results directly
|
||||
* 2. **Async triggers** (e.g., file search): `search()` triggers an API call and returns `[]`,
|
||||
* then `forceRefresh()` is called when external data arrives
|
||||
*
|
||||
* For async triggers (those with `refreshResults` defined), the hook preserves existing
|
||||
* results during the loading state to prevent UI flickering.
|
||||
*
|
||||
* @template T - The type of autocomplete items
|
||||
* @param triggers - Array of autocomplete triggers to check
|
||||
* @returns Picker state and actions
|
||||
|
|
@ -95,19 +103,48 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
|
|||
|
||||
// Check if query has changed
|
||||
const lastQuery = lastQueriesRef.current.get(foundTrigger.id)
|
||||
|
||||
if (query === lastQuery && state.isOpen && state.activeTrigger?.id === foundTrigger.id) {
|
||||
// Same query, same trigger - no need to search again
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if this is an async trigger (has refreshResults for external data)
|
||||
const isAsyncTrigger = !!foundTrigger.refreshResults
|
||||
|
||||
// For async triggers, immediately get cached results filtered by new query
|
||||
// This prevents the "empty state flash" when reopening picker with different query
|
||||
let initialResults: T[] = []
|
||||
|
||||
if (isAsyncTrigger && foundTrigger.refreshResults) {
|
||||
try {
|
||||
const cached = foundTrigger.refreshResults(query)
|
||||
if (!(cached instanceof Promise)) {
|
||||
initialResults = cached
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, will use empty array
|
||||
}
|
||||
}
|
||||
|
||||
// Set loading state immediately and open picker
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
activeTrigger: foundTrigger,
|
||||
isLoading: true,
|
||||
isOpen: true, // Open immediately when trigger is detected
|
||||
triggerInfo: foundTriggerInfo,
|
||||
}))
|
||||
// For async triggers with cached results, show them immediately to prevent flickering
|
||||
// Only set isLoading if we have no cached results to show
|
||||
const hasResults = initialResults.length > 0
|
||||
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
activeTrigger: foundTrigger,
|
||||
// Only show loading state if we have no results to display
|
||||
isLoading: !hasResults,
|
||||
isOpen: true,
|
||||
triggerInfo: foundTriggerInfo,
|
||||
// Use initial cached results if available, otherwise preserve previous
|
||||
results: initialResults.length > 0 ? initialResults : prev.results,
|
||||
selectedIndex: initialResults.length > 0 ? 0 : prev.selectedIndex,
|
||||
}
|
||||
})
|
||||
|
||||
// Debounce the search
|
||||
const timer = setTimeout(async () => {
|
||||
|
|
@ -122,11 +159,20 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
|
|||
return prev
|
||||
}
|
||||
|
||||
// For async triggers (those with refreshResults like file search):
|
||||
// - NEVER update results from search() - it always returns []
|
||||
// - Keep existing results and stay in loading state
|
||||
// - Results will be updated via forceRefresh() when async data arrives
|
||||
if (isAsyncTrigger && results.length === 0) {
|
||||
// Don't change results or loading state - forceRefresh will handle it
|
||||
return prev
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
results,
|
||||
selectedIndex: 0,
|
||||
isOpen: true, // Keep open - user can close with Escape
|
||||
isOpen: true,
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
|
|
@ -279,10 +325,20 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
|
|||
if (prev.activeTrigger?.id !== activeTrigger.id) {
|
||||
return prev
|
||||
}
|
||||
|
||||
// Only update if results actually changed to avoid unnecessary re-renders
|
||||
if (
|
||||
prev.results.length === asyncResults.length &&
|
||||
prev.results.every((r, i) => r.key === asyncResults[i]?.key)
|
||||
) {
|
||||
return { ...prev, isLoading: false }
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
results: asyncResults,
|
||||
selectedIndex: 0,
|
||||
// Preserve selectedIndex if within bounds, otherwise reset to 0
|
||||
selectedIndex: prev.selectedIndex < asyncResults.length ? prev.selectedIndex : 0,
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
|
|
@ -293,16 +349,26 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
|
|||
if (prev.activeTrigger?.id !== activeTrigger.id) {
|
||||
return prev
|
||||
}
|
||||
|
||||
// Only update if results actually changed to avoid unnecessary re-renders
|
||||
if (
|
||||
prev.results.length === results.length &&
|
||||
prev.results.every((r, i) => r.key === results[i]?.key)
|
||||
) {
|
||||
return { ...prev, isLoading: false }
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
results,
|
||||
selectedIndex: 0,
|
||||
// Preserve selectedIndex if within bounds, otherwise reset to 0
|
||||
selectedIndex: prev.selectedIndex < results.length ? prev.selectedIndex : 0,
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (_error) {
|
||||
// Silently fail on refresh errors
|
||||
// Silently fail on refresh errors.
|
||||
}
|
||||
}, [state, triggers])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,84 +1,78 @@
|
|||
/**
|
||||
* Theme configuration for Roo Code CLI TUI
|
||||
* Using Catppuccin Mocha color scheme
|
||||
* Using Hardcore color scheme
|
||||
*/
|
||||
|
||||
// Catppuccin Mocha palette
|
||||
const catppuccin = {
|
||||
// Hardcore palette
|
||||
const hardcore = {
|
||||
// Accent colors
|
||||
rosewater: "#f5e0dc",
|
||||
flamingo: "#f2cdcd",
|
||||
pink: "#f5c2e7",
|
||||
mauve: "#cba6f7",
|
||||
red: "#f38ba8",
|
||||
maroon: "#eba0ac",
|
||||
peach: "#fab387",
|
||||
yellow: "#f9e2af",
|
||||
green: "#a6e3a1",
|
||||
teal: "#94e2d5",
|
||||
sky: "#89dceb",
|
||||
sapphire: "#74c7ec",
|
||||
blue: "#89b4fa",
|
||||
lavender: "#b4befe",
|
||||
pink: "#F92672",
|
||||
pinkLight: "#FF669D",
|
||||
green: "#A6E22E",
|
||||
greenLight: "#BEED5F",
|
||||
orange: "#FD971F",
|
||||
yellow: "#E6DB74",
|
||||
cyan: "#66D9EF",
|
||||
purple: "#9E6FFE",
|
||||
|
||||
// Text colors
|
||||
text: "#cdd6f4",
|
||||
subtext1: "#bac2de",
|
||||
subtext0: "#a6adc8",
|
||||
text: "#F8F8F2",
|
||||
subtext1: "#CCCCC6",
|
||||
subtext0: "#A3BABF",
|
||||
|
||||
// Overlay colors
|
||||
overlay2: "#9399b2",
|
||||
overlay1: "#7f849c",
|
||||
overlay0: "#6c7086",
|
||||
overlay2: "#A3BABF",
|
||||
overlay1: "#5E7175",
|
||||
overlay0: "#505354",
|
||||
|
||||
// Surface colors
|
||||
surface2: "#585b70",
|
||||
surface1: "#45475a",
|
||||
surface0: "#313244",
|
||||
surface2: "#505354",
|
||||
surface1: "#383a3e",
|
||||
surface0: "#2d2e2e",
|
||||
|
||||
// Base colors
|
||||
base: "#1e1e2e",
|
||||
mantle: "#181825",
|
||||
crust: "#11111b",
|
||||
base: "#1B1D1E",
|
||||
mantle: "#161819",
|
||||
crust: "#101112",
|
||||
}
|
||||
|
||||
// Title and branding colors
|
||||
export const titleColor = catppuccin.peach // Peach for title
|
||||
export const welcomeText = catppuccin.text // Standard text
|
||||
export const asciiColor = catppuccin.blue // Blue for ASCII art
|
||||
export const titleColor = hardcore.orange // Orange for title
|
||||
export const welcomeText = hardcore.text // Standard text
|
||||
export const asciiColor = hardcore.cyan // Cyan for ASCII art
|
||||
|
||||
// Tips section colors
|
||||
export const tipsHeader = catppuccin.peach // Peach for tips headers
|
||||
export const tipsText = catppuccin.subtext0 // Subtle text for tips
|
||||
export const tipsHeader = hardcore.orange // Orange for tips headers
|
||||
export const tipsText = hardcore.subtext0 // Subtle text for tips
|
||||
|
||||
// Header text colors (for messages)
|
||||
export const userHeader = catppuccin.lavender // Lavender for user header
|
||||
export const rooHeader = catppuccin.yellow // Yellow for roo
|
||||
export const toolHeader = catppuccin.teal // Teal for tool headers
|
||||
export const thinkingHeader = catppuccin.overlay1 // Subtle gray for thinking header
|
||||
export const userHeader = hardcore.purple // Purple for user header
|
||||
export const rooHeader = hardcore.yellow // Yellow for roo
|
||||
export const toolHeader = hardcore.cyan // Cyan for tool headers
|
||||
export const thinkingHeader = hardcore.overlay1 // Subtle gray for thinking header
|
||||
|
||||
// Message text colors
|
||||
export const userText = catppuccin.text // Standard text for user
|
||||
export const rooText = catppuccin.text // Standard text for roo
|
||||
export const toolText = catppuccin.subtext0 // Subtle text for tool output
|
||||
export const thinkingText = catppuccin.overlay2 // Subtle gray for thinking text
|
||||
export const userText = hardcore.text // Standard text for user
|
||||
export const rooText = hardcore.text // Standard text for roo
|
||||
export const toolText = hardcore.subtext0 // Subtle text for tool output
|
||||
export const thinkingText = hardcore.overlay2 // Subtle gray for thinking text
|
||||
|
||||
// UI element colors
|
||||
export const borderColor = catppuccin.surface1 // Surface color for borders
|
||||
export const borderColorActive = catppuccin.blue // Active/focused border color
|
||||
export const dimText = catppuccin.overlay1 // Dim text
|
||||
export const promptColor = catppuccin.overlay2 // Prompt indicator
|
||||
export const promptColorActive = catppuccin.blue // Active prompt color
|
||||
export const placeholderColor = catppuccin.overlay0 // Placeholder text
|
||||
export const borderColor = hardcore.surface1 // Surface color for borders
|
||||
export const borderColorActive = hardcore.purple // Active/focused border color
|
||||
export const dimText = hardcore.overlay1 // Dim text
|
||||
export const promptColor = hardcore.overlay2 // Prompt indicator
|
||||
export const promptColorActive = hardcore.cyan // Active prompt color
|
||||
export const placeholderColor = hardcore.overlay0 // Placeholder text
|
||||
|
||||
// Status colors
|
||||
export const successColor = catppuccin.green // Green for success
|
||||
export const errorColor = catppuccin.red // Red for errors
|
||||
export const warningColor = catppuccin.yellow // Yellow for warnings
|
||||
export const successColor = hardcore.green // Green for success
|
||||
export const errorColor = hardcore.pink // Pink for errors
|
||||
export const warningColor = hardcore.yellow // Yellow for warnings
|
||||
|
||||
// Focus indicator colors
|
||||
export const focusColor = catppuccin.blue // Focus indicator (blue accent)
|
||||
export const scrollActiveColor = catppuccin.mauve // Scroll area active indicator (purple)
|
||||
export const focusColor = hardcore.cyan // Focus indicator (cyan accent)
|
||||
export const scrollActiveColor = hardcore.purple // Scroll area active indicator (purple)
|
||||
|
||||
// Base text color
|
||||
export const text = catppuccin.text // Standard text color
|
||||
export const text = hardcore.text // Standard text color
|
||||
|
|
|
|||
|
|
@ -1788,7 +1788,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
this.isInitialized = true
|
||||
|
||||
const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`.
|
||||
let response: ClineAskResponse
|
||||
let text: string | undefined
|
||||
let images: string[] | undefined
|
||||
|
||||
try {
|
||||
const result = await this.ask(askType) // Calls `postStateToWebview`.
|
||||
response = result.response
|
||||
text = result.text
|
||||
images = result.images
|
||||
} catch (error) {
|
||||
// Handle abort gracefully - if task was aborted during the ask, don't throw
|
||||
if (this.abort) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let responseText: string | undefined
|
||||
let responseImages: string[] | undefined
|
||||
|
|
@ -1973,7 +1988,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
|
||||
|
||||
// Task resuming from history item.
|
||||
await this.initiateTaskLoop(newUserContent)
|
||||
await this.initiateTaskLoop(newUserContent).catch((error) => {
|
||||
// Swallow loop rejection when the task was intentionally abandoned/aborted
|
||||
// during delegation or user cancellation to prevent unhandled rejections.
|
||||
if (this.abandoned === true || this.abortReason === "user_cancelled") {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue