mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
More progress
This commit is contained in:
parent
e85362fb11
commit
8072a90a7d
18 changed files with 800 additions and 58 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
234
apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx
Normal file
234
apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx
Normal 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("")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -57,4 +57,6 @@ export {
|
|||
toModeResult,
|
||||
type ModeResult,
|
||||
type ModeTriggerConfig,
|
||||
createHelpTrigger,
|
||||
type HelpShortcutResult,
|
||||
} from "./triggers/index.js"
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
106
apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx
Normal file
106
apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue