From abed1495821b1c9d82f870d05e62dd83afffbaa6 Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 25 May 2026 11:47:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=A2=9E=E5=8A=A0=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=A0=E9=99=A4=E5=8F=8A=E7=9B=B8=E5=85=B3UI?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 handleDeleteSession 方法,支持删除会话并更新会话列表 - 删除当前激活会话时,清除屏幕、重置UI状态并显示欢迎界面 - 删除按钮调用封装的删除处理函数,统一逻辑 - 优化 SessionList 中搜索逻辑,调整删除和退格键处理 - 移除 SessionList 文件内重复的 truncate 函数实现 --- src/ui/App.tsx | 181 ++++++++++++++++++++++------------------ src/ui/AppContainer.tsx | 2 +- src/ui/SessionList.tsx | 19 +---- src/ui/constants.ts | 3 + src/ui/utils/index.ts | 24 ++++++ 5 files changed, 132 insertions(+), 97 deletions(-) create mode 100644 src/ui/utils/index.ts diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3879915..71e9ca3 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -43,6 +43,8 @@ import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPromp import { buildExitSummaryText } from "./exitSummary"; import { RawMode, useRawModeContext } from "./contexts"; import { renderMessageToStdout } from "./components/MessageView/utils"; +import { renderRawModeMessages } from "./utils"; +import { ANSI_CLEAR_SCREEN } from "./constants"; const DEFAULT_MODEL = "deepseek-v4-pro"; const DEFAULT_BASE_URL = "https://api.deepseek.com"; @@ -55,7 +57,7 @@ type AppProps = { onRestart?: () => void; }; -export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { +function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns, rows } = useWindowSize(); @@ -142,6 +144,33 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. }); }, [projectRoot]); + /** + * Navigate to a sub-view. + */ + const navigateToSubView = useCallback((targetView: View) => { + setShowWelcome(false); + setView(targetView); + }, []); + + /** + * Reset the static view to the welcome screen. + */ + const resetStaticView = useCallback( + (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }) => { + if (options?.clearScreen) { + process.stdout.write(ANSI_CLEAR_SCREEN); + } + setMessages([]); + setWelcomeNonce((n) => n + 1); + navigateToSubView("chat"); + setTimeout(() => { + setMessages(loadedMessages); + setShowWelcome(true); + }, 0); + }, + [navigateToSubView] + ); + useEffect(() => { if (!busy) { return; @@ -170,6 +199,26 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. [sessionManager] ); + /** + * Reset the app to the welcome screen. + */ + const resetToWelcome = useCallback(async () => { + writeRef.current(ANSI_CLEAR_SCREEN); + sessionManager.setActiveSessionId(null); + setStatusLine(""); + setErrorLine(null); + setRunningProcesses(null); + setActiveStatus(null); + setActiveAskPermissions(undefined); + setPendingPermissionReply(null); + setDismissedQuestionIds(new Set()); + resetStaticView([]); + await refreshSkills(); + }, [sessionManager, resetStaticView, refreshSkills]); + + /** + * Refresh the list of sessions. + */ useEffect(() => { refreshSessionsList(); void refreshSkills(); @@ -182,11 +231,17 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. createOpenAIClient(projectRoot); }, [projectRoot]); + /** + * Initialize MCP servers. + */ useLayoutEffect(() => { const settings = resolveCurrentSettings(projectRoot); void sessionManager.initMcpServers(settings.mcpServers); }, [projectRoot, sessionManager]); + /** + * Dispose the session manager on unmount. + */ useEffect(() => { return () => { sessionManager.dispose(); @@ -216,33 +271,19 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. if (onRestart) { onRestart(); } else { - writeRef.current("\u001B[2J\u001B[3J\u001B[H"); - sessionManager.setActiveSessionId(null); - setMessages([]); - setStatusLine(""); - setErrorLine(null); - setRunningProcesses(null); - setActiveStatus(null); - setActiveAskPermissions(undefined); - setPendingPermissionReply(null); - setDismissedQuestionIds(new Set()); - setShowWelcome(true); - setWelcomeNonce((n) => n + 1); - await refreshSkills(); + await resetToWelcome(); refreshSessionsList(); } return; } if (submission.command === "resume") { - setShowWelcome(false); refreshSessionsList(); - setView("session-list"); + navigateToSubView("session-list"); return; } if (submission.command === "continue" && isCurrentSessionEmpty(sessionManager)) { - setShowWelcome(false); refreshSessionsList(); - setView("session-list"); + navigateToSubView("session-list"); return; } if (submission.command === "undo") { @@ -251,15 +292,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setErrorLine("No active session to undo."); return; } - setShowWelcome(false); setUndoTargets(sessionManager.listUndoTargets(activeSessionId)); - setView("undo"); + navigateToSubView("undo"); return; } if (submission.command === "mcp") { - setShowWelcome(false); setMcpStatuses(sessionManager.getMcpStatus()); - setView("mcp-status"); + navigateToSubView("mcp-status"); return; } @@ -311,7 +350,16 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setRunningProcesses(null); } }, - [exit, onRestart, pendingPermissionReply, sessionManager, refreshSkills, refreshSessionsList] + [ + sessionManager, + pendingPermissionReply, + exit, + onRestart, + refreshSkills, + refreshSessionsList, + navigateToSubView, + resetToWelcome, + ] ); const handleInterrupt = useCallback(() => { @@ -384,16 +432,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. const reloadActiveSessionView = useCallback( (sessionId: string): void => { - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); - setMessages([]); - setShowWelcome(false); - setWelcomeNonce((n) => n + 1); - setTimeout(() => { - setMessages(loadVisibleMessages(sessionManager, sessionId)); - setShowWelcome(true); - }, 0); + resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); }, - [sessionManager] + [resetStaticView, sessionManager] ); useEffect(() => { @@ -411,21 +452,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. const handleSelectSession = useCallback( async (sessionId: string) => { - const currentSessionId = sessionManager.getActiveSessionId(); - if (currentSessionId !== sessionId) { - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); - } sessionManager.setActiveSessionId(sessionId); // Clear first so resets its index to 0. - setMessages([]); - setShowWelcome(false); - setWelcomeNonce((n) => n + 1); - setView("chat"); - // Load messages after the reset so all static items are rendered. - setTimeout(() => { - setMessages(loadVisibleMessages(sessionManager, sessionId)); - setShowWelcome(true); - }, 0); + resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session) : ""); setRunningProcesses(session?.processes ?? null); @@ -436,7 +465,26 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. } await refreshSkills(sessionId); }, - [pendingPermissionReply, sessionManager, refreshSkills] + [sessionManager, resetStaticView, pendingPermissionReply, refreshSkills] + ); + + const handleDeleteSession = useCallback( + async (id: string): Promise => { + const isActiveSession = sessionManager.getActiveSessionId() === id; + + // If the deleted session is the active one, clear the active session first + if (isActiveSession) { + sessionManager.setActiveSessionId(null); + } + + sessionManager.deleteSession(id); + refreshSessionsList(); + + if (isActiveSession) { + await resetToWelcome(); + } + }, + [sessionManager, refreshSessionsList, resetToWelcome] ); const handleUndoRestore = useCallback( @@ -487,25 +535,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setShowWelcome(false); setMessages([]); // Clear screen to remove stale formatted text. - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); + process.stdout.write(ANSI_CLEAR_SCREEN); setTimeout(() => { if (nextMode === RawMode.Raw) { // Write all messages directly to stdout for raw scrollback mode. const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; - for (const msg of allMessages) { - process.stdout.write("\n"); - process.stdout.write(renderMessageToStdout(msg, nextMode) + "\n\n"); - } - if (allMessages.length > 0) { - process.stdout.write("\n\n"); - process.stdout.write(chalk.dim("Press ESC to exit raw mode")); - } else { - process.stdout.write("\n"); - process.stdout.write(chalk.dim("(No messages in this session yet. Start chatting to see them here.)")); - process.stdout.write("\n\n"); - process.stdout.write(chalk.dim("Press ESC to exit raw mode")); - } + renderRawModeMessages(allMessages, nextMode); } else if (activeSessionId) { // Switch to chat view to render messages. handleSelectSession(activeSessionId); @@ -538,22 +574,10 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. if (mode === RawMode.Raw) { // In raw mode, re-render all messages directly to stdout at the new width. // Use process.stdout.write instead of writeRef to avoid Ink interference. - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); + process.stdout.write(ANSI_CLEAR_SCREEN); const activeSessionId = sessionManager.getActiveSessionId(); const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; - for (const msg of allMessages) { - process.stdout.write("\n"); - process.stdout.write(renderMessageToStdout(msg, mode) + "\n\n"); - } - if (allMessages.length > 0) { - process.stdout.write("\n\n"); - process.stdout.write(chalk.dim("Press ESC to exit raw mode")); - } else { - process.stdout.write("\n"); - process.stdout.write(chalk.dim("(No messages in this session yet. Start chatting to see them here.)")); - process.stdout.write("\n\n"); - process.stdout.write(chalk.dim("Press ESC to exit raw mode")); - } + renderRawModeMessages(allMessages, mode); return; } @@ -719,12 +743,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. onSelect={(id) => void handleSelectSession(id)} onCancel={() => setView("chat")} onDelete={(id) => { - // If the deleted session is the active one, clear it - if (sessionManager.getActiveSessionId() === id) { - sessionManager.setActiveSessionId(null); - } - sessionManager.deleteSession(id); - refreshSessionsList(); + void handleDeleteSession(id); }} /> ) : view === "undo" ? ( @@ -784,6 +803,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. ); } +export default App; + function isCollapsedThinking(message: SessionMessage, expandedId: string | null): boolean { if (message.role !== "assistant") { return false; diff --git a/src/ui/AppContainer.tsx b/src/ui/AppContainer.tsx index e437b44..c8b3177 100644 --- a/src/ui/AppContainer.tsx +++ b/src/ui/AppContainer.tsx @@ -1,6 +1,6 @@ import React from "react"; import { AppContext } from "./contexts"; -import { App } from "./App"; +import App from "./App"; import { RawModeProvider } from "./contexts/RawModeContext"; const AppContainer: React.FC<{ diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 7d7e04e..82ca797 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -1,6 +1,7 @@ import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import type { SessionEntry, SessionStatus } from "../session"; +import { truncate } from "./components/MessageView/utils"; type Props = { sessions: SessionEntry[]; @@ -113,17 +114,10 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): return; } - // Backspace: remove last search character - if (key.backspace) { - if (searchQuery) { - handleBackspace(); - return; - } - } - // Delete key: remove search character, or start delete confirmation - if (key.delete) { + if (key.delete || key.backspace) { if (searchQuery) { + // remove last search character handleBackspace(); return; } @@ -342,10 +336,3 @@ export function formatSessionStatus(status: SessionStatus): string { return status; } } - -function truncate(value: string, max: number): string { - if (value.length <= max) { - return value; - } - return `${value.slice(0, max)}…`; -} diff --git a/src/ui/constants.ts b/src/ui/constants.ts index 7c74597..43372f8 100644 --- a/src/ui/constants.ts +++ b/src/ui/constants.ts @@ -2,3 +2,6 @@ /** Separator used when rendering command arguments inline (e.g., `arg1 | arg2 | arg3`). */ export const ARGS_SEPARATOR = " | "; + +/** ANSI escape code to clear the screen. */ +export const ANSI_CLEAR_SCREEN = "\u001B[2J\u001B[3J\u001B[H"; diff --git a/src/ui/utils/index.ts b/src/ui/utils/index.ts new file mode 100644 index 0000000..4b498a0 --- /dev/null +++ b/src/ui/utils/index.ts @@ -0,0 +1,24 @@ +import chalk from "chalk"; +import type { SessionMessage } from "../../session"; +import { renderMessageToStdout } from "../components/MessageView/utils"; +import type { RawMode } from "../contexts"; + +/** + * Render all messages directly to stdout for Raw mode display. + * Writes each message followed by the "Press ESC to exit raw mode" footer. + */ +export function renderRawModeMessages(allMessages: SessionMessage[], mode: string | RawMode): void { + for (const msg of allMessages) { + process.stdout.write("\n"); + process.stdout.write(renderMessageToStdout(msg, mode as RawMode) + "\n\n"); + } + if (allMessages.length > 0) { + process.stdout.write("\n\n"); + process.stdout.write(chalk.dim("Press ESC to exit raw mode")); + } else { + process.stdout.write("\n"); + process.stdout.write(chalk.dim("(No messages in this session yet. Start chatting to see them here.)")); + process.stdout.write("\n\n"); + process.stdout.write(chalk.dim("Press ESC to exit raw mode")); + } +}