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);