diff --git a/apps/cli/src/__tests__/MultilineTextInput.test.ts b/apps/cli/src/__tests__/MultilineTextInput.test.ts index bc75438aa0..9b484535ed 100644 --- a/apps/cli/src/__tests__/MultilineTextInput.test.ts +++ b/apps/cli/src/__tests__/MultilineTextInput.test.ts @@ -328,6 +328,162 @@ describe("MultilineTextInput", () => { }) }) +describe("word-boundary line wrapping", () => { + // Represents a visual row after wrapping a logical line + interface VisualRow { + text: string + logicalLineIndex: number + isFirstRowOfLine: boolean + startCol: number + } + + /** + * Wrap a logical line into visual rows based on available width. + * Uses word-boundary wrapping: prefers to break at spaces rather than + * in the middle of words. + */ + function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: number): VisualRow[] { + if (availableWidth <= 0 || lineText.length <= availableWidth) { + return [ + { + text: lineText, + logicalLineIndex, + isFirstRowOfLine: true, + startCol: 0, + }, + ] + } + + const rows: VisualRow[] = [] + let remaining = lineText + let startCol = 0 + let isFirst = true + + while (remaining.length > 0) { + if (remaining.length <= availableWidth) { + rows.push({ + text: remaining, + logicalLineIndex, + isFirstRowOfLine: isFirst, + startCol, + }) + break + } + + // Find a good break point - prefer breaking at a space + let breakPoint = availableWidth + + // Look backwards from availableWidth for a space + const searchStart = Math.min(availableWidth, remaining.length) + let spaceIndex = -1 + for (let i = searchStart - 1; i >= 0; i--) { + if (remaining[i] === " ") { + spaceIndex = i + break + } + } + + if (spaceIndex > 0) { + // Found a space - break after it (include the space in this row) + breakPoint = spaceIndex + 1 + } + // else: no space found, break at availableWidth (mid-word break as fallback) + + const chunk = remaining.slice(0, breakPoint) + rows.push({ + text: chunk, + logicalLineIndex, + isFirstRowOfLine: isFirst, + startCol, + }) + + remaining = remaining.slice(breakPoint) + startCol += breakPoint + isFirst = false + } + + return rows + } + + it("should not wrap text shorter than available width", () => { + const rows = wrapLine("hello world", 0, 20) + expect(rows).toHaveLength(1) + expect(rows[0]!.text).toBe("hello world") + expect(rows[0]!.isFirstRowOfLine).toBe(true) + expect(rows[0]!.startCol).toBe(0) + }) + + it("should wrap at word boundary when possible", () => { + const rows = wrapLine("hello world foo", 0, 10) + expect(rows).toHaveLength(2) + expect(rows[0]!.text).toBe("hello ") + expect(rows[0]!.isFirstRowOfLine).toBe(true) + expect(rows[0]!.startCol).toBe(0) + expect(rows[1]!.text).toBe("world foo") + expect(rows[1]!.isFirstRowOfLine).toBe(false) + expect(rows[1]!.startCol).toBe(6) // "hello " is 6 chars + }) + + it("should break mid-word when no space found", () => { + const rows = wrapLine("superlongwordwithoutspaces", 0, 10) + expect(rows).toHaveLength(3) + // Falls back to breaking at availableWidth when no space is found + expect(rows[0]!.text).toBe("superlongw") + expect(rows[1]!.text).toBe("ordwithout") + expect(rows[2]!.text).toBe("spaces") + }) + + it("should handle multiple word wraps", () => { + const rows = wrapLine("one two three four five six", 0, 8) + expect(rows).toHaveLength(4) + expect(rows[0]!.text).toBe("one two ") + expect(rows[1]!.text).toBe("three ") + expect(rows[2]!.text).toBe("four ") + expect(rows[3]!.text).toBe("five six") + }) + + it("should preserve logical line index", () => { + const rows = wrapLine("hello world", 2, 6) + expect(rows.every((r) => r.logicalLineIndex === 2)).toBe(true) + }) + + it("should handle empty string", () => { + const rows = wrapLine("", 0, 10) + expect(rows).toHaveLength(1) + expect(rows[0]!.text).toBe("") + }) + + it("should handle string that exactly matches width", () => { + const rows = wrapLine("hello", 0, 5) + expect(rows).toHaveLength(1) + expect(rows[0]!.text).toBe("hello") + }) + + it("should track correct startCol for wrapped rows", () => { + const rows = wrapLine("aa bb cc dd", 0, 5) + // "aa bb cc dd" = 11 chars, width = 5 + // "aa bb cc dd": a(0) a(1) ' '(2) b(3) b(4) ' '(5) c(6) c(7) ' '(8) d(9) d(10) + // Search backwards from index 4: + // index 4='b', 3='b', 2=' ' -> space at 2, breakPoint=3 + // Row 0: "aa " (3 chars), startCol=0 + // Remaining: "bb cc dd" (8 chars), startCol=3 + // Search backwards from index 4: + // "bb cc dd": b(0) b(1) ' '(2) c(3) c(4)... + // index 4='c', 3='c', 2=' ' -> space at 2, breakPoint=3 + // Row 1: "bb " (3 chars), startCol=3 + // Remaining: "cc dd" (5 chars), startCol=6 + // 5 <= 5, fits in one row + // Row 2: "cc dd", startCol=6 + expect(rows).toHaveLength(3) + expect(rows[0]!.text).toBe("aa ") + expect(rows[0]!.startCol).toBe(0) + expect(rows[1]!.text).toBe("bb ") + expect(rows[1]!.startCol).toBe(3) + expect(rows[2]!.text).toBe("cc dd") + expect(rows[2]!.startCol).toBe(6) + }) +}) + describe("multi-line history integration", () => { it("should store multi-line entries with newlines", () => { const entry = "foo\nbar\nbaz" diff --git a/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts b/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts index 70e97cdd5b..89013123f8 100644 --- a/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts +++ b/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts @@ -58,10 +58,13 @@ describe("FileTrigger", () => { expect(result).toBeNull() }) - it("should return null when query is empty", () => { + it("should detect @ trigger even with empty query", () => { const result = trigger.detectTrigger("hello @") - expect(result).toBeNull() + expect(result).toEqual({ + query: "", + triggerIndex: 6, + }) }) it("should find last @ in line", () => { diff --git a/apps/cli/src/extension-host.ts b/apps/cli/src/extension-host.ts index 98f6580292..c93109892e 100644 --- a/apps/cli/src/extension-host.ts +++ b/apps/cli/src/extension-host.ts @@ -576,6 +576,7 @@ export class ExtensionHost extends EventEmitter { alwaysAllowFollowupQuestions: true, allowedCommands: ["*"], commandExecutionTimeout: 20, + enableCheckpoints: false, // Checkpoints disabled until CLI UI is implemented. } this.applyRuntimeSettings(settings) @@ -584,6 +585,7 @@ export class ExtensionHost extends EventEmitter { } else { const settings: RooCodeSettings = { autoApprovalEnabled: false, + enableCheckpoints: false, // Checkpoints disabled until CLI UI is implemented. } this.applyRuntimeSettings(settings) diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 5e8f0007ae..167f82e5f0 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -19,6 +19,7 @@ import { createFileTrigger, createSlashCommandTrigger, createModeTrigger, + createHelpTrigger, toFileResult, toSlashCommandResult, toModeResult, @@ -319,7 +320,7 @@ function AppInner({ }, []) // Create autocomplete triggers - // Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult) + // Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult) // 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. @@ -347,7 +348,9 @@ function AppInner({ getModes: () => availableModesRef.current.map(toModeResult), }) - return [fileTrigger, slashCommandTrigger, modeTrigger] + const helpTrigger = createHelpTrigger() + + return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger] }, [handleFileSearch]) // Only depend on handleFileSearch - data accessed via refs // Handle Ctrl+C, Tab for focus switching, and Escape to cancel task @@ -1016,6 +1019,8 @@ function AppInner({ ) : isScrollAreaActive ? ( + ) : isInputAreaActive ? ( + ? for shortcuts ) : null // Get render function for picker items based on active trigger diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index 487b94c484..21ab685af3 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -5,12 +5,21 @@ import * as theme from "../utils/theme.js" import type { TUIMessage } from "../types.js" import TodoDisplay from "./TodoDisplay.js" +/** + * Sanitize content for terminal display by: + * - Replacing tab characters with spaces (tabs expand to variable widths in terminals) + * - Stripping carriage returns that could cause display issues + */ +function sanitizeContent(text: string): string { + return text.replace(/\t/g, " ").replace(/\r/g, "") +} + interface ChatHistoryItemProps { message: TUIMessage } function ChatHistoryItem({ message }: ChatHistoryItemProps) { - const content = message.content || "..." + const content = sanitizeContent(message.content || "...") switch (message.role) { case "user": @@ -66,14 +75,8 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) { ) } - let toolContent = message.toolDisplayOutput || content - - // Replace tab characters with spaces to prevent terminal width miscalculation - // Tabs expand to variable widths in terminals, causing layout issues - toolContent = toolContent.replace(/\t/g, " ") - - // Also strip any carriage returns that could cause issues - toolContent = toolContent.replace(/\r/g, "") + // Sanitize toolDisplayOutput if present, otherwise use already-sanitized content + const toolContent = message.toolDisplayOutput ? sanitizeContent(message.toolDisplayOutput) : content return ( diff --git a/apps/cli/src/ui/components/MultilineTextInput.tsx b/apps/cli/src/ui/components/MultilineTextInput.tsx index 1dc255f95f..b2a0494d04 100644 --- a/apps/cli/src/ui/components/MultilineTextInput.tsx +++ b/apps/cli/src/ui/components/MultilineTextInput.tsx @@ -120,7 +120,9 @@ interface VisualRow { } /** - * Wrap a logical line into visual rows based on available width + * Wrap a logical line into visual rows based on available width. + * Uses word-boundary wrapping: prefers to break at spaces rather than + * in the middle of words. */ function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: number): VisualRow[] { if (availableWidth <= 0 || lineText.length <= availableWidth) { @@ -140,21 +142,47 @@ function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: nu let isFirst = true while (remaining.length > 0) { - const chunk = remaining.slice(0, availableWidth) + if (remaining.length <= availableWidth) { + // Remaining text fits in one row + rows.push({ + text: remaining, + logicalLineIndex, + isFirstRowOfLine: isFirst, + startCol, + }) + break + } + + // Find a good break point - prefer breaking at a space + let breakPoint = availableWidth + + // Look backwards from availableWidth for a space + const searchStart = Math.min(availableWidth, remaining.length) + let spaceIndex = -1 + for (let i = searchStart - 1; i >= 0; i--) { + if (remaining[i] === " ") { + spaceIndex = i + break + } + } + + if (spaceIndex > 0) { + // Found a space - break after it (include the space in this row) + breakPoint = spaceIndex + 1 + } + // else: no space found, break at availableWidth (mid-word break as fallback) + + const chunk = remaining.slice(0, breakPoint) rows.push({ text: chunk, logicalLineIndex, isFirstRowOfLine: isFirst, startCol, }) - remaining = remaining.slice(availableWidth) - startCol += availableWidth - isFirst = false - } - // If the line ends exactly at the width boundary, add an empty row for cursor - if (lineText.length > 0 && lineText.length % availableWidth === 0) { - // The last row already exists, no need to add empty row + remaining = remaining.slice(breakPoint) + startCol += breakPoint + isFirst = false } return rows @@ -366,10 +394,11 @@ export function MultilineTextInput({ (row: VisualRow, rowIndex: number) => { const isPlaceholder = !value && !isActive && row.logicalLineIndex === 0 const isFirstLine = row.logicalLineIndex === 0 - // Only show prefix on the first visual row of each logical line + // Only show prefix on the first visual row of each logical line: + // - First line gets the prompt (e.g., "> ") + // - User-created continuation lines (via Ctrl+Enter) get continuationIndent + // - Wrapped rows (same logical line) get no prefix to avoid copy artifacts const linePrefix = row.isFirstRowOfLine ? (isFirstLine ? prompt : continuationIndent) : "" - // Pad continuation rows to align with the text - const padding = !row.isFirstRowOfLine ? (isFirstLine ? prompt : continuationIndent) : "" // Check if cursor is on this visual row let hasCursor = false @@ -400,7 +429,7 @@ export function MultilineTextInput({ return ( - {linePrefix || padding} + {linePrefix} {beforeCursor} {cursorChar} {afterCursor} @@ -410,7 +439,7 @@ export function MultilineTextInput({ return ( - {linePrefix || padding} + {linePrefix} {row.text} ) diff --git a/apps/cli/src/ui/components/ScrollArea.tsx b/apps/cli/src/ui/components/ScrollArea.tsx index 769894b1ac..3a22656940 100644 --- a/apps/cli/src/ui/components/ScrollArea.tsx +++ b/apps/cli/src/ui/components/ScrollArea.tsx @@ -328,9 +328,10 @@ export function ScrollArea({ const showScrollbarVisible = showScrollbar && (scrollbar.maxScroll > 0 || isActive) // Scrollbar colors based on focus state - // When active: handle is bright purple, track is dim purple - // When inactive: handle is dim gray, track is very dim + // When active: handle is bright purple, track is muted + // When inactive: handle is dim gray, track is more muted const handleColor = isActive ? theme.scrollActiveColor : theme.dimText + const trackColor = theme.scrollTrackColor // When no height prop is provided, use flexGrow to fill available space const useFlexGrow = heightProp === undefined @@ -356,21 +357,22 @@ export function ScrollArea({ - {/* Scrollbar - rendered as a single Text element to avoid per-character wrapping issues */} + {/* Scrollbar - rendered with separate colors for handle and track */} {showScrollbar && ( - {showScrollbarVisible && height > 0 && ( - - {Array(height) - .fill(null) - .map((_, i) => { - const isHandle = - i >= scrollbar.handleStart && i < scrollbar.handleStart + scrollbar.handleHeight - return isHandle ? "┃" : "│" - }) - .join("\n")} - - )} + {showScrollbarVisible && + height > 0 && + Array(height) + .fill(null) + .map((_, i) => { + const isHandle = + i >= scrollbar.handleStart && i < scrollbar.handleStart + scrollbar.handleHeight + return ( + + {isHandle ? "┃" : "│"} + + ) + })} )} diff --git a/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx b/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx new file mode 100644 index 0000000000..c2f9e6374b --- /dev/null +++ b/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx @@ -0,0 +1,234 @@ +import { render } from "ink-testing-library" + +import type { TUIMessage } from "../../types.js" +import ChatHistoryItem from "../ChatHistoryItem.js" + +describe("ChatHistoryItem", () => { + describe("content sanitization", () => { + it("sanitizes tabs in user messages", () => { + const message: TUIMessage = { + id: "1", + role: "user", + content: "function test() {\n\treturn true;\n}", + } + + const { lastFrame } = render() + const output = lastFrame() + + // Tabs should be replaced with 4 spaces + expect(output).toContain("function test() {") + expect(output).toContain(" return true;") // Tab replaced with 4 spaces + expect(output).not.toContain("\t") + }) + + it("sanitizes tabs in assistant messages", () => { + const message: TUIMessage = { + id: "2", + role: "assistant", + content: "Here's the code:\n\tconst x = 1;", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain(" const x = 1;") + expect(output).not.toContain("\t") + }) + + it("sanitizes tabs in thinking messages", () => { + const message: TUIMessage = { + id: "3", + role: "thinking", + content: "Looking at:\n\tMarkdown example:\n\t```ts\n\t\tfunction foo() {}\n\t```", + } + + const { lastFrame } = render() + const output = lastFrame() + + // All tabs should be converted to spaces + expect(output).not.toContain("\t") + expect(output).toContain(" Markdown example:") + expect(output).toContain(" function foo() {}") // Double-indented + }) + + it("sanitizes tabs in tool messages", () => { + const message: TUIMessage = { + id: "4", + role: "tool", + content: '{\n\t"key": "value"\n}', + toolName: "read_file", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain(' "key": "value"') + expect(output).not.toContain("\t") + }) + + it("sanitizes tabs in tool messages with toolDisplayOutput", () => { + const message: TUIMessage = { + id: "5", + role: "tool", + content: "raw content", + toolDisplayOutput: "function() {\n\treturn;\n}", + toolName: "execute_command", + } + + const { lastFrame } = render() + const output = lastFrame() + + // toolDisplayOutput should be used and sanitized + expect(output).toContain(" return;") + expect(output).not.toContain("\t") + }) + + it("sanitizes tabs in system messages", () => { + const message: TUIMessage = { + id: "6", + role: "system", + content: "System info:\n\tCPU: high", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain(" CPU: high") + expect(output).not.toContain("\t") + }) + + it("strips carriage returns from content", () => { + const message: TUIMessage = { + id: "7", + role: "thinking", + content: "Line 1\r\nLine 2\rLine 3", + } + + const { lastFrame } = render() + const output = lastFrame() + + // Carriage returns should be stripped + expect(output).not.toContain("\r") + expect(output).toContain("Line 1") + expect(output).toContain("Line 2") + expect(output).toContain("Line 3") + }) + + it("strips carriage returns from toolDisplayOutput", () => { + const message: TUIMessage = { + id: "8", + role: "tool", + content: "raw", + toolDisplayOutput: "Output\r\nwith\rCR", + toolName: "test_tool", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).not.toContain("\r") + }) + + it("handles content with both tabs and carriage returns", () => { + const message: TUIMessage = { + id: "9", + role: "thinking", + content: "Code:\r\n\tfunction() {\r\n\t\treturn;\r\n\t}", + } + + const { lastFrame } = render() + const output = lastFrame() + + // Both should be sanitized + expect(output).not.toContain("\t") + expect(output).not.toContain("\r") + expect(output).toContain(" function()") + expect(output).toContain(" return;") // Double-indented + }) + }) + + describe("message rendering", () => { + it("renders user messages with correct header", () => { + const message: TUIMessage = { + id: "1", + role: "user", + content: "Hello", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("You said:") + expect(output).toContain("Hello") + }) + + it("renders assistant messages with correct header", () => { + const message: TUIMessage = { + id: "2", + role: "assistant", + content: "Hi there", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("Roo said:") + expect(output).toContain("Hi there") + }) + + it("renders thinking messages with correct header", () => { + const message: TUIMessage = { + id: "3", + role: "thinking", + content: "Let me think...", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("Roo is thinking:") + expect(output).toContain("Let me think...") + }) + + it("renders tool messages with tool name", () => { + const message: TUIMessage = { + id: "4", + role: "tool", + content: "Output", + toolName: "read_file", + toolDisplayName: "Read File", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("tool - Read File") + expect(output).toContain("Output") + }) + + it("uses fallback content when message.content is empty", () => { + const message: TUIMessage = { + id: "5", + role: "assistant", + content: "", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("...") + }) + + it("returns null for unknown role", () => { + const message = { + id: "6", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + role: "unknown" as any, + content: "Test", + } + + const { lastFrame } = render() + expect(lastFrame()).toBe("") + }) + }) +}) diff --git a/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx index b1b7e0dab4..0773ffdb9a 100644 --- a/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx +++ b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx @@ -136,18 +136,21 @@ function AutocompleteInputInner( */ const handleChange = useCallback( (value: string) => { - setInputValue(value) - // Check for trigger activation const lastLine = getLastLine(value) - pickerActions.handleInputChange(value, lastLine) + const result = pickerActions.handleInputChange(value, lastLine) + + // If trigger consumes its character, use the consumed value instead + const effectiveValue = result.consumedValue ?? value + + setInputValue(effectiveValue) // If user types while browsing history, exit browsing mode // This prevents the history effect from overwriting their edits if (isBrowsing) { - resetBrowsing(value) + resetBrowsing(effectiveValue) } else { - setDraft(value) + setDraft(effectiveValue) } }, [pickerActions, isBrowsing, setDraft, getLastLine, resetBrowsing], diff --git a/apps/cli/src/ui/components/autocomplete/index.ts b/apps/cli/src/ui/components/autocomplete/index.ts index e4ce4c4830..0d8621df1b 100644 --- a/apps/cli/src/ui/components/autocomplete/index.ts +++ b/apps/cli/src/ui/components/autocomplete/index.ts @@ -57,4 +57,6 @@ export { toModeResult, type ModeResult, type ModeTriggerConfig, + createHelpTrigger, + type HelpShortcutResult, } from "./triggers/index.js" diff --git a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx index 3bc8a00dd1..a2e2f1de8c 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx @@ -25,6 +25,16 @@ describe("FileTrigger", () => { expect(result).toEqual({ query: "fil", triggerIndex: 10 }) }) + it("should detect @ even without text after it", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const result = trigger.detectTrigger("@") + expect(result).toEqual({ query: "", triggerIndex: 0 }) + }) + it("should not detect @ followed by space", () => { const trigger = createFileTrigger({ onSearch: () => {}, diff --git a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx index 528fdfde7e..58fc42df44 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx @@ -88,11 +88,8 @@ export function createFileTrigger(config: FileTriggerConfig): AutocompleteTrigge return null } - // Require at least one character after @ - if (query.length === 0) { - return null - } - + // Unlike other triggers that only work at line-start, @ can appear anywhere + // and should show results even with an empty query (just "@" typed) return { query, triggerIndex: atIndex } }, diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx new file mode 100644 index 0000000000..b8c8ee0b02 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx @@ -0,0 +1,146 @@ +import { render } from "ink-testing-library" +import { describe, it, expect } from "vitest" + +import { createHelpTrigger, type HelpShortcutResult } from "./HelpTrigger.js" + +describe("HelpTrigger", () => { + describe("createHelpTrigger", () => { + it("should detect ? trigger at line start", () => { + const trigger = createHelpTrigger() + + const result = trigger.detectTrigger("?") + expect(result).toEqual({ query: "", triggerIndex: 0 }) + }) + + it("should detect ? trigger with query", () => { + const trigger = createHelpTrigger() + + const result = trigger.detectTrigger("?slash") + expect(result).toEqual({ query: "slash", triggerIndex: 0 }) + }) + + it("should detect ? trigger after whitespace", () => { + const trigger = createHelpTrigger() + + const result = trigger.detectTrigger(" ?") + expect(result).toEqual({ query: "", triggerIndex: 2 }) + }) + + it("should not detect ? in middle of text", () => { + const trigger = createHelpTrigger() + + // The trigger position is "line-start", so it should only match at start + const result = trigger.detectTrigger("some text ?") + expect(result).toBeNull() + }) + + it("should not detect ? followed by space", () => { + const trigger = createHelpTrigger() + + const result = trigger.detectTrigger("? ") + expect(result).toBeNull() + }) + + it("should return all shortcuts when query is empty", () => { + const trigger = createHelpTrigger() + + const results = trigger.search("") as HelpShortcutResult[] + expect(results.length).toBe(6) + expect(results.map((r) => r.shortcut)).toContain("/") + expect(results.map((r) => r.shortcut)).toContain("@") + expect(results.map((r) => r.shortcut)).toContain("!") + expect(results.map((r) => r.shortcut)).toContain("shift + ⏎") + expect(results.map((r) => r.shortcut)).toContain("tab") + expect(results.map((r) => r.shortcut)).toContain("ctrl + c") + }) + + it("should filter shortcuts by shortcut character", () => { + const trigger = createHelpTrigger() + + const results = trigger.search("/") as HelpShortcutResult[] + expect(results.length).toBe(1) + expect(results[0]?.shortcut).toBe("/") + }) + + it("should filter shortcuts by description", () => { + const trigger = createHelpTrigger() + + const results = trigger.search("file") as HelpShortcutResult[] + expect(results.length).toBe(1) + expect(results[0]?.shortcut).toBe("@") + expect(results[0]?.description).toContain("file") + }) + + it("should filter case-insensitively", () => { + const trigger = createHelpTrigger() + + const results = trigger.search("QUIT") as HelpShortcutResult[] + expect(results.length).toBe(1) + expect(results[0]?.shortcut).toBe("ctrl + c") + }) + + it("should return empty array for non-matching query", () => { + const trigger = createHelpTrigger() + + const results = trigger.search("xyz") as HelpShortcutResult[] + expect(results.length).toBe(0) + }) + + it("should generate replacement text for trigger shortcuts", () => { + const trigger = createHelpTrigger() + + const slashItem: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" } + const replacement = trigger.getReplacementText(slashItem, "?", 0) + expect(replacement).toBe("/") + }) + + it("should clear input for action shortcuts", () => { + const trigger = createHelpTrigger() + + const tabItem: HelpShortcutResult = { key: "focus", shortcut: "tab", description: "to toggle focus" } + const replacement = trigger.getReplacementText(tabItem, "?tab", 0) + expect(replacement).toBe("") + }) + + it("should render shortcut items correctly", () => { + const trigger = createHelpTrigger() + + const item: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" } + const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement) + + const output = lastFrame() + expect(output).toContain("/") + expect(output).toContain("for commands") + }) + + it("should render selected items with different styling", () => { + const trigger = createHelpTrigger() + + const item: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" } + const { lastFrame: unselectedFrame } = render(trigger.renderItem(item, false) as React.ReactElement) + const { lastFrame: selectedFrame } = render(trigger.renderItem(item, true) as React.ReactElement) + + // Both should contain the content + expect(unselectedFrame()).toContain("/") + expect(selectedFrame()).toContain("/") + }) + + it("should have correct trigger configuration", () => { + const trigger = createHelpTrigger() + + expect(trigger.id).toBe("help") + expect(trigger.triggerChar).toBe("?") + expect(trigger.position).toBe("line-start") + expect(trigger.emptyMessage).toBe("No matching shortcuts") + expect(trigger.debounceMs).toBe(0) + }) + + it("should have consumeTrigger set to true", () => { + const trigger = createHelpTrigger() + + // The ? character should be consumed (not inserted into input) + // when the help menu is triggered + expect(trigger.consumeTrigger).toBe(true) + }) + }) +}) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx new file mode 100644 index 0000000000..c34aea7951 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx @@ -0,0 +1,106 @@ +import { Box, Text } from "ink" + +import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js" + +/** + * Help shortcut result type. + * Represents a keyboard shortcut or trigger hint. + */ +export interface HelpShortcutResult extends AutocompleteItem { + /** The shortcut key or trigger character */ + shortcut: string + /** Description of what the shortcut does */ + description: string +} + +/** + * Built-in shortcuts to display in the help menu. + */ +const HELP_SHORTCUTS: HelpShortcutResult[] = [ + { key: "slash", shortcut: "/", description: "for commands" }, + { key: "at", shortcut: "@", description: "for file paths" }, + { key: "bang", shortcut: "!", description: "for modes" }, + { key: "newline", shortcut: "shift + ⏎", description: "for newline" }, + { key: "focus", shortcut: "tab", description: "to toggle focus" }, + { key: "quit", shortcut: "ctrl + c", description: "to quit" }, +] + +/** + * Create a help trigger for ? shortcuts menu. + * + * This trigger activates when the user types ? at the start of a line, + * and displays a menu of available keyboard shortcuts. + * + * @returns AutocompleteTrigger for help shortcuts + */ +export function createHelpTrigger(): AutocompleteTrigger { + return { + id: "help", + triggerChar: "?", + position: "line-start", + consumeTrigger: true, + + detectTrigger: (lineText: string): TriggerDetectionResult | null => { + // Check if line starts with ? (after optional whitespace) + const trimmed = lineText.trimStart() + + if (!trimmed.startsWith("?")) { + return null + } + + // Extract query after ? + const query = trimmed.substring(1) + + // Close picker if query contains space + if (query.includes(" ")) { + return null + } + + // Calculate trigger index (position of ? in original line) + const triggerIndex = lineText.length - trimmed.length + + return { query, triggerIndex } + }, + + search: (query: string): HelpShortcutResult[] => { + if (query.length === 0) { + // Show all shortcuts when just "?" is typed + return HELP_SHORTCUTS + } + + // Filter shortcuts based on query + const lowerQuery = query.toLowerCase() + return HELP_SHORTCUTS.filter( + (item) => + item.shortcut.toLowerCase().includes(lowerQuery) || + item.description.toLowerCase().includes(lowerQuery), + ) + }, + + renderItem: (item: HelpShortcutResult, isSelected: boolean) => { + return ( + + + + {item.shortcut} + + {item.description} + + + ) + }, + + getReplacementText: (item: HelpShortcutResult, _lineText: string, _triggerIndex: number): string => { + // When a shortcut is selected, replace with the trigger character + // For action shortcuts (tab, ctrl+c, shift+enter), just clear the input + if (["newline", "focus", "quit"].includes(item.key)) { + return "" + } + // For trigger shortcuts (/, @, !), insert the trigger character + return item.shortcut + }, + + emptyMessage: "No matching shortcuts", + debounceMs: 0, // No debounce needed for static list + } +} diff --git a/apps/cli/src/ui/components/autocomplete/triggers/index.ts b/apps/cli/src/ui/components/autocomplete/triggers/index.ts index 9faf2ed66f..2a2f86c2e9 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/index.ts +++ b/apps/cli/src/ui/components/autocomplete/triggers/index.ts @@ -12,3 +12,5 @@ export { } from "./SlashCommandTrigger.js" export { createModeTrigger, toModeResult, type ModeResult, type ModeTriggerConfig } from "./ModeTrigger.js" + +export { createHelpTrigger, type HelpShortcutResult } from "./HelpTrigger.js" diff --git a/apps/cli/src/ui/components/autocomplete/types.ts b/apps/cli/src/ui/components/autocomplete/types.ts index c5a27a72c6..bd51803fe7 100644 --- a/apps/cli/src/ui/components/autocomplete/types.ts +++ b/apps/cli/src/ui/components/autocomplete/types.ts @@ -97,6 +97,14 @@ export interface AutocompleteTrigger { /** Handle input value changes - detects triggers and initiates search */ - handleInputChange: (value: string, lineText: string) => void + handleInputChange: (value: string, lineText: string) => InputChangeResult /** Handle item selection - returns the new input value */ handleSelect: (item: T, fullValue: string, lineText: string) => string /** Close the picker */ diff --git a/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts b/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts index 4b69ffcd7b..f606750f7d 100644 --- a/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts +++ b/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts @@ -57,10 +57,24 @@ export function useAutocompletePicker( }, []) /** - * Handle input value changes - detects triggers and initiates search + * Get the input value with the trigger character removed. + * Used when a trigger has consumeTrigger: true. + */ + const getConsumedValue = useCallback((value: string, lastLine: string, triggerIndex: number): string => { + const lines = value.split("\n") + const lastLineIndex = lines.length - 1 + // Remove the trigger character from the last line + const newLastLine = lastLine.slice(0, triggerIndex) + lastLine.slice(triggerIndex + 1) + lines[lastLineIndex] = newLastLine + return lines.join("\n") + }, []) + + /** + * Handle input value changes - detects triggers and initiates search. + * Returns an object indicating if the input should be modified (for consumeTrigger). */ const handleInputChange = useCallback( - (value: string, lineText?: string) => { + (value: string, lineText?: string): { consumedValue?: string } => { const lastLine = lineText ?? getLastLine(value) // Check each trigger for activation @@ -89,7 +103,7 @@ export function useAutocompletePicker( triggerInfo: null, })) } - return + return {} } const { query } = foundTriggerInfo @@ -106,7 +120,11 @@ export function useAutocompletePicker( if (query === lastQuery && state.isOpen && state.activeTrigger?.id === foundTrigger.id) { // Same query, same trigger - no need to search again - return + // Still return consumed value if trigger consumes input + if (foundTrigger.consumeTrigger) { + return { consumedValue: getConsumedValue(value, lastLine, foundTriggerInfo.triggerIndex) } + } + return {} } // Determine if this is an async trigger (has refreshResults for external data) @@ -188,8 +206,15 @@ export function useAutocompletePicker( }, debounceMs) debounceTimersRef.current.set(foundTrigger.id, timer) + + // Return consumed value if trigger consumes input + if (foundTrigger.consumeTrigger) { + return { consumedValue: getConsumedValue(value, lastLine, foundTriggerInfo.triggerIndex) } + } + + return {} }, - [triggers, state.isOpen, state.activeTrigger?.id, getLastLine], + [triggers, state.isOpen, state.activeTrigger?.id, getLastLine, getConsumedValue], ) /** diff --git a/apps/cli/src/ui/utils/theme.ts b/apps/cli/src/ui/utils/theme.ts index 998f5810e3..bce18f7629 100644 --- a/apps/cli/src/ui/utils/theme.ts +++ b/apps/cli/src/ui/utils/theme.ts @@ -73,6 +73,7 @@ export const warningColor = hardcore.yellow // Yellow for warnings // Focus indicator colors export const focusColor = hardcore.cyan // Focus indicator (cyan accent) export const scrollActiveColor = hardcore.purple // Scroll area active indicator (purple) +export const scrollTrackColor = hardcore.surface1 // Muted scrollbar track color // Base text color export const text = hardcore.text // Standard text color