From b6f571cadf643477763bea7fb6e146e02b5e8587 Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 6 Jan 2026 23:09:38 -0800 Subject: [PATCH] More progress --- .../cli/src/__tests__/EscapeKeyCancel.test.ts | 187 ++++++++++++++++++ apps/cli/src/ui/App.tsx | 89 +++++++-- .../cli/src/ui/components/ChatHistoryItem.tsx | 12 +- apps/cli/src/ui/components/Header.tsx | 2 +- apps/cli/src/ui/components/ScrollArea.tsx | 15 +- .../autocomplete/AutocompleteInput.tsx | 32 ++- .../components/autocomplete/PickerSelect.tsx | 125 +++++++++--- .../autocomplete/useAutocompletePicker.ts | 88 +++++++-- apps/cli/src/ui/utils/theme.ts | 102 +++++----- src/core/task/Task.ts | 26 ++- 10 files changed, 563 insertions(+), 115 deletions(-) create mode 100644 apps/cli/src/__tests__/EscapeKeyCancel.test.ts diff --git a/apps/cli/src/__tests__/EscapeKeyCancel.test.ts b/apps/cli/src/__tests__/EscapeKeyCancel.test.ts new file mode 100644 index 0000000000..c4e1cac568 --- /dev/null +++ b/apps/cli/src/__tests__/EscapeKeyCancel.test.ts @@ -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) + }) + }) +}) diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index e3be3f432f..cf404fa4fc 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -201,6 +201,22 @@ function AppInner({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const followupAutocompleteRef = useRef>(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>(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[] => { 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 ? ( {view === "ToolUse" ? "Using tool" : "Thinking"} + + Esc to cancel {isScrollAreaActive && ( <> @@ -1044,6 +1109,7 @@ function AppInner({ renderItem={getPickerRenderItem()} emptyMessage={pickerState.activeTrigger?.emptyMessage} isActive={isInputAreaActive && pickerState.isOpen} + isLoading={pickerState.isLoading} /> ) : ( @@ -1086,6 +1152,7 @@ function AppInner({ renderItem={getPickerRenderItem()} emptyMessage={pickerState.activeTrigger?.emptyMessage} isActive={isInputAreaActive && pickerState.isOpen} + isLoading={pickerState.isLoading} /> ) : ( diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index 06fc7ce68a..487b94c484 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -10,14 +10,14 @@ interface ChatHistoryItemProps { } function ChatHistoryItem({ message }: ChatHistoryItemProps) { - const content = message.content || "" + const content = message.content || "..." switch (message.role) { case "user": return ( - - user + + You said: {content} @@ -28,8 +28,8 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) { case "assistant": return ( - - roo + + Roo said: {content} @@ -41,7 +41,7 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) { return ( - thinking + Roo is thinking: {content} diff --git a/apps/cli/src/ui/components/Header.tsx b/apps/cli/src/ui/components/Header.tsx index ab1458fa89..e200544934 100644 --- a/apps/cli/src/ui/components/Header.tsx +++ b/apps/cli/src/ui/components/Header.tsx @@ -44,7 +44,7 @@ function Header({ model, cwd, mode, reasoningEffort, version, tokenUsage, contex - {ASCII_ROO} + {ASCII_ROO} Workspace: {displayCwd} diff --git a/apps/cli/src/ui/components/ScrollArea.tsx b/apps/cli/src/ui/components/ScrollArea.tsx index 9bad5ee92d..769894b1ac 100644 --- a/apps/cli/src/ui/components/ScrollArea.tsx +++ b/apps/cli/src/ui/components/ScrollArea.tsx @@ -193,6 +193,8 @@ export function ScrollArea({ const innerRef = useRef(null) const lastMeasuredHeight = useRef(0) + // Track previous scrollToLineTrigger to detect actual changes (allows scrolling to index 0) + const prevScrollToLineTriggerRef = useRef(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")} diff --git a/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx index 2183c9d9a7..b1b7e0dab4 100644 --- a/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx +++ b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx @@ -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( 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 diff --git a/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx b/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx index bc826584ba..ac17b766f0 100644 --- a/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx +++ b/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx @@ -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 { @@ -23,11 +22,64 @@ export interface PickerSelectProps { 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({ renderItem, emptyMessage = "No results found", isActive = true, + isLoading = false, }: PickerSelectProps) { - // 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({ if (key.return) { const selected = results[selectedIndex] - if (selected) { onSelect(selected) } - return } @@ -86,29 +142,48 @@ export function PickerSelect({ { 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 ( - - {emptyMessage} + + {message} ) } - // 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 ( - - {results.map((result, index) => { - const isSelected = index === selectedIndex + + {/* Scroll indicator - more items above */} + {hasMoreAbove && ( + + ↑ {visibleWindow.from} more + + )} + + {/* Visible items */} + {visibleItems.map((result, visibleIndex) => { + const actualIndex = visibleWindow.from + visibleIndex + const isSelected = actualIndex === selectedIndex return {renderItem(result, isSelected)} })} - + + {/* Scroll indicator - more items below */} + {hasMoreBelow && ( + + ↓ {results.length - visibleWindow.to} more + + )} + ) } diff --git a/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts b/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts index 11682bc8bc..4b69ffcd7b 100644 --- a/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts +++ b/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts @@ -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( // 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( 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( 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( 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]) diff --git a/apps/cli/src/ui/utils/theme.ts b/apps/cli/src/ui/utils/theme.ts index abcd2f26b0..998f5810e3 100644 --- a/apps/cli/src/ui/utils/theme.ts +++ b/apps/cli/src/ui/utils/theme.ts @@ -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 diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 33e8245ccc..eb47f96b12 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1788,7 +1788,22 @@ export class Task extends EventEmitter 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 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 + }) } /**