More progress

This commit is contained in:
cte 2026-01-07 02:13:01 -08:00
parent e85362fb11
commit 8072a90a7d
18 changed files with 800 additions and 58 deletions

View file

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

View file

@ -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", () => {

View file

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

View file

@ -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({
</Box>
) : isScrollAreaActive ? (
<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
) : isInputAreaActive ? (
<Text color={theme.dimText}>? for shortcuts</Text>
) : null
// Get render function for picker items based on active trigger

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>

View file

@ -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 (
<Box key={rowIndex}>
<Text dimColor={!isFirstLine || !row.isFirstRowOfLine}>{linePrefix || padding}</Text>
<Text dimColor={!isFirstLine}>{linePrefix}</Text>
<Text>{beforeCursor}</Text>
<Text inverse>{cursorChar}</Text>
<Text>{afterCursor}</Text>
@ -410,7 +439,7 @@ export function MultilineTextInput({
return (
<Box key={rowIndex}>
<Text dimColor={!isFirstLine || !row.isFirstRowOfLine}>{linePrefix || padding}</Text>
<Text dimColor={!isFirstLine}>{linePrefix}</Text>
<Text dimColor={isPlaceholder}>{row.text}</Text>
</Box>
)

View file

@ -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({
</Box>
</Box>
{/* Scrollbar - rendered as a single Text element to avoid per-character wrapping issues */}
{/* Scrollbar - rendered with separate colors for handle and track */}
{showScrollbar && (
<Box flexDirection="column" width={1} flexShrink={0} overflow="hidden">
{showScrollbarVisible && height > 0 && (
<Text wrap="truncate" color={handleColor}>
{Array(height)
.fill(null)
.map((_, i) => {
const isHandle =
i >= scrollbar.handleStart && i < scrollbar.handleStart + scrollbar.handleHeight
return isHandle ? "┃" : "│"
})
.join("\n")}
</Text>
)}
{showScrollbarVisible &&
height > 0 &&
Array(height)
.fill(null)
.map((_, i) => {
const isHandle =
i >= scrollbar.handleStart && i < scrollbar.handleStart + scrollbar.handleHeight
return (
<Text key={i} color={isHandle ? handleColor : trackColor}>
{isHandle ? "┃" : "│"}
</Text>
)
})}
</Box>
)}
</Box>

View file

@ -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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
expect(lastFrame()).toBe("")
})
})
})

View file

@ -136,18 +136,21 @@ function AutocompleteInputInner<T extends AutocompleteItem>(
*/
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],

View file

@ -57,4 +57,6 @@ export {
toModeResult,
type ModeResult,
type ModeTriggerConfig,
createHelpTrigger,
type HelpShortcutResult,
} from "./triggers/index.js"

View file

@ -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: () => {},

View file

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

View file

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

View file

@ -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<HelpShortcutResult> {
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 (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>
<Text bold color={isSelected ? "cyan" : "yellow"}>
{item.shortcut}
</Text>
<Text> {item.description}</Text>
</Text>
</Box>
)
},
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
}
}

View file

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

View file

@ -97,6 +97,14 @@ export interface AutocompleteTrigger<T extends AutocompleteItem = AutocompleteIt
* @default 150
*/
debounceMs?: number
/**
* Whether the trigger character should be consumed (not shown in input).
* When true, the trigger character is treated as a control character
* that activates the picker but doesn't appear in the text input.
* @default false
*/
consumeTrigger?: boolean
}
/**
@ -117,12 +125,20 @@ export interface AutocompletePickerState<T extends AutocompleteItem = Autocomple
triggerInfo: TriggerDetectionResult | null
}
/**
* Result from handleInputChange indicating if input should be modified.
*/
export interface InputChangeResult {
/** If set, the input value should be replaced with this value (trigger char consumed) */
consumedValue?: string
}
/**
* Actions returned by the useAutocompletePicker hook.
*/
export interface AutocompletePickerActions<T extends AutocompleteItem> {
/** 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 */

View file

@ -57,10 +57,24 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
}, [])
/**
* 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<T extends AutocompleteItem>(
triggerInfo: null,
}))
}
return
return {}
}
const { query } = foundTriggerInfo
@ -106,7 +120,11 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
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<T extends AutocompleteItem>(
}, 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],
)
/**

View file

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