diff --git a/src/session.ts b/src/session.ts index e40e9aa..7008f33 100644 --- a/src/session.ts +++ b/src/session.ts @@ -598,7 +598,7 @@ The candidate skills are as follows:\n\n`; if (!fs.existsSync(root)) { return []; } - let entries: fs.Dirent[] = []; + let entries: fs.Dirent[]; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { diff --git a/src/tools/read-handler.ts b/src/tools/read-handler.ts index 548bcfd..3cca578 100644 --- a/src/tools/read-handler.ts +++ b/src/tools/read-handler.ts @@ -340,9 +340,18 @@ function loadGitignoreMatcher(projectRoot: string): ((relPath: string, isDir: bo }; } - let content = ""; try { - content = fs.readFileSync(gitignorePath, "utf8"); + const content = fs.readFileSync(gitignorePath, "utf8"); + const ig = ignore(); + ig.add(DEFAULT_GITIGNORE); + ig.add(content); + return (relPath: string, isDir: boolean) => { + if (!relPath) { + return false; + } + const candidate = isDir ? `${relPath}/` : relPath; + return ig.ignores(candidate); + }; } catch { const ig = ignore(); ig.add(DEFAULT_GITIGNORE); @@ -354,17 +363,6 @@ function loadGitignoreMatcher(projectRoot: string): ((relPath: string, isDir: bo return ig.ignores(candidate); }; } - - const ig = ignore(); - ig.add(DEFAULT_GITIGNORE); - ig.add(content); - return (relPath: string, isDir: boolean) => { - if (!relPath) { - return false; - } - const candidate = isDir ? `${relPath}/` : relPath; - return ig.ignores(candidate); - }; } function parseLineNumber( diff --git a/src/ui/DropdownMenu.tsx b/src/ui/DropdownMenu.tsx new file mode 100644 index 0000000..3a963a5 --- /dev/null +++ b/src/ui/DropdownMenu.tsx @@ -0,0 +1,191 @@ +import React, { useMemo } from "react"; +import { Box, Text } from "ink"; + +/** + * Generic dropdown menu item structure + */ +export type DropdownMenuItem = { + /** Unique key for React list rendering */ + key: string; + /** Main label text (can include status indicators) */ + label: string; + /** Secondary description text (dimmed) */ + description?: string; + /** Whether this item is currently selected */ + selected?: boolean; + /** Whether to show a special status indicator (e.g., loaded checkmark) */ + statusIndicator?: { + symbol: string; + color: string; + }; +}; + +/** + * Props for the DropdownMenu component + */ +type DropdownMenuProps = { + /** List of items to display */ + items: DropdownMenuItem[]; + /** Index of the currently active/highlighted item */ + activeIndex: number; + /** Maximum number of visible items before scrolling */ + maxVisible?: number; + /** Container width in columns */ + width: number; + /** Optional title displayed at the top */ + title?: string; + /** Color for the title (default: "magenta") */ + titleColor?: string; + /** Color for the active item indicator (default: "cyanBright") */ + activeColor?: string; + /** Help text displayed at the bottom */ + helpText?: string; + /** Text to display when items list is empty */ + emptyText?: string; + /** Custom item renderer (overrides default rendering) */ + renderItem?: (item: DropdownMenuItem, isActive: boolean) => React.ReactNode; +}; + +/** + * Calculate the visible window start position for scrolling + * Ensures the activeIndex is always visible within the window + */ +export function calculateVisibleStart(activeIndex: number, totalItems: number, maxVisible: number): number { + return Math.min(Math.max(0, activeIndex - Math.floor((maxVisible - 1) / 2)), Math.max(0, totalItems - maxVisible)); +} + +/** + * Generic dropdown menu component with scrolling support + * Used by Skills Dropdown, Model Dropdown, and other selection menus + */ +const DropdownMenu = React.memo(function DropdownMenu({ + items, + activeIndex, + maxVisible = 8, + width, + title, + titleColor = "magenta", + activeColor = "cyanBright", + helpText, + emptyText = "No items found", + renderItem, +}: DropdownMenuProps): React.ReactElement | null { + // Calculate visible window + const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible); + const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible); + + // 计算标签列最佳宽度:包含所有可能的前缀和后缀 + const labelColumnWidth = useMemo(() => { + if (visibleItems.length === 0) { + return 0; + } + // 计算每个 item 实际需要的最大宽度 + const maxContentWidth = Math.max( + ...visibleItems.map((item) => { + let width = 2; // prefix "› " or " " + if (item.selected !== undefined) { + width += 2; // "● " or "○ " + } + width += item.label.length; + if (item.statusIndicator) { + width += 2; // " ✓" or similar + } + return width; + }) + ); + const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 + return Math.min(maxContentWidth, maxAllowed); + }, [visibleItems, width]); + + // Early return if no items + if (items?.length === 0) { + return ( + + {title ? ( + + {title} + + ) : null} + {emptyText} + {helpText ? {helpText} : null} + + ); + } + + return ( + + {/* Title */} + {title ? ( + + + {title} + + + ) : null} + + {/* Scroll indicator - top */} + {visibleStart > 0 ? ( + + … {visibleStart} above + + ) : null} + + {/* Visible items */} + {visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + const isActive = actualIndex === activeIndex; + + // Use custom renderer if provided + if (renderItem) { + return {renderItem(item, isActive)}; + } + + // Default rendering with selection indicator and optional features + return ( + + + + {isActive ? "› " : " "} + {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} + {item.statusIndicator ? ( + {item.statusIndicator.symbol} + ) : null} + + + {item.description ? {`${item.description}`} : null} + + ); + })} + + {/* Scroll indicator - bottom */} + {visibleStart + visibleItems.length < items.length ? ( + + … {items.length - visibleStart - visibleItems.length} more + + ) : null} + + {/* Help text */} + {helpText ? ( + + {helpText} + + ) : null} + + ); +}); + +export default DropdownMenu; diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 24560e2..1e0f7e8 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; import { @@ -33,6 +33,7 @@ export type { InputKey } from "./prompt"; import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt"; +import DropdownMenu from "./DropdownMenu"; import SlashCommandMenu from "./SlashCommandMenu"; import type { ModelConfigSelection, ReasoningEffort } from "../settings"; @@ -89,7 +90,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }, [busy]); const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; + return {prefix}; }); export const PromptInput = React.memo(function PromptInput({ @@ -669,8 +670,6 @@ export const PromptInput = React.memo(function PromptInput({ }); } - const visibleSkillStart = Math.min(Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8)); - const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8); const modelDropdownItems = modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.map((model) => ({ @@ -684,6 +683,11 @@ export const PromptInput = React.memo(function PromptInput({ description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", })); + const showFooterText = useMemo( + () => showMenu || showSkillsDropdown || modelDropdownStep !== null, + [showMenu, showSkillsDropdown, modelDropdownStep] + ); + return ( {imageUrls.length > 0 ? ( @@ -713,58 +717,45 @@ export const PromptInput = React.memo(function PromptInput({ {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} {showSkillsDropdown ? ( - - - Select Skills - - {skills.length === 0 ? ( - No skills found - ) : ( - visibleSkills.map((skill, idx) => { - const skillIndex = visibleSkillStart + idx; - const selected = isSkillSelected(selectedSkills, skill); - const active = skillIndex === skillsDropdownIndex; - return ( - - {active ? "› " : " "} - {selected ? "●" : "○"} {skill.name} - {skill.isLoaded ? : null} - {` ${skill.path}`} - - ); - }) - )} - {visibleSkillStart > 0 ? … {visibleSkillStart} above : null} - {visibleSkillStart + visibleSkills.length < skills.length ? ( - … {skills.length - visibleSkillStart - visibleSkills.length} more - ) : null} - space toggle · enter toggle · esc to close - + ({ + key: skill.path || skill.name, + label: skill.name, + description: skill.path, + selected: isSkillSelected(selectedSkills, skill), + statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined, + }))} + activeIndex={skillsDropdownIndex} + activeColor="#229ac3" + maxVisible={8} + /> ) : null} {modelDropdownStep ? ( - - - {modelDropdownStep === "model" ? "Select Model" : "Select Thinking Mode"} - - {modelDropdownItems.map((item, idx) => { - const active = idx === modelDropdownIndex; - return ( - - {active ? "› " : " "} - {item.selected ? "●" : "○"} {item.label} - {item.description ? {` ${item.description}`} : null} - - ); - })} - - {modelDropdownStep === "model" + - + : "space/enter apply · esc to cancel" + } + items={modelDropdownItems.map((item) => ({ + key: item.label, + label: item.label, + description: item.description, + selected: item.selected, + }))} + activeIndex={modelDropdownIndex} + activeColor="#229ac3" + maxVisible={8} + /> ) : null} - {!showMenu && ( + {!showFooterText && ( {footerText} diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index a0f454f..10824b9 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -20,6 +20,8 @@ export type InputKey = { meta: boolean; focusIn: boolean; focusOut: boolean; + /** The input was received as part of a bracketed paste (sent by the terminal). */ + paste: boolean; }; const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); @@ -34,6 +36,9 @@ const META_LEFT_SEQUENCES = new Set(["\u001B[1;3D", "\u001B[3D", "\u001Bb"]); const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]); const TERMINAL_FOCUS_IN = "\u001B[I"; const TERMINAL_FOCUS_OUT = "\u001B[O"; +/** Bracketed paste mode markers: start and end delimiters sent by terminals. */ +const BRACKETED_PASTE_START = "\u001B[200~"; +const BRACKETED_PASTE_END = "\u001B[201~"; export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } { const raw = String(data); @@ -57,6 +62,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), focusIn: raw === TERMINAL_FOCUS_IN, focusOut: raw === TERMINAL_FOCUS_OUT, + paste: false, }; if (input <= "\u001A" && !key.return) { @@ -112,13 +118,22 @@ export function useTerminalInput( const handlerRef = useRef(inputHandler); handlerRef.current = inputHandler; + // Accumulates text between bracketed paste start and end markers. + // Non-null means a paste is in progress. + const pasteRef = useRef(null); + useEffect(() => { if (!isActive) { return; } setRawMode(true); + // Enable bracketed paste mode so the terminal wraps pasted text in + // \u001B[200~ / \u001B[201~ markers, allowing us to deliver the full + // paste as a single atomic input instead of fragmented key events. + process.stdout.write("\u001B[?2004h"); return () => { setRawMode(false); + process.stdout.write("\u001B[?2004l"); }; }, [isActive, setRawMode]); @@ -127,8 +142,119 @@ export function useTerminalInput( return; } const handleData = (data: Buffer | string) => { - const { input, key } = parseTerminalInput(data); - handlerRef.current(input, key); + const raw = String(data); + + // Bracketed paste mode: the terminal wraps pasted content in + // \u001B[200~ (start) and \u001B[201~ (end). Accumulate everything + // between them and deliver as a single input chunk with + // normalized line endings. + // + // Use indexOf scanning instead of exact-match so we correctly + // handle the case where markers and content arrive in the same + // data event (e.g. "\u001B[200~hello\u001B[201~"). + + // In the middle of a paste: accumulate and look for end marker. + if (pasteRef.current !== null) { + const endIdx = raw.indexOf(BRACKETED_PASTE_END); + if (endIdx === -1) { + pasteRef.current += raw; + return; + } + // End marker found: flush accumulated content. + pasteRef.current += raw.slice(0, endIdx); + const pasted = pasteRef.current; + pasteRef.current = null; + if (pasted.length > 0) { + const normalized = pasted.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + handlerRef.current(normalized, { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + paste: true, + }); + } + // Process any text after the end marker as normal input. + const after = raw.slice(endIdx + BRACKETED_PASTE_END.length); + if (after.length > 0) { + const { input, key } = parseTerminalInput(after); + handlerRef.current(input, key); + } + return; + } + + // Not in paste mode: look for a paste start marker. + const startIdx = raw.indexOf(BRACKETED_PASTE_START); + if (startIdx === -1) { + // No paste marker: normal input. + const { input, key } = parseTerminalInput(data); + handlerRef.current(input, key); + return; + } + + // Process text before the start marker as normal input. + if (startIdx > 0) { + const { input, key } = parseTerminalInput(raw.slice(0, startIdx)); + handlerRef.current(input, key); + } + + // Text after the start marker. + const afterStart = raw.slice(startIdx + BRACKETED_PASTE_START.length); + + // Check if end marker is also in this chunk. + const endIdx = afterStart.indexOf(BRACKETED_PASTE_END); + if (endIdx !== -1) { + // Complete paste within this chunk. + const pasted = afterStart.slice(0, endIdx); + if (pasted.length > 0) { + const normalized = pasted.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + handlerRef.current(normalized, { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + paste: true, + }); + } + // Process text after the end marker. + const after = afterStart.slice(endIdx + BRACKETED_PASTE_END.length); + if (after.length > 0) { + const { input, key } = parseTerminalInput(after); + handlerRef.current(input, key); + } + return; + } + + // End marker not found: start accumulating. + pasteRef.current = afterStart; }; stdin?.on("data", handleData);