Skip to content

Commit 33fdcd6

Browse files
committed
refactor(prompt): 重构粘贴和历史导航处理逻辑
- 将粘贴处理逻辑提取到单独的 usePasteHandling hook 中 - 将历史导航逻辑提取到单独的 useHistoryNavigation hook 中 - 用 hook 替代 PromptInput 内部状态管理,简化组件代码 - 支持大文本粘贴显示可折叠的占位符标记 - 实现粘贴内容的展开与折叠控制 - 重置粘贴状态时清理所有历史数据 - 修正状态同步,提升粘贴和历史浏览体验 - 对外提供相关类型定义和状态、操作接口
1 parent 89a42e1 commit 33fdcd6

4 files changed

Lines changed: 240 additions & 133 deletions

File tree

src/ui/PromptInput.tsx

Lines changed: 19 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,13 @@ import {
66
EMPTY_BUFFER,
77
PASTE_MARKER_REGEX,
88
backspace,
9-
cleanPasteContent,
109
deleteForward,
1110
deletePasteMarkerBackward,
1211
deletePasteMarkerForward,
1312
deleteWordBefore,
1413
deleteWordAfter,
1514
expandPasteMarkers,
16-
findPasteMarkerContaining,
1715
getCurrentSlashToken,
18-
hasActivePasteMarkers,
1916
insertText,
2017
isEmpty,
2118
killLine,
@@ -53,6 +50,8 @@ export type { InputKey } from "./prompt";
5350

5451
import { useTerminalInput } from "./prompt";
5552
import type { InputKey } from "./prompt";
53+
import { usePasteHandling } from "./prompt/paste-handling";
54+
import { useHistoryNavigation } from "./prompt/history-navigation";
5655
import {
5756
useHiddenTerminalCursor,
5857
useTerminalExtendedKeys,
@@ -150,21 +149,22 @@ export const PromptInput = React.memo(function PromptInput({
150149
const [showModelDropdown, setShowModelDropdown] = useState(false);
151150
const [fileMentionItems, setFileMentionItems] = useState<FileMentionItem[]>(() => scanFileMentionItems(projectRoot));
152151
const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState<string | null>(null);
153-
const [historyCursor, setHistoryCursor] = useState(-1);
154-
const [draftBeforeHistory, setDraftBeforeHistory] = useState<string | null>(null);
155152
const [hasTerminalFocus, setHasTerminalFocus] = useState(true);
156153
const lastCtrlDAt = React.useRef<number>(0);
157154
const undoRedoRef = React.useRef(createPromptUndoRedoState());
158155
const wasBusyRef = React.useRef(busy);
159156
const hadFileMentionTokenRef = React.useRef(false);
160157
const appliedDraftNonceRef = React.useRef<number | null>(null);
161-
const pastesRef = React.useRef<Map<number, string>>(new Map());
162-
const pasteCounterRef = React.useRef<number>(0);
163-
// Track expanded paste regions for toggle (Ctrl+O expand / collapse).
164-
const expandedRegionsRef = React.useRef<Map<number, { start: number; end: number; content: string; marker: string }>>(
165-
new Map()
158+
159+
const { historyCursor, navigateHistory, exitHistoryBrowsing } = useHistoryNavigation(
160+
buffer,
161+
setBuffer,
162+
promptHistory
166163
);
167164

165+
const { pastesRef, handlePaste, expandPasteMarkerAtCursor, resetPastes, hasCollapsedMarkers, hasExpandedRegions } =
166+
usePasteHandling(buffer, updateBuffer, setStatusMessage);
167+
168168
const fileMentionToken = getCurrentFileMentionToken(buffer);
169169
const hasFileMentionToken = fileMentionToken !== null;
170170
const fileMentionKey = fileMentionToken ? `${fileMentionToken.start}:${fileMentionToken.query}` : null;
@@ -191,8 +191,6 @@ export const PromptInput = React.memo(function PromptInput({
191191
const showMenu = slashMenu.length > 0;
192192
const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]);
193193
const hasRunningProcess = runningProcesses && runningProcesses.size > 0;
194-
const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text, pastesRef.current);
195-
const hasExpandedRegions = expandedRegionsRef.current.size > 0;
196194
const processOrPasteHint = hasRunningProcess
197195
? " · ctrl+o view output"
198196
: hasCollapsedMarkers
@@ -268,17 +266,14 @@ export const PromptInput = React.memo(function PromptInput({
268266
setSelectedSkills([]);
269267
setShowSkillsDropdown(false);
270268
setOpenRawModelDropdown(false);
271-
setHistoryCursor(-1);
272-
setDraftBeforeHistory(null);
269+
exitHistoryBrowsing();
273270
clearPromptUndoRedoState(undoRedoRef.current);
274-
pastesRef.current.clear();
275-
expandedRegionsRef.current.clear();
276-
}, [promptDraft]);
271+
resetPastes();
272+
}, [promptDraft, exitHistoryBrowsing, resetPastes]);
277273

278274
useEffect(() => {
279-
setHistoryCursor(-1);
280-
setDraftBeforeHistory(null);
281-
}, [promptHistoryKey]);
275+
exitHistoryBrowsing();
276+
}, [promptHistoryKey, exitHistoryBrowsing]);
282277

283278
useTerminalInput(
284279
(input, key) => {
@@ -338,8 +333,7 @@ export const PromptInput = React.memo(function PromptInput({
338333
} else if (!isEmpty(buffer)) {
339334
setBuffer(EMPTY_BUFFER);
340335
clearUndoRedoStacks();
341-
pastesRef.current.clear();
342-
expandedRegionsRef.current.clear();
336+
resetPastes();
343337
} else {
344338
setStatusMessage("press ctrl+d to exit");
345339
}
@@ -529,8 +523,7 @@ export const PromptInput = React.memo(function PromptInput({
529523
}
530524
if (key.ctrl && (input === "u" || input === "U")) {
531525
updateBuffer(() => EMPTY_BUFFER);
532-
pastesRef.current.clear();
533-
expandedRegionsRef.current.clear();
526+
resetPastes();
534527
return;
535528
}
536529
if (key.ctrl && (input === "w" || input === "W")) {
@@ -594,11 +587,6 @@ export const PromptInput = React.memo(function PromptInput({
594587
clearPromptUndoRedoState(undoRedoRef.current);
595588
}
596589

597-
function exitHistoryBrowsing(): void {
598-
setHistoryCursor(-1);
599-
setDraftBeforeHistory(null);
600-
}
601-
602590
function updateBuffer(updater: (state: PromptBufferState) => PromptBufferState): void {
603591
exitHistoryBrowsing();
604592
setBuffer((current) => {
@@ -608,107 +596,6 @@ export const PromptInput = React.memo(function PromptInput({
608596
});
609597
}
610598

611-
function handlePaste(pastedText: string): void {
612-
const totalChars = pastedText.length;
613-
614-
if (totalChars <= 1000) {
615-
const newlineCount = (pastedText.match(/\n/g) ?? []).length;
616-
if (newlineCount <= 9) {
617-
const clean = pastedText
618-
.replace(/\r\n|\r/g, "\n")
619-
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
620-
.replace(/\t/g, " ");
621-
updateBuffer((s) => insertText(s, clean));
622-
return;
623-
}
624-
}
625-
626-
// Large paste: store raw text, insert marker with line/char count.
627-
const lineCount = (pastedText.match(/\n/g) ?? []).length + 1;
628-
pasteCounterRef.current += 1;
629-
const pasteId = pasteCounterRef.current;
630-
pastesRef.current.set(pasteId, pastedText);
631-
632-
const marker =
633-
lineCount > 10 ? `[paste #${pasteId} +${lineCount} lines]` : `[paste #${pasteId} ${totalChars} chars]`;
634-
635-
updateBuffer((s) => insertText(s, marker));
636-
}
637-
638-
function expandPasteMarkerAtCursor(): void {
639-
// First, try to collapse an already-expanded region at the cursor.
640-
for (const [id, region] of expandedRegionsRef.current) {
641-
if (buffer.cursor >= region.start && buffer.cursor <= region.end) {
642-
// Collapse back to marker.
643-
expandedRegionsRef.current.delete(id);
644-
pastesRef.current.set(id, region.content);
645-
setTimeout(() => {
646-
updateBuffer((s) => {
647-
const text = s.text.slice(0, region.start) + region.marker + s.text.slice(region.end);
648-
return { text, cursor: region.start + region.marker.length };
649-
});
650-
}, 0);
651-
return;
652-
}
653-
}
654-
655-
// No expanded region at cursor — try to expand a paste marker.
656-
const marker = findPasteMarkerContaining(buffer);
657-
if (!marker) {
658-
setStatusMessage("No paste marker at cursor");
659-
return;
660-
}
661-
const content = pastesRef.current.get(marker.id);
662-
if (!content) {
663-
setStatusMessage("Paste content not found");
664-
return;
665-
}
666-
667-
const pasteId = marker.id;
668-
const originalMarker = buffer.text.slice(marker.start, marker.end);
669-
pastesRef.current.delete(pasteId);
670-
671-
setTimeout(() => {
672-
updateBuffer((s) => {
673-
const text = s.text.slice(0, marker.start) + cleanPasteContent(content) + s.text.slice(marker.end);
674-
const newEnd = marker.start + content.length;
675-
expandedRegionsRef.current.set(pasteId, {
676-
start: marker.start,
677-
end: newEnd,
678-
content,
679-
marker: originalMarker,
680-
});
681-
return { text, cursor: marker.start };
682-
});
683-
}, 0);
684-
}
685-
686-
function navigateHistory(direction: -1 | 1): void {
687-
if (promptHistory.length === 0) {
688-
return;
689-
}
690-
691-
const previousCursor = historyCursor === -1 ? promptHistory.length : historyCursor;
692-
const nextCursor = Math.max(0, Math.min(promptHistory.length, previousCursor + direction));
693-
const draft = historyCursor === -1 ? buffer.text : draftBeforeHistory;
694-
695-
if (historyCursor === -1) {
696-
setDraftBeforeHistory(buffer.text);
697-
}
698-
699-
if (nextCursor === promptHistory.length) {
700-
const text = draft ?? "";
701-
setBuffer({ text, cursor: text.length });
702-
setHistoryCursor(-1);
703-
setDraftBeforeHistory(null);
704-
return;
705-
}
706-
707-
const text = promptHistory[nextCursor] ?? "";
708-
setBuffer({ text, cursor: text.length });
709-
setHistoryCursor(nextCursor);
710-
}
711-
712599
function insertFileMentionSelection(item: FileMentionItem): void {
713600
if (!fileMentionToken) {
714601
return;
@@ -723,9 +610,8 @@ export const PromptInput = React.memo(function PromptInput({
723610
setImageUrls([]);
724611
setSelectedSkills([]);
725612
setShowSkillsDropdown(false);
726-
pastesRef.current.clear();
727-
expandedRegionsRef.current.clear();
728-
pasteCounterRef.current = 0;
613+
exitHistoryBrowsing();
614+
resetPastes();
729615
}
730616

731617
function handleSlashSelection(item: SlashCommandItem): void {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type React from "react";
2+
import { useCallback, useState } from "react";
3+
import type { PromptBufferState } from "../promptBuffer";
4+
5+
export type HistoryNavigationState = {
6+
historyCursor: number;
7+
draftBeforeHistory: string | null;
8+
};
9+
10+
export type HistoryNavigationActions = {
11+
/**
12+
* Navigate through prompt history. Pass -1 for previous, 1 for next.
13+
* Stores current draft before entering history mode and restores it when
14+
* scrolling past the last entry.
15+
*/
16+
navigateHistory: (direction: -1 | 1) => void;
17+
/** Exit history browsing mode, restoring the pre-history draft if any. */
18+
exitHistoryBrowsing: () => void;
19+
};
20+
21+
export function useHistoryNavigation(
22+
buffer: PromptBufferState,
23+
setBuffer: React.Dispatch<React.SetStateAction<PromptBufferState>>,
24+
promptHistory: string[]
25+
): HistoryNavigationState & HistoryNavigationActions {
26+
const [historyCursor, setHistoryCursor] = useState(-1);
27+
const [draftBeforeHistory, setDraftBeforeHistory] = useState<string | null>(null);
28+
29+
const exitHistoryBrowsing = useCallback((): void => {
30+
setHistoryCursor(-1);
31+
setDraftBeforeHistory(null);
32+
}, []);
33+
34+
function navigateHistory(direction: -1 | 1): void {
35+
if (promptHistory.length === 0) {
36+
return;
37+
}
38+
39+
const previousCursor = historyCursor === -1 ? promptHistory.length : historyCursor;
40+
const nextCursor = Math.max(0, Math.min(promptHistory.length, previousCursor + direction));
41+
42+
if (historyCursor === -1) {
43+
setDraftBeforeHistory(buffer.text);
44+
}
45+
46+
if (nextCursor === promptHistory.length) {
47+
const text = draftBeforeHistory ?? "";
48+
setBuffer({ text, cursor: text.length });
49+
setHistoryCursor(-1);
50+
setDraftBeforeHistory(null);
51+
return;
52+
}
53+
54+
const text = promptHistory[nextCursor] ?? "";
55+
setBuffer({ text, cursor: text.length });
56+
setHistoryCursor(nextCursor);
57+
}
58+
59+
return {
60+
historyCursor,
61+
draftBeforeHistory,
62+
navigateHistory,
63+
exitHistoryBrowsing,
64+
};
65+
}

src/ui/prompt/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ export {
99
useTerminalFocusReporting,
1010
getPromptCursorPlacement,
1111
} from "./cursor";
12+
13+
export { usePasteHandling } from "./paste-handling";
14+
export type { PasteRegion, PasteHandlingState, PasteHandlingActions } from "./paste-handling";
15+
16+
export { useHistoryNavigation } from "./history-navigation";
17+
export type { HistoryNavigationState, HistoryNavigationActions } from "./history-navigation";

0 commit comments

Comments
 (0)