Skip to content

Commit 8072a90

Browse files
committed
More progress
1 parent e85362f commit 8072a90

18 files changed

+800
-58
lines changed

apps/cli/src/__tests__/MultilineTextInput.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,162 @@ describe("MultilineTextInput", () => {
328328
})
329329
})
330330

331+
describe("word-boundary line wrapping", () => {
332+
// Represents a visual row after wrapping a logical line
333+
interface VisualRow {
334+
text: string
335+
logicalLineIndex: number
336+
isFirstRowOfLine: boolean
337+
startCol: number
338+
}
339+
340+
/**
341+
* Wrap a logical line into visual rows based on available width.
342+
* Uses word-boundary wrapping: prefers to break at spaces rather than
343+
* in the middle of words.
344+
*/
345+
function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: number): VisualRow[] {
346+
if (availableWidth <= 0 || lineText.length <= availableWidth) {
347+
return [
348+
{
349+
text: lineText,
350+
logicalLineIndex,
351+
isFirstRowOfLine: true,
352+
startCol: 0,
353+
},
354+
]
355+
}
356+
357+
const rows: VisualRow[] = []
358+
let remaining = lineText
359+
let startCol = 0
360+
let isFirst = true
361+
362+
while (remaining.length > 0) {
363+
if (remaining.length <= availableWidth) {
364+
rows.push({
365+
text: remaining,
366+
logicalLineIndex,
367+
isFirstRowOfLine: isFirst,
368+
startCol,
369+
})
370+
break
371+
}
372+
373+
// Find a good break point - prefer breaking at a space
374+
let breakPoint = availableWidth
375+
376+
// Look backwards from availableWidth for a space
377+
const searchStart = Math.min(availableWidth, remaining.length)
378+
let spaceIndex = -1
379+
for (let i = searchStart - 1; i >= 0; i--) {
380+
if (remaining[i] === " ") {
381+
spaceIndex = i
382+
break
383+
}
384+
}
385+
386+
if (spaceIndex > 0) {
387+
// Found a space - break after it (include the space in this row)
388+
breakPoint = spaceIndex + 1
389+
}
390+
// else: no space found, break at availableWidth (mid-word break as fallback)
391+
392+
const chunk = remaining.slice(0, breakPoint)
393+
rows.push({
394+
text: chunk,
395+
logicalLineIndex,
396+
isFirstRowOfLine: isFirst,
397+
startCol,
398+
})
399+
400+
remaining = remaining.slice(breakPoint)
401+
startCol += breakPoint
402+
isFirst = false
403+
}
404+
405+
return rows
406+
}
407+
408+
it("should not wrap text shorter than available width", () => {
409+
const rows = wrapLine("hello world", 0, 20)
410+
expect(rows).toHaveLength(1)
411+
expect(rows[0]!.text).toBe("hello world")
412+
expect(rows[0]!.isFirstRowOfLine).toBe(true)
413+
expect(rows[0]!.startCol).toBe(0)
414+
})
415+
416+
it("should wrap at word boundary when possible", () => {
417+
const rows = wrapLine("hello world foo", 0, 10)
418+
expect(rows).toHaveLength(2)
419+
expect(rows[0]!.text).toBe("hello ")
420+
expect(rows[0]!.isFirstRowOfLine).toBe(true)
421+
expect(rows[0]!.startCol).toBe(0)
422+
expect(rows[1]!.text).toBe("world foo")
423+
expect(rows[1]!.isFirstRowOfLine).toBe(false)
424+
expect(rows[1]!.startCol).toBe(6) // "hello " is 6 chars
425+
})
426+
427+
it("should break mid-word when no space found", () => {
428+
const rows = wrapLine("superlongwordwithoutspaces", 0, 10)
429+
expect(rows).toHaveLength(3)
430+
// Falls back to breaking at availableWidth when no space is found
431+
expect(rows[0]!.text).toBe("superlongw")
432+
expect(rows[1]!.text).toBe("ordwithout")
433+
expect(rows[2]!.text).toBe("spaces")
434+
})
435+
436+
it("should handle multiple word wraps", () => {
437+
const rows = wrapLine("one two three four five six", 0, 8)
438+
expect(rows).toHaveLength(4)
439+
expect(rows[0]!.text).toBe("one two ")
440+
expect(rows[1]!.text).toBe("three ")
441+
expect(rows[2]!.text).toBe("four ")
442+
expect(rows[3]!.text).toBe("five six")
443+
})
444+
445+
it("should preserve logical line index", () => {
446+
const rows = wrapLine("hello world", 2, 6)
447+
expect(rows.every((r) => r.logicalLineIndex === 2)).toBe(true)
448+
})
449+
450+
it("should handle empty string", () => {
451+
const rows = wrapLine("", 0, 10)
452+
expect(rows).toHaveLength(1)
453+
expect(rows[0]!.text).toBe("")
454+
})
455+
456+
it("should handle string that exactly matches width", () => {
457+
const rows = wrapLine("hello", 0, 5)
458+
expect(rows).toHaveLength(1)
459+
expect(rows[0]!.text).toBe("hello")
460+
})
461+
462+
it("should track correct startCol for wrapped rows", () => {
463+
const rows = wrapLine("aa bb cc dd", 0, 5)
464+
// "aa bb cc dd" = 11 chars, width = 5
465+
// "aa bb cc dd": a(0) a(1) ' '(2) b(3) b(4) ' '(5) c(6) c(7) ' '(8) d(9) d(10)
466+
// Search backwards from index 4:
467+
// index 4='b', 3='b', 2=' ' -> space at 2, breakPoint=3
468+
// Row 0: "aa " (3 chars), startCol=0
469+
// Remaining: "bb cc dd" (8 chars), startCol=3
470+
// Search backwards from index 4:
471+
// "bb cc dd": b(0) b(1) ' '(2) c(3) c(4)...
472+
// index 4='c', 3='c', 2=' ' -> space at 2, breakPoint=3
473+
// Row 1: "bb " (3 chars), startCol=3
474+
// Remaining: "cc dd" (5 chars), startCol=6
475+
// 5 <= 5, fits in one row
476+
// Row 2: "cc dd", startCol=6
477+
expect(rows).toHaveLength(3)
478+
expect(rows[0]!.text).toBe("aa ")
479+
expect(rows[0]!.startCol).toBe(0)
480+
expect(rows[1]!.text).toBe("bb ")
481+
expect(rows[1]!.startCol).toBe(3)
482+
expect(rows[2]!.text).toBe("cc dd")
483+
expect(rows[2]!.startCol).toBe(6)
484+
})
485+
})
486+
331487
describe("multi-line history integration", () => {
332488
it("should store multi-line entries with newlines", () => {
333489
const entry = "foo\nbar\nbaz"

apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,13 @@ describe("FileTrigger", () => {
5858
expect(result).toBeNull()
5959
})
6060

61-
it("should return null when query is empty", () => {
61+
it("should detect @ trigger even with empty query", () => {
6262
const result = trigger.detectTrigger("hello @")
6363

64-
expect(result).toBeNull()
64+
expect(result).toEqual({
65+
query: "",
66+
triggerIndex: 6,
67+
})
6568
})
6669

6770
it("should find last @ in line", () => {

apps/cli/src/extension-host.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ export class ExtensionHost extends EventEmitter {
576576
alwaysAllowFollowupQuestions: true,
577577
allowedCommands: ["*"],
578578
commandExecutionTimeout: 20,
579+
enableCheckpoints: false, // Checkpoints disabled until CLI UI is implemented.
579580
}
580581

581582
this.applyRuntimeSettings(settings)
@@ -584,6 +585,7 @@ export class ExtensionHost extends EventEmitter {
584585
} else {
585586
const settings: RooCodeSettings = {
586587
autoApprovalEnabled: false,
588+
enableCheckpoints: false, // Checkpoints disabled until CLI UI is implemented.
587589
}
588590

589591
this.applyRuntimeSettings(settings)

apps/cli/src/ui/App.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
createFileTrigger,
2020
createSlashCommandTrigger,
2121
createModeTrigger,
22+
createHelpTrigger,
2223
toFileResult,
2324
toSlashCommandResult,
2425
toModeResult,
@@ -319,7 +320,7 @@ function AppInner({
319320
}, [])
320321

321322
// Create autocomplete triggers
322-
// Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult)
323+
// Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult)
323324
// IMPORTANT: We use refs here to avoid recreating triggers every time data changes.
324325
// This prevents the UI flash caused by: data change -> memo recreation -> re-render with stale state
325326
// The getResults/getCommands/getModes callbacks always read from refs to get fresh data.
@@ -347,7 +348,9 @@ function AppInner({
347348
getModes: () => availableModesRef.current.map(toModeResult),
348349
})
349350

350-
return [fileTrigger, slashCommandTrigger, modeTrigger]
351+
const helpTrigger = createHelpTrigger()
352+
353+
return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger]
351354
}, [handleFileSearch]) // Only depend on handleFileSearch - data accessed via refs
352355

353356
// Handle Ctrl+C, Tab for focus switching, and Escape to cancel task
@@ -1016,6 +1019,8 @@ function AppInner({
10161019
</Box>
10171020
) : isScrollAreaActive ? (
10181021
<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
1022+
) : isInputAreaActive ? (
1023+
<Text color={theme.dimText}>? for shortcuts</Text>
10191024
) : null
10201025

10211026
// Get render function for picker items based on active trigger

apps/cli/src/ui/components/ChatHistoryItem.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,21 @@ import * as theme from "../utils/theme.js"
55
import type { TUIMessage } from "../types.js"
66
import TodoDisplay from "./TodoDisplay.js"
77

8+
/**
9+
* Sanitize content for terminal display by:
10+
* - Replacing tab characters with spaces (tabs expand to variable widths in terminals)
11+
* - Stripping carriage returns that could cause display issues
12+
*/
13+
function sanitizeContent(text: string): string {
14+
return text.replace(/\t/g, " ").replace(/\r/g, "")
15+
}
16+
817
interface ChatHistoryItemProps {
918
message: TUIMessage
1019
}
1120

1221
function ChatHistoryItem({ message }: ChatHistoryItemProps) {
13-
const content = message.content || "..."
22+
const content = sanitizeContent(message.content || "...")
1423

1524
switch (message.role) {
1625
case "user":
@@ -66,14 +75,8 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) {
6675
)
6776
}
6877

69-
let toolContent = message.toolDisplayOutput || content
70-
71-
// Replace tab characters with spaces to prevent terminal width miscalculation
72-
// Tabs expand to variable widths in terminals, causing layout issues
73-
toolContent = toolContent.replace(/\t/g, " ")
74-
75-
// Also strip any carriage returns that could cause issues
76-
toolContent = toolContent.replace(/\r/g, "")
78+
// Sanitize toolDisplayOutput if present, otherwise use already-sanitized content
79+
const toolContent = message.toolDisplayOutput ? sanitizeContent(message.toolDisplayOutput) : content
7780

7881
return (
7982
<Box flexDirection="column" paddingX={1}>

apps/cli/src/ui/components/MultilineTextInput.tsx

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ interface VisualRow {
120120
}
121121

122122
/**
123-
* Wrap a logical line into visual rows based on available width
123+
* Wrap a logical line into visual rows based on available width.
124+
* Uses word-boundary wrapping: prefers to break at spaces rather than
125+
* in the middle of words.
124126
*/
125127
function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: number): VisualRow[] {
126128
if (availableWidth <= 0 || lineText.length <= availableWidth) {
@@ -140,21 +142,47 @@ function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: nu
140142
let isFirst = true
141143

142144
while (remaining.length > 0) {
143-
const chunk = remaining.slice(0, availableWidth)
145+
if (remaining.length <= availableWidth) {
146+
// Remaining text fits in one row
147+
rows.push({
148+
text: remaining,
149+
logicalLineIndex,
150+
isFirstRowOfLine: isFirst,
151+
startCol,
152+
})
153+
break
154+
}
155+
156+
// Find a good break point - prefer breaking at a space
157+
let breakPoint = availableWidth
158+
159+
// Look backwards from availableWidth for a space
160+
const searchStart = Math.min(availableWidth, remaining.length)
161+
let spaceIndex = -1
162+
for (let i = searchStart - 1; i >= 0; i--) {
163+
if (remaining[i] === " ") {
164+
spaceIndex = i
165+
break
166+
}
167+
}
168+
169+
if (spaceIndex > 0) {
170+
// Found a space - break after it (include the space in this row)
171+
breakPoint = spaceIndex + 1
172+
}
173+
// else: no space found, break at availableWidth (mid-word break as fallback)
174+
175+
const chunk = remaining.slice(0, breakPoint)
144176
rows.push({
145177
text: chunk,
146178
logicalLineIndex,
147179
isFirstRowOfLine: isFirst,
148180
startCol,
149181
})
150-
remaining = remaining.slice(availableWidth)
151-
startCol += availableWidth
152-
isFirst = false
153-
}
154182

155-
// If the line ends exactly at the width boundary, add an empty row for cursor
156-
if (lineText.length > 0 && lineText.length % availableWidth === 0) {
157-
// The last row already exists, no need to add empty row
183+
remaining = remaining.slice(breakPoint)
184+
startCol += breakPoint
185+
isFirst = false
158186
}
159187

160188
return rows
@@ -366,10 +394,11 @@ export function MultilineTextInput({
366394
(row: VisualRow, rowIndex: number) => {
367395
const isPlaceholder = !value && !isActive && row.logicalLineIndex === 0
368396
const isFirstLine = row.logicalLineIndex === 0
369-
// Only show prefix on the first visual row of each logical line
397+
// Only show prefix on the first visual row of each logical line:
398+
// - First line gets the prompt (e.g., "> ")
399+
// - User-created continuation lines (via Ctrl+Enter) get continuationIndent
400+
// - Wrapped rows (same logical line) get no prefix to avoid copy artifacts
370401
const linePrefix = row.isFirstRowOfLine ? (isFirstLine ? prompt : continuationIndent) : ""
371-
// Pad continuation rows to align with the text
372-
const padding = !row.isFirstRowOfLine ? (isFirstLine ? prompt : continuationIndent) : ""
373402

374403
// Check if cursor is on this visual row
375404
let hasCursor = false
@@ -400,7 +429,7 @@ export function MultilineTextInput({
400429

401430
return (
402431
<Box key={rowIndex}>
403-
<Text dimColor={!isFirstLine || !row.isFirstRowOfLine}>{linePrefix || padding}</Text>
432+
<Text dimColor={!isFirstLine}>{linePrefix}</Text>
404433
<Text>{beforeCursor}</Text>
405434
<Text inverse>{cursorChar}</Text>
406435
<Text>{afterCursor}</Text>
@@ -410,7 +439,7 @@ export function MultilineTextInput({
410439

411440
return (
412441
<Box key={rowIndex}>
413-
<Text dimColor={!isFirstLine || !row.isFirstRowOfLine}>{linePrefix || padding}</Text>
442+
<Text dimColor={!isFirstLine}>{linePrefix}</Text>
414443
<Text dimColor={isPlaceholder}>{row.text}</Text>
415444
</Box>
416445
)

0 commit comments

Comments
 (0)