More progress

This commit is contained in:
cte 2026-01-06 23:09:38 -08:00
parent c95706e345
commit b6f571cadf
10 changed files with 563 additions and 115 deletions

View 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)
})
})
})

View file

@ -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>
) : (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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])

View file

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

View file

@ -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
})
}
/**