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