diff --git a/src/tests/sessionList.test.ts b/src/tests/sessionList.test.ts index e3bf51c..3dfda33 100644 --- a/src/tests/sessionList.test.ts +++ b/src/tests/sessionList.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { formatSessionTitle } from "../ui"; +import { formatSessionTitle, filterSessions, formatSessionStatus } from "../ui"; +import type { SessionEntry } from "../session"; test("formatSessionTitle replaces newlines with spaces", () => { assert.equal(formatSessionTitle("first line\nsecond line\r\nthird"), "first line second line third"); @@ -9,3 +10,108 @@ test("formatSessionTitle replaces newlines with spaces", () => { test("formatSessionTitle truncates after normalizing whitespace", () => { assert.equal(formatSessionTitle("one\n two three", 10), "one two th…"); }); + +test("formatSessionStatus maps status values to display labels", () => { + assert.equal(formatSessionStatus("completed"), "done"); + assert.equal(formatSessionStatus("processing"), "running"); + assert.equal(formatSessionStatus("pending"), "pending"); + assert.equal(formatSessionStatus("waiting_for_user"), "waiting"); + assert.equal(formatSessionStatus("failed"), "failed"); + assert.equal(formatSessionStatus("interrupted"), "stopped"); + assert.equal(formatSessionStatus("unknown_status" as any), "unknown_status"); +}); + +test("filterSessions returns all sessions when query is empty", () => { + const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]); + assert.equal(filterSessions(sessions, "").length, 2); + assert.equal(filterSessions(sessions, " ").length, 2); +}); + +test("filterSessions matches by summary (case-insensitive)", () => { + const sessions = buildSessions([ + { summary: "Fix login bug" }, + { summary: "Add dark mode" }, + { summary: "Refactor auth module" }, + ]); + + assert.equal(filterSessions(sessions, "login").length, 1); + assert.equal(filterSessions(sessions, "LOGIN").length, 1); + assert.equal(filterSessions(sessions, "Login").length, 1); +}); + +test("filterSessions matches by status (case-insensitive)", () => { + const sessions = buildSessions([ + { summary: "Task 1", status: "completed" }, + { summary: "Task 2", status: "failed" }, + { summary: "Task 3", status: "completed" }, + ]); + + assert.equal(filterSessions(sessions, "failed").length, 1); + assert.equal(filterSessions(sessions, "completed").length, 2); +}); + +test("filterSessions matches by failReason", () => { + const sessions = buildSessions([ + { summary: "Task 1", status: "failed", failReason: "API key not found" }, + { summary: "Task 2", status: "completed" }, + ]); + + assert.equal(filterSessions(sessions, "API key").length, 1); + assert.equal(filterSessions(sessions, "not found").length, 1); +}); + +test("filterSessions matches by assistantReply", () => { + const sessions = buildSessions([ + { summary: "Task 1", assistantReply: "The bug was fixed by updating the config." }, + { summary: "Task 2", assistantReply: "Dark mode has been added successfully." }, + ]); + + assert.equal(filterSessions(sessions, "dark mode").length, 1); + assert.equal(filterSessions(sessions, "config").length, 1); +}); + +test("filterSessions returns empty array when no match", () => { + const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]); + + assert.equal(filterSessions(sessions, "nonexistent").length, 0); +}); + +test("filterSessions matches across multiple fields on same session", () => { + const sessions = buildSessions([ + { summary: "Fix login bug", status: "failed", failReason: "Timeout error" }, + { summary: "Add dark mode", status: "completed" }, + ]); + + // Should match the first session via status + assert.equal(filterSessions(sessions, "failed").length, 1); + // Should match the first session via failReason + assert.equal(filterSessions(sessions, "timeout").length, 1); + // Partial summary match + assert.equal(filterSessions(sessions, "login").length, 1); +}); + +test("filterSessions handles sessions with null fields", () => { + const sessions = buildSessions([{ summary: null }, { summary: "Valid summary" }]); + + assert.equal(filterSessions(sessions, "valid").length, 1); + assert.equal(filterSessions(sessions, "summary").length, 1); +}); + +function buildSessions(overrides: Array>): SessionEntry[] { + return overrides.map((override, i) => ({ + id: `session-${i}`, + summary: override.summary ?? null, + assistantReply: override.assistantReply ?? null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: override.status ?? "completed", + failReason: override.failReason ?? null, + usage: null, + usagePerModel: null, + activeTokens: 0, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + processes: null, + })); +} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 3cb51f2..074cab6 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -48,10 +48,9 @@ export type { InputKey } from "./prompt"; import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; -import SlashCommandMenu from "./SlashCommandMenu"; -import type { ModelConfigSelection, ReasoningEffort } from "../settings"; -import DropdownMenu from "./DropdownMenu"; -import { RawModelDropdown } from "./components"; +import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; +import type { ModelConfigSelection } from "../settings"; +import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components"; export type PromptSubmission = { text: string; @@ -86,21 +85,6 @@ type Props = { }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; - -type ThinkingModeOption = { - label: string; - thinkingEnabled: boolean; - reasoningEffort?: ReasoningEffort; -}; - -export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ - { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, - { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, - { label: "No thinking", thinkingEnabled: false }, -]; - -type ModelDropdownStep = "model" | "thinking"; const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { const [spinnerIndex, setSpinnerIndex] = useState(0); @@ -148,12 +132,8 @@ export const PromptInput = React.memo(function PromptInput({ const [menuIndex, setMenuIndex] = useState(0); const [showSkillsDropdown, setShowSkillsDropdown] = useState(false); const [openRawModelDropdown, setOpenRawModelDropdown] = useState(false); - const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); - const [modelDropdownStep, setModelDropdownStep] = useState(null); - const [modelDropdownIndex, setModelDropdownIndex] = useState(0); - const [pendingModel, setPendingModel] = useState(null); + const [showModelDropdown, setShowModelDropdown] = useState(false); const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot)); - const [fileMentionIndex, setFileMentionIndex] = useState(0); const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null); const [historyCursor, setHistoryCursor] = useState(-1); const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); @@ -173,19 +153,19 @@ export const PromptInput = React.memo(function PromptInput({ ); const showFileMentionMenu = !showSkillsDropdown && - !modelDropdownStep && + !showModelDropdown && fileMentionToken !== null && fileMentionKey !== dismissedFileMentionKey; const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); const slashMenu = React.useMemo( () => - showSkillsDropdown || modelDropdownStep || showFileMentionMenu + showSkillsDropdown || showModelDropdown || showFileMentionMenu ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : [], - [showSkillsDropdown, modelDropdownStep, showFileMentionMenu, slashToken, slashItems] + [showSkillsDropdown, showModelDropdown, showFileMentionMenu, slashToken, slashItems] ); const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); @@ -240,33 +220,6 @@ export const PromptInput = React.memo(function PromptInput({ } }, [fileMentionKey]); - useEffect(() => { - if (!showFileMentionMenu) { - setFileMentionIndex(0); - return; - } - if (fileMentionIndex >= fileMentionMatches.length) { - setFileMentionIndex(Math.max(0, fileMentionMatches.length - 1)); - } - }, [fileMentionMatches.length, fileMentionIndex, showFileMentionMenu]); - - useEffect(() => { - if (skillsDropdownIndex >= skills.length) { - setSkillsDropdownIndex(Math.max(0, skills.length - 1)); - } - }, [skills.length, skillsDropdownIndex]); - - useEffect(() => { - if (!modelDropdownStep) { - return; - } - const optionCount = - modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; - if (modelDropdownIndex >= optionCount) { - setModelDropdownIndex(Math.max(0, optionCount - 1)); - } - }, [modelDropdownIndex, modelDropdownStep]); - useEffect(() => { if (!statusMessage) { return; @@ -285,7 +238,6 @@ export const PromptInput = React.memo(function PromptInput({ setSelectedSkills([]); setShowSkillsDropdown(false); setOpenRawModelDropdown(false); - setModelDropdownStep(null); setHistoryCursor(-1); setDraftBeforeHistory(null); clearPromptUndoRedoState(undoRedoRef.current); @@ -312,16 +264,7 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.escape) { - if (modelDropdownStep) { - closeModelDropdown(); - return; - } - if (showSkillsDropdown) { - setShowSkillsDropdown(false); - return; - } - if (showFileMentionMenu && fileMentionKey) { - setDismissedFileMentionKey(fileMentionKey); + if (showFileMentionMenu) { return; } if (busy) { @@ -373,7 +316,7 @@ export const PromptInput = React.memo(function PromptInput({ setPendingExit(false); } - if (openRawModelDropdown) { + if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) { return; } @@ -381,53 +324,6 @@ export const PromptInput = React.memo(function PromptInput({ exitHistoryBrowsing(); } - if (showSkillsDropdown) { - if (skills.length === 0) { - setShowSkillsDropdown(false); - } else { - if (key.upArrow) { - setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); - return; - } - if (key.downArrow) { - setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); - return; - } - if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - const skill = skills[skillsDropdownIndex]; - if (skill) { - toggleSelectedSkill(skill); - } - return; - } - if (key.tab) { - setShowSkillsDropdown(false); - return; - } - } - } - - if (modelDropdownStep) { - const optionCount = - modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; - if (key.upArrow) { - setModelDropdownIndex((idx) => (idx - 1 + optionCount) % optionCount); - return; - } - if (key.downArrow) { - setModelDropdownIndex((idx) => (idx + 1) % optionCount); - return; - } - if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - selectModelDropdownItem(); - return; - } - if (key.tab) { - closeModelDropdown(); - return; - } - } - if (key.ctrl && (input === "v" || input === "V")) { setStatusMessage("Reading clipboard..."); readClipboardImageAsync() @@ -460,32 +356,9 @@ export const PromptInput = React.memo(function PromptInput({ const isPlainReturn = returnAction === "submit"; if (showFileMentionMenu) { - if (key.upArrow) { - if (fileMentionMatches.length > 0) { - setFileMentionIndex((idx) => (idx - 1 + fileMentionMatches.length) % fileMentionMatches.length); - } + if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") { return; } - if (key.downArrow) { - if (fileMentionMatches.length > 0) { - setFileMentionIndex((idx) => (idx + 1) % fileMentionMatches.length); - } - return; - } - if (key.tab || returnAction === "submit") { - const selected = fileMentionMatches[fileMentionIndex]; - if (selected && fileMentionToken) { - insertFileMentionSelection(selected); - return; - } - if (key.tab) { - setDismissedFileMentionKey(fileMentionKey); - return; - } - if (fileMentionKey) { - setDismissedFileMentionKey(fileMentionKey); - } - } } if (showMenu) { @@ -728,6 +601,14 @@ export const PromptInput = React.memo(function PromptInput({ setDismissedFileMentionKey(null); } + function resetPromptInput(): void { + setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + } + function handleSlashSelection(item: SlashCommandItem): void { if (busy && item.kind !== "exit") { setStatusMessage("wait for the current response or press esc to interrupt"); @@ -747,7 +628,8 @@ export const PromptInput = React.memo(function PromptInput({ } if (item.kind === "model") { clearSlashToken(); - openModelDropdown(); + setShowSkillsDropdown(false); + setShowModelDropdown(true); return; } if (item.kind === "raw") { @@ -757,38 +639,22 @@ export const PromptInput = React.memo(function PromptInput({ } if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "init") { onSubmit(buildInitPromptSubmission(selectedSkills)); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "resume") { onSubmit({ text: "", imageUrls: [], command: "resume" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "continue") { onSubmit({ text: "/continue", imageUrls: [], command: "continue" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "undo") { @@ -802,11 +668,7 @@ export const PromptInput = React.memo(function PromptInput({ } if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "exit") { @@ -841,11 +703,7 @@ export const PromptInput = React.memo(function PromptInput({ imageUrls, selectedSkills, }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); } function addSelectedSkill(skill: SkillInfo): void { @@ -862,63 +720,9 @@ export const PromptInput = React.memo(function PromptInput({ clearUndoRedoStacks(); } - function openModelDropdown(): void { - const currentModelIndex = MODEL_COMMAND_MODELS.findIndex((model) => model === modelConfig.model); - setPendingModel(null); - setModelDropdownStep("model"); - setModelDropdownIndex(currentModelIndex >= 0 ? currentModelIndex : 0); - setShowSkillsDropdown(false); - } - - function closeModelDropdown(): void { - setModelDropdownStep(null); - setPendingModel(null); - } - - function selectModelDropdownItem(): void { - if (modelDropdownStep === "model") { - const model = MODEL_COMMAND_MODELS[modelDropdownIndex] ?? modelConfig.model; - setPendingModel(model); - setModelDropdownStep("thinking"); - setModelDropdownIndex(getThinkingOptionIndex(modelConfig)); - return; - } - - const option = MODEL_COMMAND_THINKING_OPTIONS[modelDropdownIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]; - const selection: ModelConfigSelection = { - model: pendingModel ?? modelConfig.model, - thinkingEnabled: option.thinkingEnabled, - reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort, - }; - closeModelDropdown(); - Promise.resolve(onModelConfigChange(selection)) - .then((message) => { - if (message) { - setStatusMessage(message); - } - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setStatusMessage(`Failed to update model settings: ${message}`); - }); - } - - const modelDropdownItems = - modelDropdownStep === "model" - ? MODEL_COMMAND_MODELS.map((model) => ({ - label: model, - selected: model === (pendingModel ?? modelConfig.model), - description: model === modelConfig.model ? "current model" : "", - })) - : MODEL_COMMAND_THINKING_OPTIONS.map((option) => ({ - label: option.label, - selected: getThinkingOptionIndex(modelConfig) === MODEL_COMMAND_THINKING_OPTIONS.indexOf(option), - description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", - })); - const showFooterText = useMemo( - () => showMenu || showSkillsDropdown || openRawModelDropdown || modelDropdownStep !== null || showFileMentionMenu, - [showMenu, showSkillsDropdown, modelDropdownStep, openRawModelDropdown, showFileMentionMenu] + () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, + [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] ); const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null; @@ -959,75 +763,34 @@ export const PromptInput = React.memo(function PromptInput({ onSelect={(mode) => onRawModeChange?.(mode)} screenWidth={screenWidth} /> - {showSkillsDropdown ? ( - ({ - 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={6} - /> - ) : null} - {modelDropdownStep ? ( - + setShowModelDropdown(false)} + onModelConfigChange={onModelConfigChange} + onStatusMessage={setStatusMessage} + /> + { + if (fileMentionKey) { + setDismissedFileMentionKey(fileMentionKey); } - items={modelDropdownItems.map((item) => ({ - key: item.label, - label: item.label, - description: item.description, - selected: item.selected, - }))} - activeIndex={modelDropdownIndex} - activeColor="#229ac3" - maxVisible={6} - /> - ) : null} - {showFileMentionMenu ? ( - ({ - key: item.path, - label: item.path, - description: item.type === "directory" ? "directory" : "file", - }))} - activeIndex={fileMentionIndex} - activeColor="#229ac3" - maxVisible={8} - renderItem={(item, isActive) => ( - - {isActive ? "> " : " "} - - - {item.label} - - - {item.description ? ( - - {item.description} - - ) : null} - - )} - /> - ) : null} + }} + onSelect={insertFileMentionSelection} + /> {!showFooterText && ( @@ -1055,10 +818,6 @@ export function formatSelectedSkillsStatus(skills: SkillInfo[]): string { return `⚡ ${names.join(", ")}`; } -export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean { - return skills.some((item) => item.name === skill.name); -} - export function addUniqueSkill(skills: SkillInfo[], skill: SkillInfo): SkillInfo[] { if (isSkillSelected(skills, skill)) { return skills; @@ -1078,18 +837,6 @@ export function buildInitPromptSubmission(selectedSkills: SkillInfo[]): PromptSu }; } -export function getThinkingOptionIndex( - config: Pick -): number { - const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => { - if (!config.thinkingEnabled) { - return !option.thinkingEnabled; - } - return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort; - }); - return index >= 0 ? index : 0; -} - export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState { let start = state.cursor; while (start > 0 && !/\s/.test(state.text[start - 1] ?? "")) { diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index fdbd1fe..5f186bd 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -1,6 +1,6 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; -import type { SessionEntry } from "../session"; +import type { SessionEntry, SessionStatus } from "../session"; type Props = { sessions: SessionEntry[]; @@ -8,25 +8,57 @@ type Props = { onCancel: () => void; }; +/** + * Filter sessions by a search query. + * Matches against summary, status, and failReason fields (case-insensitive). + * Returns all sessions when query is empty. + */ +export function filterSessions(sessions: SessionEntry[], query: string): SessionEntry[] { + if (!query.trim()) { + return sessions; + } + + const lowerQuery = query.toLowerCase().trim(); + return sessions.filter((session) => { + if (session.summary && session.summary.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.status.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.failReason && session.failReason.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.assistantReply && session.assistantReply.toLowerCase().includes(lowerQuery)) { + return true; + } + return false; + }); +} + export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement { const [index, setIndex] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); const { columns, rows } = useWindowSize(); + // Filter sessions by search query + const filteredSessions = useMemo(() => filterSessions(sessions, searchQuery), [sessions, searchQuery]); + + // Reset index when filtered list changes (e.g., query changes) + const safeIndex = useMemo(() => { + if (filteredSessions.length === 0) return 0; + return Math.max(0, Math.min(index, filteredSessions.length - 1)); + }, [index, filteredSessions.length]); + // Dynamically calculate the number of visible sessions based on terminal height const maxVisibleSessions = useMemo(() => { - // Subtract space used by borders, header, footer, scroll indicator, etc. - // Outer container height=rows-1, outer border 2 + header 1 + inner border 2 + footer 1 + scroll indicator 1 = 8 - const reservedLines = 8; + // Subtract space used by borders, header (2 lines with search bar), footer, scroll indicator, etc. + // Outer container height=rows-1, outer border 2 + header 2 + search bar 1 + inner border 2 + footer 1 + scroll indicator 1 = 9 + const reservedLines = searchQuery ? 12 : 9; const linesPerSession = 3; // height=2 + marginBottom=1 const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); return Math.max(1, Math.floor(availableLines / linesPerSession)); - }, [rows]); - - // Ensure index stays within valid range - const safeIndex = useMemo(() => { - if (sessions.length === 0) return 0; - return Math.max(0, Math.min(index, sessions.length - 1)); - }, [index, sessions.length]); + }, [rows, searchQuery]); // Calculate scroll offset to keep the selected item visible const scrollOffset = useMemo(() => { @@ -36,23 +68,63 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac // Get the currently visible session list const visibleSessions = useMemo(() => { - return sessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); - }, [sessions, scrollOffset, maxVisibleSessions]); + return filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); + }, [filteredSessions, scrollOffset, maxVisibleSessions]); + + // Handle backspace for search query + const handleBackspace = useCallback(() => { + setSearchQuery((prev) => prev.slice(0, -1)); + setIndex(0); + }, []); useInput((input, key) => { - if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + // ESC: clear search first, then cancel + if (key.escape) { + if (searchQuery) { + setSearchQuery(""); + setIndex(0); + return; + } onCancel(); return; } - if (sessions.length === 0) { + + // Ctrl+C also cancels + if (key.ctrl && (input === "c" || input === "C")) { + onCancel(); return; } + + // Backspace / Delete: remove last search character + if (key.backspace || key.delete) { + if (searchQuery) { + handleBackspace(); + return; + } + // If no search query, navigation keys below handle the rest + } + + // Printable character: append to search query + if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab && !key.return) { + // Ignore if it's a named key that happens to have input (safety check) + if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { + return; + } + setSearchQuery((prev) => prev + input); + setIndex(0); + return; + } + + if (filteredSessions.length === 0) { + return; + } + if (key.upArrow) { setIndex((i) => Math.max(0, i - 1)); return; } if (key.downArrow) { - setIndex((i) => Math.min(sessions.length - 1, i + 1)); + setIndex((i) => Math.min(filteredSessions.length - 1, i + 1)); return; } if (key.pageUp) { @@ -60,7 +132,7 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return; } if (key.pageDown) { - setIndex((i) => Math.min(sessions.length - 1, i + maxVisibleSessions)); + setIndex((i) => Math.min(filteredSessions.length - 1, i + maxVisibleSessions)); return; } if (key.home) { @@ -68,17 +140,19 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return; } if (key.end) { - setIndex(sessions.length - 1); + setIndex(filteredSessions.length - 1); return; } if (key.return) { - const session = sessions[safeIndex]; + const session = filteredSessions[safeIndex]; if (session) { onSelect(session.id); } } }); + const hasActiveSearch = searchQuery.trim().length > 0; + if (sessions.length === 0) { return ( @@ -99,15 +173,24 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac > {/* Header row */} - - - Resume a session - - - {" "} - ({sessions.length} total) - + + + + Resume a session + + + {" "} + ({sessions.length} total + {hasActiveSearch ? `, ${filteredSessions.length} matched` : ""}) + + + {/* Search bar */} + + {searchQuery ? `Search: ${searchQuery}` : "Type to search\u2026"} + {searchQuery ? | : null} + + {/* Session list */} - {visibleSessions.map((session, i) => { - const actualIndex = scrollOffset + i; - return ( - - - {actualIndex === safeIndex ? "> " : " "} - - - - - {formatSessionTitle(session.summary || "Untitled")} - - ({session.status}) + {filteredSessions.length === 0 ? ( + + No sessions match "{searchQuery}". + + ) : ( + visibleSessions.map((session, i) => { + const actualIndex = scrollOffset + i; + return ( + + + {actualIndex === safeIndex ? "> " : " "} - - {formatTimestamp(session.updateTime)} + + + + {formatSessionTitle(session.summary || "Untitled")} + + ({formatSessionStatus(session.status)}) + + + {formatTimestamp(session.updateTime)} + - - ); - })} - {scrollOffset > 0 || scrollOffset + maxVisibleSessions < sessions.length ? ( + ); + }) + )} + {scrollOffset > 0 || scrollOffset + maxVisibleSessions < filteredSessions.length ? ( - {scrollOffset > 0 ? … {scrollOffset} newer sessions above. : null} - {scrollOffset + maxVisibleSessions < sessions.length ? ( - … {sessions.length - scrollOffset - maxVisibleSessions} older sessions below. + {scrollOffset > 0 ? … {scrollOffset} sessions above. : null} + {scrollOffset + maxVisibleSessions < filteredSessions.length ? ( + … {filteredSessions.length - scrollOffset - maxVisibleSessions} sessions below. ) : null} ) : null} {/* Footer */} - - ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel + + {hasActiveSearch ? ( + + Esc clear search · + ↑/↓ navigate · Enter select · Esc again to cancel + + ) : ( + + Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel + + )} @@ -179,6 +277,25 @@ export function formatSessionTitle(value: string, max = 70): string { return truncate(value.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim(), max); } +export function formatSessionStatus(status: SessionStatus): string { + switch (status) { + case "completed": + return "done"; + case "processing": + return "running"; + case "pending": + return "pending"; + case "waiting_for_user": + return "waiting"; + case "failed": + return "failed"; + case "interrupted": + return "stopped"; + default: + return status; + } +} + function truncate(value: string, max: number): string { if (value.length <= max) { return value; diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx index 02ff308..df599b5 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -3,6 +3,7 @@ import type { SlashCommandItem } from "./slashCommands"; import { ARGS_SEPARATOR } from "./constants"; import React from "react"; import { Box, Text } from "ink"; +import type { SkillInfo } from "../session"; type SlashCommandMenuProps = { items: SlashCommandItem[]; @@ -10,7 +11,9 @@ type SlashCommandMenuProps = { width: number; maxVisible?: number; }; - +export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean { + return skills.some((item) => item.name === skill.name); +} const SlashCommandMenu = React.memo(function SlashCommandMenu({ items, activeIndex, diff --git a/src/ui/components/FileMentionMenu/index.tsx b/src/ui/components/FileMentionMenu/index.tsx new file mode 100644 index 0000000..ce9a8ee --- /dev/null +++ b/src/ui/components/FileMentionMenu/index.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import { Box, Text } from "ink"; +import { useInput } from "ink"; +import DropdownMenu from "../../DropdownMenu"; +import type { FileMentionItem, FileMentionToken } from "../../fileMentions"; + +type Props = { + open: boolean; + width: number; + token: FileMentionToken | null; + items: FileMentionItem[]; + onClose: () => void; + onSelect: (item: FileMentionItem) => void; +}; + +const FileMentionMenu: React.FC = ({ open, width, token, items, onClose, onSelect }) => { + const [activeIndex, setActiveIndex] = useState(0); + + // Reset index when opened + useEffect(() => { + if (open) { + setActiveIndex(0); + } + }, [open]); + + // Validate activeIndex bounds + useEffect(() => { + if (!open) { + return; + } + if (items.length === 0) { + setActiveIndex(0); + return; + } + if (activeIndex >= items.length) { + setActiveIndex(Math.max(0, items.length - 1)); + } + }, [activeIndex, items.length, open]); + + useInput( + (input, key) => { + if (!open) { + return; + } + + if (key.escape) { + onClose(); + return; + } + + if (key.upArrow) { + if (items.length > 0) { + setActiveIndex((idx) => (idx - 1 + items.length) % items.length); + } + return; + } + + if (key.downArrow) { + if (items.length > 0) { + setActiveIndex((idx) => (idx + 1) % items.length); + } + return; + } + + if (key.tab || (key.return && !key.shift && !key.meta)) { + const selected = items[activeIndex]; + if (selected) { + onSelect(selected); + return; + } + if (key.tab) { + onClose(); + } + return; + } + }, + { isActive: open } + ); + + if (!open) { + return null; + } + + return ( + ({ + key: item.path, + label: item.path, + description: item.type === "directory" ? "directory" : "file", + }))} + activeIndex={activeIndex} + activeColor="#229ac3" + maxVisible={8} + renderItem={(item, isActive) => ( + + {isActive ? "> " : " "} + + + {item.label} + + + {item.description ? ( + + {item.description} + + ) : null} + + )} + /> + ); +}; + +export default FileMentionMenu; diff --git a/src/ui/components/ModelsDropdown/index.tsx b/src/ui/components/ModelsDropdown/index.tsx new file mode 100644 index 0000000..bdd68ab --- /dev/null +++ b/src/ui/components/ModelsDropdown/index.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from "react"; +import { useInput } from "ink"; +import DropdownMenu from "../../DropdownMenu"; +import type { ModelConfigSelection, ReasoningEffort } from "../../../settings"; + +type ModelStep = "model" | "thinking"; + +type ThinkingModeOption = { + label: string; + thinkingEnabled: boolean; + reasoningEffort?: ReasoningEffort; +}; + +export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; + +export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ + { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, + { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, + { label: "No thinking", thinkingEnabled: false }, +]; + +function getThinkingOptionIndex(config: Pick): number { + const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => { + if (!config.thinkingEnabled) { + return !option.thinkingEnabled; + } + return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort; + }); + return index >= 0 ? index : 0; +} + +type Props = { + open: boolean; + modelConfig: ModelConfigSelection; + width: number; + onClose: () => void; + onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; + onStatusMessage?: (message: string | null) => void; +}; + +const ModelsDropdown: React.FC = ({ + open, + modelConfig, + width, + onClose, + onModelConfigChange, + onStatusMessage, +}) => { + const [step, setStep] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const [pendingModel, setPendingModel] = useState(null); + + // Initialize state when opened + useEffect(() => { + if (open) { + const currentIndex = MODEL_COMMAND_MODELS.findIndex((m) => m === modelConfig.model); + setPendingModel(null); + setStep("model"); + setActiveIndex(currentIndex >= 0 ? currentIndex : 0); + } else { + setStep(null); + } + }, [open, modelConfig.model]); + + // Validate activeIndex bounds + useEffect(() => { + if (!step) { + return; + } + const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + if (activeIndex >= optionCount) { + setActiveIndex(Math.max(0, optionCount - 1)); + } + }, [activeIndex, step]); + + function selectItem(): void { + if (step === "model") { + const model = MODEL_COMMAND_MODELS[activeIndex] ?? modelConfig.model; + setPendingModel(model); + setStep("thinking"); + setActiveIndex(getThinkingOptionIndex(modelConfig)); + return; + } + + const option = MODEL_COMMAND_THINKING_OPTIONS[activeIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]!; + const selection: ModelConfigSelection = { + model: pendingModel ?? modelConfig.model, + thinkingEnabled: option.thinkingEnabled, + reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort, + }; + onClose(); + Promise.resolve(onModelConfigChange(selection)) + .then((message) => { + if (message) { + onStatusMessage?.(message); + } + }) + .catch((error) => { + const msg = error instanceof Error ? error.message : String(error); + onStatusMessage?.(`Failed to update model settings: ${msg}`); + }); + } + + useInput( + (input, key) => { + if (!step) { + return; + } + + const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + + if (key.upArrow) { + setActiveIndex((idx) => (idx - 1 + optionCount) % optionCount); + return; + } + if (key.downArrow) { + setActiveIndex((idx) => (idx + 1) % optionCount); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + selectItem(); + return; + } + if (key.tab || key.escape) { + onClose(); + return; + } + }, + { isActive: open } + ); + + if (!open || !step) { + return null; + } + + const items = + step === "model" + ? MODEL_COMMAND_MODELS.map((model) => ({ + key: model, + label: model, + description: model === modelConfig.model ? "current model" : "", + selected: model === (pendingModel ?? modelConfig.model), + })) + : MODEL_COMMAND_THINKING_OPTIONS.map((option, i) => ({ + key: option.label, + label: option.label, + description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", + selected: getThinkingOptionIndex(modelConfig) === i, + })); + + return ( + + ); +}; + +export { getThinkingOptionIndex }; +export default ModelsDropdown; diff --git a/src/ui/components/SkillsDropdown/index.tsx b/src/ui/components/SkillsDropdown/index.tsx new file mode 100644 index 0000000..b320d24 --- /dev/null +++ b/src/ui/components/SkillsDropdown/index.tsx @@ -0,0 +1,74 @@ +import DropdownMenu from "../../DropdownMenu"; +import React, { useEffect, useState } from "react"; +import type { SkillInfo } from "../../../session"; +import { useInput } from "ink"; +import { isSkillSelected } from "../../SlashCommandMenu"; + +const SkillsDropdown: React.FC<{ + open: boolean; + onClose?: (value: boolean) => void; + width: number; + skills: SkillInfo[]; + selectedSkills: SkillInfo[]; + onSelect?: (skill: SkillInfo) => void; +}> = ({ open, width, skills, selectedSkills, onSelect, onClose }) => { + const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); + useInput( + (input, key) => { + if (key.upArrow) { + setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); + return; + } + if (key.downArrow) { + setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + const skill = skills[skillsDropdownIndex]; + if (skill) { + onSelect?.(skill); + } + return; + } + if (key.tab) { + onClose?.(false); + return; + } + if (key.escape) { + onClose?.(false); + } + }, + { isActive: open } + ); + + useEffect(() => { + if (skillsDropdownIndex >= skills.length) { + setSkillsDropdownIndex(Math.max(0, skills.length - 1)); + } + }, [skills.length, skillsDropdownIndex]); + + if (!open) { + return null; + } + + return ( + ({ + 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={6} + /> + ); +}; + +export default SkillsDropdown; diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index 942d3ed..635f733 100644 --- a/src/ui/components/index.ts +++ b/src/ui/components/index.ts @@ -1,3 +1,6 @@ export { default as RawModelDropdown } from "./RawModelDropdown"; export { MessageView } from "./MessageView"; export { RawModeExitPrompt } from "./RawModeExitPrompt"; +export { default as SkillsDropdown } from "./SkillsDropdown"; +export { default as ModelsDropdown } from "./ModelsDropdown"; +export { default as FileMentionMenu } from "./FileMentionMenu"; diff --git a/src/ui/index.ts b/src/ui/index.ts index 681d77c..26e7eaa 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,3 +1,9 @@ +import { + getThinkingOptionIndex, + MODEL_COMMAND_MODELS, + MODEL_COMMAND_THINKING_OPTIONS, +} from "./components/ModelsDropdown"; + export { readSettings, readProjectSettings, @@ -17,7 +23,6 @@ export { IMAGE_ATTACHMENT_CLEAR_HINT, formatImageAttachmentStatus, formatSelectedSkillsStatus, - isSkillSelected, addUniqueSkill, toggleSkillSelection, removeCurrentSlashToken, @@ -25,9 +30,6 @@ export { getPromptReturnKeyAction, renderBufferWithCursor, buildInitPromptSubmission, - getThinkingOptionIndex, - MODEL_COMMAND_MODELS, - MODEL_COMMAND_THINKING_OPTIONS, useTerminalInput, parseTerminalInput, dispatchTerminalInput, @@ -35,8 +37,9 @@ export { type PromptDraft, type InputKey, } from "./PromptInput"; +export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS }; export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; -export { SessionList, formatSessionTitle } from "./SessionList"; +export { SessionList, formatSessionTitle, filterSessions, formatSessionStatus } from "./SessionList"; export { ThemedGradient } from "./ThemedGradient"; export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt"; export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./WelcomeScreen";