mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb6af2d6da | ||
|
|
1b9dd3172b | ||
|
|
de386dabb5 | ||
|
|
7121023753 | ||
|
|
573c4a8485 | ||
|
|
333022bcc3 | ||
|
|
dbce5059cb | ||
|
|
5a7eadfe41 | ||
|
|
9759f0656e | ||
|
|
0336c7ca36 | ||
|
|
1f88575066 | ||
|
|
4633a1368f |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Support for better pasting in CLI
|
||||
+159
-126
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user