Compare commits

...
Author SHA1 Message Date
shey eb6af2d6da remove paste #n & fix 0 lines pasted issue 2026-02-18 13:37:50 -08:00
shey 1b9dd3172b actual fix for placeholder wrapping 2026-02-18 13:27:48 -08:00
shey de386dabb5 fix bug where pasted placeholder wraps 2026-02-17 16:19:06 -08:00
shey 7121023753 refactors 2026-02-17 12:27:36 -08:00
shey 573c4a8485 a better pasting experience 2026-02-17 12:19:09 -08:00
shey 333022bcc3 add changeset 2026-02-16 14:02:16 -08:00
shey dbce5059cb fix layout shifts 2026-02-16 13:24:40 -08:00
shey 5a7eadfe41 refactors 2026-02-16 12:17:47 -08:00
shey 9759f0656e treat wrapped long lines as separate lines... sort of 2026-02-16 11:00:53 -08:00
shey 0336c7ca36 better handling for line breaks 2026-02-16 10:49:57 -08:00
shey 1f88575066 do proper truncation 2026-02-13 16:02:40 -08:00
shey 4633a1368f init 2026-02-12 10:40:11 -08:00
3 changed files with 433 additions and 266 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Support for better pasting in CLI
+159 -126
View File
@@ -185,7 +185,9 @@ const SEARCH_DEBOUNCE_MS = 150
const RIPGREP_WARNING_DURATION_MS = 5000
const MAX_SEARCH_RESULTS = 15
const DEFAULT_CONTEXT_WINDOW = 200000
const PASTE_COLLAPSE_THRESHOLD = 100 // Characters before showing placeholder
const PASTE_COLLAPSE_THRESHOLD = 100 // Characters before collapsing into expandable placeholder
const PASTE_CHUNK_WINDOW_MS = 150 // Chunks arriving within this window are combined into one paste
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual placeholder updates to avoid flicker
const MAX_HISTORY_ITEMS = 20 // Max history items to navigate with up/down arrows
/**
@@ -326,17 +328,35 @@ function parseAskOptions(text: string): string[] {
return parts.options || []
}
/**
* Expand pasted text placeholders back to actual content
* Replaces [Pasted text #N +X lines] with the stored content
*/
/** Replace paste placeholders (e.g. `▸ 21 lines pasted #1`) with stored content. */
function expandPastedTexts(text: string, pastedTexts: Map<number, string>): string {
return text.replace(/\[Pasted text #(\d+) \+\d+ lines\]/g, (match, num) => {
const content = pastedTexts.get(Number.parseInt(num, 10))
return content ?? match
return text.replace(/▸ \d+ lines? pasted #(\d+)/g, (match, id) => {
return pastedTexts.get(Number.parseInt(id, 10)) ?? match
})
}
/** Find the paste placeholder surrounding or adjacent to `cursorPos`, if any. */
function findPlaceholderAtCursor(
text: string,
cursorPos: number,
): { pasteId: number; start: number; end: number; lineCount: number } | null {
const regex = /▸ (\d+) lines? pasted #(\d+)/g
let match
while ((match = regex.exec(text)) !== null) {
const start = match.index
const end = start + match[0].length
if (cursorPos >= start && cursorPos <= end) {
return {
pasteId: Number.parseInt(match[2], 10),
lineCount: Number.parseInt(match[1], 10),
start,
end,
}
}
}
return null
}
export const ChatView: React.FC<ChatViewProps> = ({
controller,
onExit,
@@ -357,6 +377,9 @@ export const ChatView: React.FC<ChatViewProps> = ({
// Prefer prop controller over context controller (memoized for stable reference in callbacks)
const ctrl = useMemo(() => controller || taskController, [controller, taskController])
// Get storage key for persisting input across remounts
const storageKey = useMemo(() => getInputStorageKey(ctrl, taskId), [ctrl, taskId])
// Input state - using hook for text editing with keyboard shortcuts
const {
text: textInput,
@@ -369,9 +392,6 @@ export const ChatView: React.FC<ChatViewProps> = ({
insertText: insertTextAtCursor,
} = useTextInput()
// Get storage key for persisting input across remounts
const storageKey = useMemo(() => getInputStorageKey(ctrl, taskId), [ctrl, taskId])
// Refs for text input and cursor position (used by useHomeEndKeys and to avoid stale closures in useInput)
const textInputRef = useRef(textInput)
textInputRef.current = textInput
@@ -387,19 +407,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
const [respondedToAsk, setRespondedToAsk] = useState<number | null>(null)
const [userScrolled, setUserScrolled] = useState(false)
// Pasted text storage - maps placeholder number to full pasted content
const [pastedTexts, setPastedTexts] = useState<Map<number, string>>(() => {
return inputStateStorage.get(storageKey)?.pastedTexts ?? new Map()
})
const pasteCounterRef = useRef<number>(inputStateStorage.get(storageKey)?.pasteCounter ?? 0)
// Track paste timing to combine chunks that arrive in rapid succession
const lastPasteTimeRef = useRef<number>(0)
const activePasteNumRef = useRef<number>(0)
const activePasteStartPosRef = useRef<number>(0) // Where the placeholder starts in the text
const activePasteLinesRef = useRef<number>(0) // Total line count for current paste
const pasteUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null) // Debounce placeholder updates
const PASTE_CHUNK_WINDOW_MS = 150 // Chunks within this window are combined into one paste
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
// Paste placeholder state — uses refs for synchronous read/write (no async state delays).
// pastedTextsRef maps paste ID → full content; the text model only stores the placeholder string.
const pastedTextsRef = useRef<Map<number, string>>(inputStateStorage.get(storageKey)?.pastedTexts ?? new Map())
const pasteCounterRef = useRef(inputStateStorage.get(storageKey)?.pasteCounter ?? 0)
const activePasteRef = useRef({ id: 0, lines: 0, lastTime: 0 })
const pasteUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Slash command state
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>([])
@@ -431,22 +444,22 @@ export const ChatView: React.FC<ChatViewProps> = ({
if (stored) {
setTextInput(stored.text)
setCursorPos(stored.cursorPos)
setPastedTexts(stored.pastedTexts)
pastedTextsRef.current = stored.pastedTexts
pasteCounterRef.current = stored.pasteCounter
}
}, [storageKey, setTextInput, setCursorPos])
// Persist input state to storage whenever it changes (survives remount)
useEffect(() => {
if (textInput || pastedTexts.size > 0) {
if (textInput || pastedTextsRef.current.size > 0) {
inputStateStorage.set(storageKey, {
text: textInput,
cursorPos,
pastedTexts: new Map(pastedTexts),
pastedTexts: new Map(pastedTextsRef.current),
pasteCounter: pasteCounterRef.current,
})
}
}, [storageKey, textInput, cursorPos, pastedTexts])
}, [storageKey, textInput, cursorPos])
// Task switch handling: when switching tasks via /history, we clear the terminal and
// increment a counter used as the root Box's key. This forces React to remount the tree,
@@ -517,12 +530,13 @@ export const ChatView: React.FC<ChatViewProps> = ({
// When switching from plan to act, include any text in the input box
// Text stays visible in the input - don't clear it
if (newMode === "act" && textInput.trim()) {
const expandedText = expandPastedTexts(textInput, pastedTexts)
// Expand any pasted text placeholders
const expandedText = expandPastedTexts(textInput, pastedTextsRef.current)
await ctrl.togglePlanActMode(newMode, { message: expandedText.trim() })
} else {
await ctrl.togglePlanActMode(newMode)
}
}, [mode, ctrl, textInput, pastedTexts])
}, [mode, ctrl, textInput])
// Clear the terminal view and reset task state (used by /clear and "Start New Task" button)
// This is async to ensure clearTask() completes before we remount, preventing race conditions
@@ -540,6 +554,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
clearState() // Force clear React state (bypasses empty messages check)
setTextInput("")
setCursorPos(0)
pastedTextsRef.current = new Map() // Clear stored pastes
pasteCounterRef.current = 0
// Clear persisted state
inputStateStorage.delete(storageKey)
@@ -801,12 +817,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
if (!ctrl?.task || !pendingAsk) return
// Expand any pasted text placeholders
const expandedText = text ? expandPastedTexts(text, pastedTexts) : text
const expandedText = text ? expandPastedTexts(text, pastedTextsRef.current) : text
setRespondedToAsk(pendingAsk.ts)
setTextInput("")
setCursorPos(0)
setPastedTexts(new Map()) // Clear stored pastes
pastedTextsRef.current = new Map() // Clear stored pastes
pasteCounterRef.current = 0
// Clear persisted state
inputStateStorage.delete(storageKey)
@@ -817,7 +833,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
// Controller may be disposed
}
},
[ctrl, pendingAsk, pastedTexts, storageKey],
[ctrl, pendingAsk, storageKey],
)
// Handle cancel/interrupt
@@ -897,16 +913,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
)
// Handle task submission (new task)
// Note: text should already have placeholders expanded by caller
const handleSubmit = useCallback(
async (text: string, images: string[]) => {
if (!ctrl || !text.trim()) return
// Expand any pasted text placeholders
const expandedText = expandPastedTexts(text, pastedTexts)
setTextInput("")
setCursorPos(0)
setPastedTexts(new Map()) // Clear stored pastes
pastedTextsRef.current = new Map() // Clear stored pastes
pasteCounterRef.current = 0
// Clear persisted state
inputStateStorage.delete(storageKey)
@@ -931,13 +945,13 @@ export const ChatView: React.FC<ChatViewProps> = ({
)
: []
const validImages = imageDataUrls.filter((img): img is string => img !== null)
setTerminalTitle(expandedText.trim())
await ctrl.initTask(expandedText.trim(), validImages.length > 0 ? validImages : undefined)
setTerminalTitle(text.trim())
await ctrl.initTask(text.trim(), validImages.length > 0 ? validImages : undefined)
} catch (_error) {
onError?.()
}
},
[ctrl, onError, pastedTexts, storageKey],
[ctrl, onError, storageKey],
)
// Auto-submit initial prompt if provided
@@ -1057,15 +1071,18 @@ export const ChatView: React.FC<ChatViewProps> = ({
// 1. Mouse escape sequences -> filtered out (from AsciiMotionCli tracking)
// 2. Option+arrow escape sequences -> word navigation (handleKeyboardSequence)
// 3. Option+arrow via key.meta -> word navigation (backup for when Ink parses it)
// 4. Panel open -> bail (let panel handle its own input)
// 5. Slash menu open -> menu navigation (up/down/tab/return/escape)
// 6. File menu open -> menu navigation (up/down/tab/return/escape)
// 7. History navigation -> up/down when input empty or matches history item
// 8. Button actions -> "1"/"2" keys when buttons shown and no text typed
// 9. Ask responses -> return to send, numbers for option selection
// 10. Ctrl shortcuts -> Ctrl+A/E/W/U (handleCtrlShortcut)
// 11. Large paste detection -> collapse into placeholder
// 12. Normal input -> tab (mode toggle), return (submit), backspace, arrows, text
// 4. Active paste continuation -> append chunks to existing paste during paste window
// 5. Small multi-line paste normalization -> normalize \r\n for small pastes (≤100 chars)
// 6. Large paste detection -> collapse into placeholder (>100 chars)
// 7. Space on placeholder -> expand placeholder inline
// 8. Panel open -> bail (let panel handle its own input)
// 9. Slash menu open -> menu navigation (up/down/tab/return/escape)
// 10. File menu open -> menu navigation (up/down/tab/return/escape)
// 11. History navigation -> up/down when input empty or matches history item
// 12. Button actions -> "1"/"2" keys when buttons shown and no text typed
// 13. Ask responses -> return to send, numbers for option selection
// 14. Ctrl shortcuts -> Ctrl+A/E/W/U (handleCtrlShortcut)
// 15. Normal input -> tab (mode toggle), return (submit), backspace, arrows, text
//
// Note: Home/End keys are handled separately by useHomeEndKeys hook because
// Ink doesn't expose them in useInput (it sets input='' for these keys).
@@ -1093,7 +1110,76 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
}
// 4. When a panel is open, let the panel handle its own input
// 4. Active paste continuation: chunks arriving rapidly after a large paste
// get appended to the stored content rather than inserted as visible text.
// Must run before newline normalization (step 5) to prevent placeholder corruption.
if (input.length > 1 && activePasteRef.current.id > 0) {
const elapsed = Date.now() - activePasteRef.current.lastTime
if (elapsed < PASTE_CHUNK_WINDOW_MS) {
activePasteRef.current.lastTime = Date.now()
const { id, lines } = activePasteRef.current
const normalized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const newLines = normalized.match(/\n/g)?.length || 0
activePasteRef.current.lines = lines + newLines
const existing = pastedTextsRef.current.get(id) || ""
pastedTextsRef.current.set(id, existing + normalized)
// Debounce visual update to avoid flicker while chunks stream in
if (pasteUpdateTimeoutRef.current) clearTimeout(pasteUpdateTimeoutRef.current)
pasteUpdateTimeoutRef.current = setTimeout(() => {
const totalLines = activePasteRef.current.lines + 1
const updated = `${totalLines} ${totalLines === 1 ? "line" : "lines"} pasted #${id}`
const newText = textInputRef.current.replace(new RegExp(`\\d+ lines? pasted #${id}`), updated)
textInputRef.current = newText
setTextInput(newText)
}, PASTE_UPDATE_DEBOUNCE_MS)
return
}
}
// 5. Small multi-line paste: normalize \r\n → \n and insert directly.
// Large pastes (>threshold) fall through to step 6 for placeholder creation.
if (input.length > 1 && input.length <= PASTE_COLLAPSE_THRESHOLD && /[\r\n]/.test(input)) {
insertTextAtCursor(input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"))
return
}
// 6. Large paste detection → collapse into an expandable placeholder.
if (input && input.length > PASTE_COLLAPSE_THRESHOLD) {
const normalized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pasteId = ++pasteCounterRef.current
const newlineCount = normalized.match(/\n/g)?.length || 0
const lineCount = newlineCount + 1
activePasteRef.current = { id: pasteId, lines: newlineCount, lastTime: Date.now() }
pastedTextsRef.current.set(pasteId, normalized)
const placeholder = `${lineCount} ${lineCount === 1 ? "line" : "lines"} pasted #${pasteId}`
const pos = cursorPosRef.current
const newText = textInputRef.current.slice(0, pos) + placeholder + " " + textInputRef.current.slice(pos)
textInputRef.current = newText
setTextInput(newText)
return
}
// 7. Space inside placeholder → expand to full content.
// Strictly inside (not at edges) so space before/after the placeholder inserts normally.
if (input === " " && !key.ctrl && !key.meta) {
const ph = findPlaceholderAtCursor(textInputRef.current, cursorPosRef.current)
if (ph && cursorPosRef.current > ph.start && cursorPosRef.current < ph.end) {
const content = pastedTextsRef.current.get(ph.pasteId)
if (content) {
const newText = textInputRef.current.slice(0, ph.start) + content + textInputRef.current.slice(ph.end)
textInputRef.current = newText
setTextInput(newText)
return
}
}
}
// 8. When a panel is open, let the panel handle its own input
if (activePanel) {
return
}
@@ -1101,7 +1187,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
const inSlashMenu = slashInfo.inSlashMode && filteredCommands.length > 0 && !slashMenuDismissed
const inFileMenu = mentionInfo.inMentionMode && fileResults.length > 0 && !inSlashMenu
// 5. Slash command menu navigation (takes priority over file menu)
// 9. Slash command menu navigation (takes priority over file menu)
if (inSlashMenu) {
if (key.upArrow) {
setSelectedSlashIndex((i) => Math.max(0, i - 1))
@@ -1181,7 +1267,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
}
// 6. File mention menu navigation
// 10. File mention menu navigation
if (inFileMenu) {
if (key.upArrow) {
setSelectedIndex((i) => Math.max(0, i - 1))
@@ -1209,7 +1295,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
}
// 7. History navigation with up/down arrows
// 11. History navigation with up/down arrows
// Only works when: input is empty, or input matches the currently selected history item
if (key.upArrow && !inSlashMenu && !inFileMenu) {
const historyItems = getHistoryItems()
@@ -1259,7 +1345,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
}
// 8. Handle button actions (1 for primary, 2 for secondary)
// 12. Handle button actions (1 for primary, 2 for secondary)
// Only when buttons are enabled, not streaming, and no text has been typed
if (
buttonConfig.enableButtons &&
@@ -1287,7 +1373,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
}
// 9. Handle ask responses for options and text input
// 13. Handle ask responses for options and text input
if (pendingAsk && !isYoloSuppressed(yolo, pendingAsk.ask as ClineAsk | undefined)) {
// Allow sending text message for any ask type where sending is enabled
if (key.return && textInput.trim() && !buttonConfig.sendingDisabled) {
@@ -1305,77 +1391,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
}
// 10. Handle Ctrl+ shortcuts (Ctrl+A, Ctrl+E, Ctrl+W, etc.)
// 14. Handle Ctrl+ shortcuts (Ctrl+A, Ctrl+E, Ctrl+W, etc.)
if (key.ctrl && input && handleCtrlShortcut(input)) {
return
}
// 11. Detect paste by checking if input length exceeds threshold
// Large pastes mess up the terminal UI, so we collapse them into a placeholder
// Terminal sends large pastes in multiple chunks, so we combine chunks that arrive rapidly
if (input && input.length > PASTE_COLLAPSE_THRESHOLD) {
const now = Date.now()
const timeSinceLastPaste = now - lastPasteTimeRef.current
lastPasteTimeRef.current = now
// Check if this is a continuation of a recent paste (within time window)
if (timeSinceLastPaste < PASTE_CHUNK_WINDOW_MS && activePasteNumRef.current > 0) {
// Append to existing paste content (store immediately, don't lose data)
const pasteNum = activePasteNumRef.current
const chunkLines = input.match(/[\r\n]/g)?.length || 0
activePasteLinesRef.current += chunkLines
setPastedTexts((prev) => {
const next = new Map(prev)
const existing = next.get(pasteNum) || ""
next.set(pasteNum, existing + input)
return next
})
// Debounce the visual update to avoid flicker while chunks are arriving
if (pasteUpdateTimeoutRef.current) {
clearTimeout(pasteUpdateTimeoutRef.current)
}
pasteUpdateTimeoutRef.current = setTimeout(() => {
const newPlaceholder = `[Pasted text #${pasteNum} +${activePasteLinesRef.current} lines]`
const pattern = new RegExp(`\\[Pasted text #${pasteNum} \\+\\d+ lines\\]`)
const newText = textInputRef.current.replace(pattern, newPlaceholder)
textInputRef.current = newText // Update ref immediately so setCursorPos bounds check works
setTextInput(newText)
// Update cursor to be right after the placeholder
setCursorPos(activePasteStartPosRef.current + newPlaceholder.length)
Logger.info(`Paste #${pasteNum} complete: ${activePasteLinesRef.current} lines`)
}, PASTE_UPDATE_DEBOUNCE_MS)
return // Don't add another placeholder
}
// New paste operation - create placeholder
pasteCounterRef.current += 1
const pasteNum = pasteCounterRef.current
activePasteNumRef.current = pasteNum
const currentCursorPos = cursorPosRef.current // Use ref to avoid stale closure
activePasteStartPosRef.current = currentCursorPos // Track where placeholder starts
// Count line breaks in the pasted content (handle both \n and \r)
const extraLines = input.match(/[\r\n]/g)?.length || 0
activePasteLinesRef.current = extraLines // Track total lines
const placeholder = `[Pasted text #${pasteNum} +${extraLines} lines]`
// Store the full content
setPastedTexts((prev) => {
const next = new Map(prev)
next.set(pasteNum, input)
return next
})
const newText =
textInputRef.current.slice(0, currentCursorPos) + placeholder + textInputRef.current.slice(currentCursorPos)
textInputRef.current = newText // Update ref immediately so setCursorPos bounds check works
setTextInput(newText)
setCursorPos(currentCursorPos + placeholder.length)
return // Exit early - don't also add the raw input via normal handling below
}
// 12. Normal input handling
// 15. Normal input handling
if (key.shift && key.tab) {
toggleAutoApproveAll()
return
@@ -1385,12 +1406,24 @@ export const ChatView: React.FC<ChatViewProps> = ({
return
}
if (key.return && !mentionInfo.inMentionMode && !slashInfo.inSlashMode && !pendingAsk && !isSpinnerActive) {
if (prompt.trim() || imagePaths.length > 0) {
handleSubmit(prompt.trim(), imagePaths)
// Expand placeholders before submitting
const expandedPrompt = expandPastedTexts(prompt, pastedTextsRef.current)
if (expandedPrompt.trim() || imagePaths.length > 0) {
handleSubmit(expandedPrompt.trim(), imagePaths)
}
return
}
if (key.backspace || key.delete) {
// If cursor is inside a placeholder, delete the whole placeholder as a unit
const ph = findPlaceholderAtCursor(textInputRef.current, cursorPosRef.current)
if (ph) {
const newText = textInputRef.current.slice(0, ph.start) + textInputRef.current.slice(ph.end)
textInputRef.current = newText
setTextInput(() => newText)
setCursorPos(ph.start)
pastedTextsRef.current.delete(ph.pasteId)
return
}
deleteCharBefore()
return
}
@@ -1411,7 +1444,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
setCursorPos(moveCursorDown(textInputRef.current, cursorPosRef.current))
return
}
// Normal input (single char or short paste)
// Normal input (including pastes)
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
insertTextAtCursor(input)
}
@@ -1512,7 +1545,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
paddingLeft={1}
paddingRight={1}
width="100%">
<Box>
<Box width="100%">
{inputPrompt && <Text color={borderColor}>{inputPrompt} </Text>}
<HighlightedInput
availableCommands={availableCommands.map((c) => c.name)}
+269 -140
View File
@@ -1,189 +1,318 @@
/**
* Highlighted input component for CLI
* Renders text with @ mentions and / commands highlighted, plus a movable cursor
* Renders text with @ mentions, / commands, and paste placeholders highlighted,
* plus a movable cursor. For long multi-line input, only a viewport window of
* lines is rendered, centered on the cursor position, with scroll indicators.
*/
import { mentionRegexGlobal } from "@shared/context-mentions"
import { Text } from "ink"
import { Box, Text } from "ink"
import React from "react"
// ── Constants ──────────────────────────────────────────────────────────────────
const DEFAULT_MAX_LINES = 10
const SLASH_COMMAND_REGEX = /(^|\s)(\/[a-zA-Z0-9_.-]+)/g
const PASTE_PLACEHOLDER_REGEX = /▸ \d+ lines? pasted #\d+/g
const PLACEHOLDER_HINT = " (space to expand...)"
// ── Types ──────────────────────────────────────────────────────────────────────
interface HighlightedInputProps {
text: string
cursorPos?: number
availableCommands?: string[]
/** Max visible lines before viewport windowing kicks in. Defaults to 10. */
maxLines?: number
}
// Regex for / commands (at start or after whitespace)
const slashCommandRegex = /(^|\s)(\/[a-zA-Z0-9_.-]+)/g
type SegmentType = "normal" | "mention" | "command" | "placeholder"
interface Segment {
text: string
type: "normal" | "mention" | "command"
type: SegmentType
startIndex: number
}
function parseInput(text: string, availableCommands?: string[]): Segment[] {
const highlights: { start: number; end: number; type: "mention" | "command" }[] = []
interface Highlight {
start: number
end: number
type: "mention" | "command" | "placeholder"
}
// Find all mentions
mentionRegexGlobal.lastIndex = 0
interface ViewportInfo {
viewportText: string
adjustedCursorPos: number
linesAbove: number
linesBelow: number
}
// ── Viewport Windowing ─────────────────────────────────────────────────────────
function calculateVisualLineCounts(lines: string[], contentWidth: number): number[] {
return lines.map((line) => Math.max(1, Math.ceil(line.length / contentWidth)))
}
function findCursorLine(lines: string[], cursorPos: number): number {
let charCount = 0
for (let i = 0; i < lines.length; i++) {
if (cursorPos <= charCount + lines[i].length) return i
charCount += lines[i].length + 1
}
return lines.length - 1
}
function calculateCharOffset(lines: string[], upToLineIndex: number): number {
let offset = 0
for (let i = 0; i < upToLineIndex; i++) {
offset += lines[i].length + 1
}
return offset
}
/**
* Greedily expand a viewport window around the cursor line to fill a visual line budget.
* Expands below first, then above with any remaining budget.
*/
function expandViewportWindow(
cursorLine: number,
visualCounts: number[],
maxLines: number,
totalLines: number,
): [start: number, end: number] {
let viewStart = cursorLine
let viewEnd = cursorLine + 1
let budget = maxLines - visualCounts[cursorLine]
for (let i = viewEnd; i < totalLines && budget > 0; i++) {
if (visualCounts[i] > budget) break
budget -= visualCounts[i]
viewEnd = i + 1
}
for (let i = viewStart - 1; i >= 0 && budget > 0; i--) {
if (visualCounts[i] > budget) break
budget -= visualCounts[i]
viewStart = i
}
return [viewStart, viewEnd]
}
/**
* Compute a viewport window of `maxLines` visual lines centered on the cursor.
* Returns null if the entire text fits without windowing.
*/
function computeViewport(text: string, cursorPos: number, maxLines: number): ViewportInfo | null {
const lines = text.split(/\r?\n|\r/)
const contentWidth = Math.max(1, (process.stdout.columns || 80) - 4)
const visualCounts = calculateVisualLineCounts(lines, contentWidth)
const totalVisualLines = visualCounts.reduce((sum, c) => sum + c, 0)
if (totalVisualLines <= maxLines) return null
const cursorLine = findCursorLine(lines, cursorPos)
// First pass: reserve space for both scroll indicators (worst case)
let [viewStart, viewEnd] = expandViewportWindow(cursorLine, visualCounts, maxLines - 2, lines.length)
// Second pass: reclaim space if fewer indicators are actually needed
const indicatorCount = (viewStart > 0 ? 1 : 0) + (viewEnd < lines.length ? 1 : 0)
if (indicatorCount < 2) {
;[viewStart, viewEnd] = expandViewportWindow(cursorLine, visualCounts, maxLines - indicatorCount, lines.length)
}
return {
viewportText: lines.slice(viewStart, viewEnd).join("\n"),
adjustedCursorPos: cursorPos - calculateCharOffset(lines, viewStart),
linesAbove: viewStart,
linesBelow: lines.length - viewEnd,
}
}
// ── Highlight Detection ────────────────────────────────────────────────────────
function findAllMatches(regex: RegExp, text: string, type: Highlight["type"]): Highlight[] {
regex.lastIndex = 0
const results: Highlight[] = []
let match
while ((match = mentionRegexGlobal.exec(text)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
type: "mention",
})
while ((match = regex.exec(text)) !== null) {
results.push({ start: match.index, end: match.index + match[0].length, type })
}
return results
}
// Find first slash command only (must be complete and valid)
slashCommandRegex.lastIndex = 0
const slashMatch = slashCommandRegex.exec(text)
if (slashMatch) {
const prefix = slashMatch[1] || ""
const commandText = slashMatch[2] // e.g., "/help"
const commandName = commandText.slice(1) // e.g., "help"
const commandStart = slashMatch.index + prefix.length
const commandEnd = commandStart + commandText.length
function findSlashCommand(text: string, availableCommands?: string[]): Highlight | null {
SLASH_COMMAND_REGEX.lastIndex = 0
const match = SLASH_COMMAND_REGEX.exec(text)
if (!match) return null
// Only highlight if command exists in available commands (or if no list provided)
if (!availableCommands || availableCommands.includes(commandName)) {
highlights.push({
start: commandStart,
end: commandEnd,
type: "command",
})
}
}
const prefix = match[1] || ""
const commandName = match[2].slice(1) // strip leading /
if (availableCommands && !availableCommands.includes(commandName)) return null
const start = match.index + prefix.length
return { start, end: start + match[2].length, type: "command" }
}
/**
* Parse text into segments with highlighted regions (mentions, commands, placeholders)
* and normal text filling the gaps.
*/
function parseInput(text: string, availableCommands?: string[]): Segment[] {
const highlights: Highlight[] = [
...findAllMatches(mentionRegexGlobal, text, "mention"),
...findAllMatches(PASTE_PLACEHOLDER_REGEX, text, "placeholder"),
]
const slashCmd = findSlashCommand(text, availableCommands)
if (slashCmd) highlights.push(slashCmd)
// Sort highlights by start position
highlights.sort((a, b) => a.start - b.start)
// Build segments
if (highlights.length === 0) {
return [{ text, type: "normal", startIndex: 0 }]
}
const segments: Segment[] = []
let lastIndex = 0
let cursor = 0
for (const highlight of highlights) {
// Skip overlapping highlights
if (highlight.start < lastIndex) continue
// Add normal text before this highlight
if (highlight.start > lastIndex) {
segments.push({
text: text.slice(lastIndex, highlight.start),
type: "normal",
startIndex: lastIndex,
})
for (const h of highlights) {
if (h.start < cursor) continue // skip overlapping
if (h.start > cursor) {
segments.push({ text: text.slice(cursor, h.start), type: "normal", startIndex: cursor })
}
// Add highlighted segment
segments.push({
text: text.slice(highlight.start, highlight.end),
type: highlight.type,
startIndex: highlight.start,
})
lastIndex = highlight.end
segments.push({ text: text.slice(h.start, h.end), type: h.type, startIndex: h.start })
cursor = h.end
}
// Add remaining text
if (lastIndex < text.length) {
segments.push({
text: text.slice(lastIndex),
type: "normal",
startIndex: lastIndex,
})
}
// Always ensure at least one segment exists for stable cursor rendering
if (segments.length === 0) {
segments.push({
text: text,
type: "normal",
startIndex: 0,
})
if (cursor < text.length) {
segments.push({ text: text.slice(cursor), type: "normal", startIndex: cursor })
}
return segments
}
export const HighlightedInput: React.FC<HighlightedInputProps> = ({ text, cursorPos, availableCommands }) => {
// If no cursor position provided, just render text with highlights (backward compatible)
if (cursorPos === undefined) {
if (!text) return null
const segments = parseInput(text, availableCommands)
// ── Segment Rendering ──────────────────────────────────────────────────────────
function renderSegment(segment: Segment, key: number): React.ReactElement {
if (segment.type === "placeholder") {
// Strip the internal #N ID from the displayed text
const displayText = segment.text.replace(/ #\d+$/, "")
return (
<Text>
{segments.map((segment, idx) => {
if (segment.type === "mention" || segment.type === "command") {
return (
<Text backgroundColor="gray" key={idx}>
{segment.text}
</Text>
)
}
return <Text key={idx}>{segment.text}</Text>
})}
<Text bold key={key} underline>
{displayText}
{PLACEHOLDER_HINT}
</Text>
)
}
// With cursor position - render cursor within the text
const safeCursorPos = Math.min(Math.max(0, cursorPos), text.length)
const segments = parseInput(text, availableCommands)
// Render segments with cursor
const renderSegmentWithCursor = (segment: Segment, segmentIdx: number) => {
const segmentStart = segment.startIndex
const segmentEnd = segmentStart + segment.text.length
const isHighlighted = segment.type === "mention" || segment.type === "command"
// Check if cursor is within this segment
if (safeCursorPos >= segmentStart && safeCursorPos < segmentEnd) {
// Cursor is in this segment - split it
const localCursorPos = safeCursorPos - segmentStart
const beforeCursor = segment.text.slice(0, localCursorPos)
const cursorChar = segment.text[localCursorPos]
const afterCursor = segment.text.slice(localCursorPos + 1)
if (isHighlighted) {
return (
<Text key={segmentIdx}>
{beforeCursor && <Text backgroundColor="gray">{beforeCursor}</Text>}
<Text backgroundColor="gray" inverse>
{cursorChar}
</Text>
{afterCursor && <Text backgroundColor="gray">{afterCursor}</Text>}
</Text>
)
}
return (
<Text key={segmentIdx}>
{beforeCursor}
<Text inverse>{cursorChar}</Text>
{afterCursor}
</Text>
)
}
// Cursor not in this segment - render normally
if (isHighlighted) {
return (
<Text backgroundColor="gray" key={segmentIdx}>
{segment.text}
</Text>
)
}
return <Text key={segmentIdx}>{segment.text}</Text>
if (segment.type === "mention" || segment.type === "command") {
return (
<Text backgroundColor="gray" key={key}>
{segment.text}
</Text>
)
}
// Check if cursor is at the end (past all text)
const cursorAtEnd = safeCursorPos >= text.length
return <Text key={key}>{segment.text}</Text>
}
function cursorInSegment(cursorPos: number, segment: Segment): boolean {
return cursorPos >= segment.startIndex && cursorPos < segment.startIndex + segment.text.length
}
function renderSegmentWithCursor(segment: Segment, cursorPos: number, key: number): React.ReactElement {
const { type, text: segText, startIndex } = segment
const isPlaceholder = type === "placeholder"
const isHighlighted = type === "mention" || type === "command"
const localPos = cursorPos - startIndex
const before = segText.slice(0, localPos)
const char = segText[localPos]
const after = segText.slice(localPos + 1)
const onNewline = char === "\n"
// Wrapper applies segment-specific styling to non-cursor text
const Wrap = isPlaceholder
? ({ children }: { children: React.ReactNode }) => (
<Text bold underline>
{children}
</Text>
)
: isHighlighted
? ({ children }: { children: React.ReactNode }) => <Text backgroundColor="gray">{children}</Text>
: React.Fragment
return (
<Text>
{segments.map((segment, idx) => renderSegmentWithCursor(segment, idx))}
{cursorAtEnd && <Text inverse> </Text>}
<Text key={key}>
{before && <Wrap>{before}</Wrap>}
<Text backgroundColor={isHighlighted ? "gray" : undefined} inverse>
{onNewline ? " " : char}
</Text>
{onNewline && "\n"}
{after && <Wrap>{after}</Wrap>}
{isPlaceholder && (
<Text bold underline>
{PLACEHOLDER_HINT}
</Text>
)}
</Text>
)
}
// ── Main Render Logic ──────────────────────────────────────────────────────────
function renderHighlightedText(text: string, cursorPos: number | undefined, availableCommands?: string[]): React.ReactElement {
if (cursorPos === undefined) {
if (!text) return <Text />
return <Text>{parseInput(text, availableCommands).map(renderSegment)}</Text>
}
const segments = parseInput(text, availableCommands)
const safePos = Math.min(Math.max(0, cursorPos), text.length)
return (
<Text>
{segments.map((seg, i) =>
cursorInSegment(safePos, seg) ? renderSegmentWithCursor(seg, safePos, i) : renderSegment(seg, i),
)}
{safePos >= text.length && <Text inverse> </Text>}
</Text>
)
}
// ── Component ──────────────────────────────────────────────────────────────────
export const HighlightedInput: React.FC<HighlightedInputProps> = ({
text,
cursorPos,
availableCommands,
maxLines = DEFAULT_MAX_LINES,
}) => {
const viewport = cursorPos !== undefined ? computeViewport(text, cursorPos, maxLines) : null
if (!viewport) {
return (
<Box flexDirection="column" width="100%">
{renderHighlightedText(text, cursorPos, availableCommands)}
</Box>
)
}
return (
<Box flexDirection="column" width="100%">
{viewport.linesAbove > 0 && (
<Text color="gray">
{viewport.linesAbove} more {viewport.linesAbove === 1 ? "line" : "lines"}
</Text>
)}
{renderHighlightedText(viewport.viewportText, viewport.adjustedCursorPos, availableCommands)}
{viewport.linesBelow > 0 && (
<Text color="gray">
{viewport.linesBelow} more {viewport.linesBelow === 1 ? "line" : "lines"}
</Text>
)}
</Box>
)
}