From 47d3c21abe3c3582d24e7c1109bdf19e0818c90d Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 18 May 2026 18:13:34 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20/raw=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=92=8C=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RawMode 功能,包括 Normal、Lite 和 Raw scrollback 模式 - App 组件中集成 RawMode 上下文及切换逻辑,支持在 Raw 模式下直接向 stdout 渲染消息 - 增加 RawModeExitPrompt 组件,支持按 ESC 退出原始模式 - 新增 RawModelDropdown 组件,提供原始模式选择下拉菜单 - 在 PromptInput 中集成原始模式选择交互及状态管理 - 调整消息视图实现,拆分 MessageView 到 compoments 目录,支持根据 RawMode 呈现不同内容 - 新建 AppContainer 组件,包装 App 并提供版本上下文和 RawModeProvider - 修改 SlashCommand 体系,支持内置 /raw 命令及对应测试覆盖 - 更新 cli 入口,使用 AppContainer 替换直接渲染 App,传递版本信息 - 移除旧 MessageView 文件,重构消息渲染逻辑 - 优化 SlashCommandMenu 显示,支持命令参数提示显示 - 更新相关测试,支持原始模式功能验证 --- src/cli.tsx | 4 +- src/tests/messageView.test.ts | 51 +-- src/tests/slashCommands.test.ts | 9 +- src/ui/App.tsx | 69 +++- src/ui/AppContainer.tsx | 21 ++ src/ui/MessageView.tsx | 355 ------------------ src/ui/PromptInput.tsx | 27 +- src/ui/SlashCommandMenu.tsx | 5 +- src/ui/WelcomeScreen.tsx | 11 +- src/ui/compoments/MessageView/index.tsx | 183 +++++++++ .../{ => compoments/MessageView}/markdown.ts | 0 src/ui/compoments/MessageView/types.ts | 19 + src/ui/compoments/MessageView/utils.ts | 255 +++++++++++++ src/ui/compoments/RawModeExitPrompt/index.tsx | 15 + src/ui/compoments/RawModelDropdown/index.tsx | 55 +++ src/ui/compoments/index.ts | 3 + src/ui/contexts/AppContext.tsx | 15 + src/ui/contexts/RawModeContext.tsx | 40 ++ src/ui/contexts/index.ts | 3 + src/ui/index.ts | 5 +- src/ui/slashCommands.ts | 22 +- 21 files changed, 750 insertions(+), 417 deletions(-) create mode 100644 src/ui/AppContainer.tsx delete mode 100644 src/ui/MessageView.tsx create mode 100644 src/ui/compoments/MessageView/index.tsx rename src/ui/{ => compoments/MessageView}/markdown.ts (100%) create mode 100644 src/ui/compoments/MessageView/types.ts create mode 100644 src/ui/compoments/MessageView/utils.ts create mode 100644 src/ui/compoments/RawModeExitPrompt/index.tsx create mode 100644 src/ui/compoments/RawModelDropdown/index.tsx create mode 100644 src/ui/compoments/index.ts create mode 100644 src/ui/contexts/AppContext.tsx create mode 100644 src/ui/contexts/RawModeContext.tsx create mode 100644 src/ui/contexts/index.ts diff --git a/src/cli.tsx b/src/cli.tsx index 435499a..e8e8659 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,8 +1,8 @@ import React from "react"; import { render } from "ink"; -import { App } from "./ui"; import { setShellIfWindows } from "./common/shell-utils"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck"; +import AppContainer from "./ui/AppContainer"; const args = process.argv.slice(2); const packageInfo = readPackageInfo(); @@ -81,7 +81,7 @@ async function main(): Promise { const appInitialPrompt = initialPrompt; initialPrompt = undefined; const inkInstance = render( - { const lines = parseDiffPreview( @@ -25,45 +26,29 @@ test("parseDiffPreview keeps nonstandard context lines", () => { test("MessageView summarizes thinking content across lines", () => { assert.equal( - getThinkingParams({ - content: "Plan:\n\nInspect the code and update tests", - }), + buildThinkingSummary("Plan:\n\nInspect the code and update tests", null, RawMode.Lite), "Plan: Inspect the code and update tests" ); }); -test("MessageView removes a trailing colon from thinking summaries", () => { - assert.equal(getThinkingParams({ content: "Planning:" }), "Planning"); +test("MessageView removes a trailing colon from thinking summary", () => { + assert.equal(buildThinkingSummary("Planning:", null, RawMode.Lite), "Planning"); }); -test("MessageView falls back to a reasoning placeholder for hidden reasoning content", () => { +test("MessageView falls back to a reasoning placeholder for hidden reasoning content in Lite mode", () => { assert.equal( - getThinkingParams({ - content: "", - messageParams: { reasoning_content: "hidden chain of thought" }, - }), + buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Lite), "(reasoning...)" ); }); -function getThinkingParams(overrides: Partial): string { - const view = MessageView({ message: buildAssistantMessage(overrides) }) as any; - return view.props.children.props.params; -} - -function buildAssistantMessage(overrides: Partial): SessionMessage { - return { - id: "message-1", - sessionId: "session-1", - role: "assistant", - content: "", - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - meta: { asThinking: true }, - ...overrides, - }; -} +test("MessageView shows full reasoning content in Normal/Raw mode", () => { + assert.equal( + buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.None), + "hidden chain of thought" + ); + assert.equal( + buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Raw), + "hidden chain of thought" + ); +}); diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index bba5244..34b48d0 100644 --- a/src/tests/slashCommands.test.ts +++ b/src/tests/slashCommands.test.ts @@ -19,7 +19,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => { assert.equal(items[0].kind, "skill"); assert.equal(items[0].name, "skill-writer"); const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name); - assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "exit"]); + assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "raw", "exit"]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -80,6 +80,13 @@ test("findExactSlashCommand returns built-in /model", () => { assert.equal(item?.kind, "model"); }); +test("findExactSlashCommand returns built-in /raw", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/raw"); + assert.ok(item); + assert.equal(item?.kind, "raw"); +}); + test("findExactSlashCommand returns the matching skill", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/code-review"); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e56111f..1c9bac4 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -6,10 +6,10 @@ import * as os from "os"; import * as path from "path"; import OpenAI from "openai"; import { - SessionManager, type LlmStreamProgress, type MessageMeta, type SessionEntry, + SessionManager, type SessionMessage, type SessionStatus, type SkillInfo, @@ -17,13 +17,13 @@ import { } from "../session"; import { applyModelConfigSelection, - resolveSettingsSources, type DeepcodingSettings, type ModelConfigSelection, type ResolvedDeepcodingSettings, + resolveSettingsSources, } from "../settings"; import { PromptInput, type PromptSubmission } from "./PromptInput"; -import { MessageView } from "./MessageView"; +import { MessageView, RawModeExitPrompt } from "./compoments"; import { SessionList } from "./SessionList"; import { buildLoadingText } from "./loadingText"; import { findExpandedThinkingId } from "./thinkingState"; @@ -32,11 +32,13 @@ import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { McpStatusList } from "./McpStatusList"; import { ProcessStdoutView } from "./ProcessStdoutView"; import { + type AskUserQuestionAnswers, findPendingAskUserQuestion, formatAskUserQuestionAnswers, - type AskUserQuestionAnswers, } from "./askUserQuestion"; import { buildExitSummaryText } from "./exitSummary"; +import { RawMode, useRawModeContext } from "./contexts"; +import { renderMessageToStdout } from "./compoments/MessageView/utils"; const DEFAULT_MODEL = "deepseek-v4-pro"; const DEFAULT_BASE_URL = "https://api.deepseek.com"; @@ -45,12 +47,11 @@ type View = "chat" | "session-list" | "mcp-status"; type AppProps = { projectRoot: string; - version?: string; initialPrompt?: string; onRestart?: () => void; }; -export function App({ projectRoot, version = "", initialPrompt, onRestart }: AppProps): React.ReactElement { +export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns } = useWindowSize(); @@ -75,6 +76,10 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App const [showProcessStdout, setShowProcessStdout] = useState(false); const processStdoutRef = useRef>(new Map()); + const { mode, setMode } = useRawModeContext(); + const rawModeRef = useRef(mode); + rawModeRef.current = mode; + const messagesRef = useRef([]); messagesRef.current = messages; @@ -86,6 +91,10 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App renderMarkdown: (text) => text, onAssistantMessage: (message: SessionMessage) => { setMessages((prev) => [...prev, message]); + if (rawModeRef.current === RawMode.Raw) { + process.stdout.write("\n"); + process.stdout.write(renderMessageToStdout(message, rawModeRef.current) + "\n\n"); + } }, onSessionEntryUpdated: (entry) => { setStatusLine(buildStatusLine(entry)); @@ -362,6 +371,39 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App [sessionManager, refreshSkills] ); + const handleRawModeChange = useCallback( + (nextMode: string) => { + const activeSessionId = sessionManager.getActiveSessionId(); + if (!activeSessionId) { + return; + } + + setMode(nextMode as RawMode); + + // Clear screen to remove stale formatted text. + process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); + + setTimeout(() => { + if (nextMode === RawMode.Raw) { + // Write all messages directly to stdout for raw scrollback mode. + const allMessages = 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 { + // Switch to chat view to render messages. + handleSelectSession(activeSessionId); + } + }, 200); + }, + [handleSelectSession, sessionManager, setMode] + ); + const [stableColumns, setStableColumns] = useState(columns); useEffect(() => { const timer = setTimeout(() => setStableColumns(columns), 100); @@ -413,7 +455,7 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App // eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation [busy, streamProgress, runningProcesses, nowTick] ); - const welcomeSettings = resolvedSettings; + const welcomeItem: SessionMessage = useMemo( () => ({ id: `__welcome__${welcomeNonce}`, @@ -430,11 +472,14 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App [welcomeNonce] ); const staticItems = useMemo(() => { + if (mode === RawMode.Raw) { + return []; + } if (showWelcome && view === "chat") { return [welcomeItem, ...messages]; } return messages; - }, [showWelcome, view, messages, welcomeItem]); + }, [mode, showWelcome, view, messages, welcomeItem]); const handleQuestionAnswers = useCallback( (answers: AskUserQuestionAnswers) => { @@ -453,6 +498,10 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App setDismissedQuestionIds((prev) => new Set(prev).add(pendingQuestion.messageId)); }, [pendingQuestion]); + if (mode === RawMode.Raw) { + return handleRawModeChange(RawMode.None)} />; + } + return ( @@ -462,9 +511,8 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App ); @@ -521,6 +569,7 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App runningProcesses={runningProcesses} onSubmit={handleSubmit} onModelConfigChange={handleModelConfigChange} + onRawModeChange={handleRawModeChange} onInterrupt={handleInterrupt} onToggleProcessStdout={handleToggleProcessStdout} placeholder="Type your message..." diff --git a/src/ui/AppContainer.tsx b/src/ui/AppContainer.tsx new file mode 100644 index 0000000..e437b44 --- /dev/null +++ b/src/ui/AppContainer.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { AppContext } from "./contexts"; +import { App } from "./App"; +import { RawModeProvider } from "./contexts/RawModeContext"; + +const AppContainer: React.FC<{ + projectRoot: string; + version: string; + initialPrompt: string | undefined; + onRestart: () => void; +}> = ({ version, projectRoot, initialPrompt, onRestart }) => { + return ( + + + + + + ); +}; + +export default AppContainer; diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx deleted file mode 100644 index c8793fc..0000000 --- a/src/ui/MessageView.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import React from "react"; -import { Box, Text } from "ink"; -import { renderMarkdown } from "./markdown"; -import type { SessionMessage } from "../session"; - -type Props = { - message: SessionMessage; - collapsed?: boolean; - width?: number; -}; - -export function MessageView({ message, collapsed, width = 80 }: Props): React.ReactElement | null { - if (!message.visible) { - return null; - } - - if (message.role === "user") { - const text = message.content || "(no content)"; - return ( - - - {`>`} - - - {text} - {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( - {` 📎 ${message.contentParams.length} image attachment(s)`} - ) : null} - - - ); - } - - if (message.role === "assistant") { - const isThinking = Boolean(message.meta?.asThinking); - const content = (message.content || "").trim(); - - if (isThinking) { - const summary = buildThinkingSummary(content, message.messageParams); - if (collapsed !== false) { - return ( - - - - ); - } - return ( - - - - {content ? {renderMarkdown(content)} : null} - - - ); - } - - const containerWidth = Math.max(1, width - 2); - const contentWidth = Math.max(1, width - 4); - - return ( - - - - - - {content ? {renderMarkdown(content)} : null} - - - ); - } - - if (message.role === "tool") { - const summary = buildToolSummary(message); - const diffLines = getToolDiffPreviewLines(summary); - return ( - - - {diffLines.length > 0 ? : null} - - ); - } - - if (message.role === "system") { - // Render model change messages in the same style as user commands. - if (message.meta?.isModelChange) { - return ( - - - {`>`} - - - {message.content} - - - ); - } - - if (message.meta?.skill) { - return ( - - ⚡ Loaded skill: {message.meta.skill.name} - - ); - } - if (message.meta?.isSummary) { - return ( - - - (conversation summary inserted) - - - ); - } - return null; - } - - return null; -} - -function StatusLine({ - bulletColor, - name, - params, -}: { - bulletColor: "gray" | "green" | "red"; - name: string; - params: string; -}): React.ReactElement { - return ( - - {[ - - ✧ - , - " ", - - {name} - , - params ? {` ${params}`} : null, - ]} - - ); -} - -function formatToolStatusParams(summary: ToolSummary): string { - const params = firstNonEmptyLine(summary.params); - return summary.name.toLowerCase() === "bash" ? params : truncate(params, 120); -} - -type ToolSummary = { - name: string; - params: string; - ok: boolean; - metadata: Record | null; -}; - -type DiffPreviewLine = { - marker: string; - content: string; - kind: "added" | "removed" | "context"; -}; - -function buildToolSummary(message: SessionMessage): ToolSummary { - const payload = parseToolPayload(message.content); - const metaFunctionName = - message.meta?.function && typeof (message.meta.function as { name?: unknown }).name === "string" - ? (message.meta.function as { name: string }).name - : null; - const name = payload.name || metaFunctionName || "tool"; - const params = - name === "AskUserQuestion" - ? extractAskUserQuestionParams(message) || getMetaParams(message) - : getMetaParams(message); - - return { - name, - params, - ok: payload.ok !== false, - metadata: payload.metadata, - }; -} - -function getMetaParams(message: SessionMessage): string { - return typeof message.meta?.paramsMd === "string" ? message.meta.paramsMd.trim() : ""; -} - -function extractAskUserQuestionParams(message: SessionMessage): string { - const fromFunction = extractQuestionsFromToolFunction(message.meta?.function); - if (fromFunction) { - return fromFunction; - } - - const params = getMetaParams(message); - if (!params) { - return ""; - } - - try { - const parsed = JSON.parse(params); - return extractQuestionsFromValue(parsed); - } catch { - return ""; - } -} - -function extractQuestionsFromToolFunction(toolFunction: unknown): string { - if (!toolFunction || typeof toolFunction !== "object") { - return ""; - } - const args = (toolFunction as { arguments?: unknown }).arguments; - if (typeof args !== "string" || !args.trim()) { - return ""; - } - try { - const parsed = JSON.parse(args); - return extractQuestionsFromValue((parsed as { questions?: unknown })?.questions); - } catch { - return ""; - } -} - -function extractQuestionsFromValue(value: unknown): string { - if (!Array.isArray(value)) { - return ""; - } - return value - .map((item) => { - if (!item || typeof item !== "object" || Array.isArray(item)) { - return ""; - } - return typeof (item as { question?: unknown }).question === "string" - ? (item as { question: string }).question.trim() - : ""; - }) - .filter(Boolean) - .join(" / "); -} - -function parseToolPayload(content: string | null): { - name: string | null; - ok: boolean; - metadata: Record | null; -} { - if (!content) { - return { name: null, ok: true, metadata: null }; - } - - try { - const parsed = JSON.parse(content) as { name?: unknown; ok?: unknown; metadata?: unknown }; - return { - name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null, - ok: parsed.ok !== false, - metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null, - }; - } catch { - return { name: null, ok: true, metadata: null }; - } -} - -function getToolDiffPreviewLines(summary: ToolSummary): DiffPreviewLine[] { - if (!summary.ok || !["edit", "write"].includes(summary.name.toLowerCase())) { - return []; - } - const diffPreview = summary.metadata?.diff_preview; - if (typeof diffPreview !== "string" || !diffPreview.trim()) { - return []; - } - return parseDiffPreview(diffPreview); -} - -export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { - return diffPreview - .split("\n") - .filter((line) => line && !line.startsWith("--- ") && !line.startsWith("+++ ") && !line.startsWith("@@ ")) - .map((line) => { - if (line.startsWith("+")) { - return { marker: "+", content: line.slice(1), kind: "added" }; - } - if (line.startsWith("-")) { - return { marker: "-", content: line.slice(1), kind: "removed" }; - } - return { - marker: " ", - content: line.startsWith(" ") ? line.slice(1) : line, - kind: "context", - }; - }); -} - -function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElement { - return ( - - └ Changes - - {lines.map((line, index) => ( - - - {line.marker} - - - {line.content} - - - ))} - - - ); -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function formatStatusName(value: string): string { - return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : "Tool"; -} - -function truncate(value: string, max: number): string { - if (value.length <= max) { - return value; - } - return `${value.slice(0, max)}…`; -} - -function firstNonEmptyLine(value: string): string { - for (const line of value.split(/\r?\n/)) { - const trimmed = line.trim().replace(/\s+/g, " "); - if (trimmed) { - return trimmed; - } - } - return ""; -} - -function buildThinkingSummary(content: string, messageParams: unknown | null): string { - if (content) { - const normalized = content.replace(/\r?\n/g, " ").replace(/\s+/g, " "); - let result = truncate(normalized, 100); - if (result.endsWith(":") || result.endsWith(":")) { - result = result.slice(0, -1); - } - return result; - } - - const params = messageParams as { reasoning_content?: unknown } | null | undefined; - if (typeof params?.reasoning_content === "string" && params.reasoning_content.trim()) { - return "(reasoning...)"; - } - - return ""; -} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index affa9ad..c1cf335 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -43,6 +43,7 @@ import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusRepor import SlashCommandMenu from "./SlashCommandMenu"; import type { ModelConfigSelection, ReasoningEffort } from "../settings"; import DropdownMenu from "./DropdownMenu"; +import { RawModelDropdown } from "./compoments"; export type PromptSubmission = { text: string; @@ -63,6 +64,7 @@ type Props = { runningProcesses?: Map | null; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; + onRawModeChange?: (mode: string) => void; onInterrupt: () => void; onToggleProcessStdout?: () => void; }; @@ -116,6 +118,7 @@ export const PromptInput = React.memo(function PromptInput({ onModelConfigChange, onInterrupt, onToggleProcessStdout, + onRawModeChange, }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); @@ -126,6 +129,7 @@ export const PromptInput = React.memo(function PromptInput({ const [pendingExit, setPendingExit] = useState(false); 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); @@ -271,6 +275,10 @@ export const PromptInput = React.memo(function PromptInput({ setPendingExit(false); } + if (openRawModelDropdown) { + return; + } + if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { exitHistoryBrowsing(); } @@ -607,6 +615,11 @@ export const PromptInput = React.memo(function PromptInput({ openModelDropdown(); return; } + if (item.kind === "raw") { + clearSlashToken(); + setOpenRawModelDropdown(true); + return; + } if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); setBuffer(EMPTY_BUFFER); @@ -760,10 +773,13 @@ export const PromptInput = React.memo(function PromptInput({ })); const showFooterText = useMemo( - () => showMenu || showSkillsDropdown || modelDropdownStep !== null, - [showMenu, showSkillsDropdown, modelDropdownStep] + () => showMenu || showSkillsDropdown || openRawModelDropdown || modelDropdownStep !== null, + [showMenu, showSkillsDropdown, openRawModelDropdown, modelDropdownStep] ); + const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null; + const inlineHint = matchedCommand?.args ? ` ${matchedCommand.args.join("|")}` : ""; + return ( {imageUrls.length > 0 ? ( @@ -791,7 +807,14 @@ export const PromptInput = React.memo(function PromptInput({ > {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} + {inlineHint ? {inlineHint} : null} + onRawModeChange?.(mode)} + screenWidth={screenWidth} + /> {showSkillsDropdown ? ( s.label.length)); + const longestLabel = Math.max(...items.map((s) => s.label.length + (s.args ? s.args?.join("|")?.length + 4 : 0))); const contentWidth = longestLabel + 2; // +2 for prefix "> " or " " const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 return Math.min(contentWidth, maxAllowed); @@ -49,11 +49,12 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ const actualIndex = visibleStart + idx; return ( - + {actualIndex === activeIndex ? "> " : " "} {formatSlashCommandLabel(item)} + {item.args ? {item.args.join("|")} : null} diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 3d82eed..7e740d1 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -7,12 +7,12 @@ import type { ResolvedDeepcodingSettings } from "../settings"; import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription } from "./slashCommands"; import { ThemedGradient } from "./ThemedGradient"; import { AsciiLogo } from "../AsciiArt"; +import { useAppContext } from "./contexts"; type WelcomeScreenProps = { projectRoot: string; settings: ResolvedDeepcodingSettings; skills: SkillInfo[]; - version: string; width: number; }; @@ -28,13 +28,8 @@ const SHORTCUT_TIPS = [ { label: "Ctrl+D twice", description: "Quit Deep Code CLI" }, ]; -export function WelcomeScreen({ - projectRoot, - settings, - skills, - version, - width, -}: WelcomeScreenProps): React.ReactElement { +export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeScreenProps): React.ReactElement { + const { version } = useAppContext(); const tips = useMemo(() => buildWelcomeTips(skills), [skills]); const [tipIndex] = useState(() => randomTipIndex(tips.length)); const compact = width < TITLE_PANEL_WIDTH + 42; diff --git a/src/ui/compoments/MessageView/index.tsx b/src/ui/compoments/MessageView/index.tsx new file mode 100644 index 0000000..9aa82fd --- /dev/null +++ b/src/ui/compoments/MessageView/index.tsx @@ -0,0 +1,183 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { renderMarkdown } from "./markdown"; +import { + buildThinkingSummary, + buildToolSummary, + formatStatusName, + formatToolStatusParams, + getToolDiffPreviewLines, +} from "./utils"; +import type { DiffPreviewLine, MessageViewProps } from "./types"; +import { RawMode, useRawModeContext } from "../../contexts"; + +export function MessageView({ message, collapsed, width = 80 }: MessageViewProps): React.ReactElement | null { + const { mode } = useRawModeContext(); + if (!message.visible) { + return null; + } + + if (message.role === "user") { + const text = message.content || "(no content)"; + return ( + + + {`>`} + + + {text} + {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( + {` 📎 ${message.contentParams.length} image attachment(s)`} + ) : null} + + + ); + } + + if (message.role === "assistant") { + const isThinking = Boolean(message.meta?.asThinking); + const content = (message.content || "").trim(); + + if (isThinking) { + const summary = buildThinkingSummary(content, message.messageParams, mode); + if (collapsed !== false) { + return ( + + + + ); + } + return ( + + + + {content ? {renderMarkdown(content)} : null} + + + ); + } + + const containerWidth = Math.max(1, width - 2); + const contentWidth = Math.max(1, width - 4); + + return ( + + + + + + {content ? {renderMarkdown(content)} : null} + + + ); + } + + if (message.role === "tool") { + const summary = buildToolSummary(message); + const diffLines = getToolDiffPreviewLines(summary); + return ( + + + {diffLines.length > 0 ? : null} + + ); + } + + if (message.role === "system") { + // Render model change messages in the same style as user commands. + if (message.meta?.isModelChange) { + return ( + + + {`>`} + + + {message.content} + + + ); + } + + if (message.meta?.skill) { + return ( + + ⚡ Loaded skill: {message.meta.skill.name} + + ); + } + if (message.meta?.isSummary) { + return ( + + + (conversation summary inserted) + + + ); + } + return null; + } + + return null; +} + +function StatusLine({ + bulletColor, + name, + params, + width, +}: { + bulletColor: "gray" | "green" | "red"; + name: string; + params: string; + width: number; +}): React.ReactElement { + const { mode } = useRawModeContext(); + const containerWidth = Math.max(1, width - 2); + const contentWidth = Math.max(1, width - 4); + return ( + + + + ✧ + + + + + + {name} + + {params ? ( + + {` ${params}`} + + ) : null} + + + + ); +} + +function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElement { + return ( + + └ Changes + + {lines.map((line, index) => ( + + + {line.marker} + + + {line.content} + + + ))} + + + ); +} diff --git a/src/ui/markdown.ts b/src/ui/compoments/MessageView/markdown.ts similarity index 100% rename from src/ui/markdown.ts rename to src/ui/compoments/MessageView/markdown.ts diff --git a/src/ui/compoments/MessageView/types.ts b/src/ui/compoments/MessageView/types.ts new file mode 100644 index 0000000..743eb2d --- /dev/null +++ b/src/ui/compoments/MessageView/types.ts @@ -0,0 +1,19 @@ +import type { SessionMessage } from "../../../session"; + +export type MessageViewProps = { + message: SessionMessage; + collapsed?: boolean; + width?: number; +}; +export type ToolSummary = { + name: string; + params: string; + ok: boolean; + metadata: Record | null; +}; + +export type DiffPreviewLine = { + marker: string; + content: string; + kind: "added" | "removed" | "context"; +}; diff --git a/src/ui/compoments/MessageView/utils.ts b/src/ui/compoments/MessageView/utils.ts new file mode 100644 index 0000000..50a7b94 --- /dev/null +++ b/src/ui/compoments/MessageView/utils.ts @@ -0,0 +1,255 @@ +import type { DiffPreviewLine, ToolSummary } from "./types"; +import type { SessionMessage } from "../../../session"; +import { RawMode } from "../../contexts"; +import chalk from "chalk"; + +/** Type guard that checks whether a value is a plain object (not null, not an array). */ +export function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +/** Capitalizes the first character of a tool status name, falling back to "Tool". */ +export function formatStatusName(value: string): string { + return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : "Tool"; +} + +/** Truncates a string to the given maximum length, appending an ellipsis when truncated. */ +export function truncate(value: string, max: number): string { + if (value.length <= max) { + return value; + } + return `${value.slice(0, max)}…`; +} + +/** Returns the first non-empty line from a multi-line string, normalizing whitespace. */ +export function firstNonEmptyLine(value: string): string { + for (const line of value.split(/\r?\n/)) { + const trimmed = line.trim().replace(/\s+/g, " "); + if (trimmed) { + return trimmed; + } + } + return ""; +} + +/** + * Builds a one-line summary of thinking / reasoning content. + * Falls back to "(reasoning...)" when only reasoning_content params are present. + */ +export function buildThinkingSummary(content: string, messageParams: unknown | null, mode?: RawMode): string { + if (content) { + const normalized = content.replace(/\r?\n/g, " ").replace(/\s+/g, " "); + let result = truncate(normalized, 100); + if (result.endsWith(":") || result.endsWith(":")) { + result = result.slice(0, -1); + } + return result; + } + + const params = messageParams as { reasoning_content?: unknown } | null | undefined; + if (typeof params?.reasoning_content === "string" && params.reasoning_content.trim()) { + return mode !== RawMode.Lite ? params?.reasoning_content || "" : "(reasoning...)"; + } + + return ""; +} + +/** Formats a tool's parameters for status display, preserving full bash commands but truncating others. */ +export function formatToolStatusParams(summary: ToolSummary): string { + const params = firstNonEmptyLine(summary.params); + return summary.name.toLowerCase() === "bash" ? params : truncate(params, 120); +} + +/** Builds a structured summary (name, params, ok, metadata) from a tool session message. */ +export function buildToolSummary(message: SessionMessage): ToolSummary { + const payload = parseToolPayload(message.content); + const metaFunctionName = + message.meta?.function && typeof (message.meta.function as { name?: unknown }).name === "string" + ? (message.meta.function as { name: string }).name + : null; + const name = payload.name || metaFunctionName || "tool"; + const params = + name === "AskUserQuestion" + ? extractAskUserQuestionParams(message) || getMetaParams(message) + : getMetaParams(message); + + return { + name, + params, + ok: payload.ok !== false, + metadata: payload.metadata, + }; +} + +/** Extracts the paramsMd field from a session message's metadata, trimmed. */ +export function getMetaParams(message: SessionMessage): string { + return typeof message.meta?.paramsMd === "string" ? message.meta.paramsMd.trim() : ""; +} + +/** + * Extracts human-readable question text from an AskUserQuestion tool message. + * Tries the tool function arguments first, then falls back to parsing metadata params. + */ +export function extractAskUserQuestionParams(message: SessionMessage): string { + const fromFunction = extractQuestionsFromToolFunction(message.meta?.function); + if (fromFunction) { + return fromFunction; + } + + const params = getMetaParams(message); + if (!params) { + return ""; + } + + try { + const parsed = JSON.parse(params); + return extractQuestionsFromValue(parsed); + } catch { + return ""; + } +} + +/** + * Extracts question strings from a tool function object by parsing its JSON arguments. + */ +export function extractQuestionsFromToolFunction(toolFunction: unknown): string { + if (!toolFunction || typeof toolFunction !== "object") { + return ""; + } + const args = (toolFunction as { arguments?: unknown }).arguments; + if (typeof args !== "string" || !args.trim()) { + return ""; + } + try { + const parsed = JSON.parse(args); + return extractQuestionsFromValue((parsed as { questions?: unknown })?.questions); + } catch { + return ""; + } +} + +/** Extracts and joins question strings from an array of question objects. */ +export function extractQuestionsFromValue(value: unknown): string { + if (!Array.isArray(value)) { + return ""; + } + return value + .map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return ""; + } + return typeof (item as { question?: unknown }).question === "string" + ? (item as { question: string }).question.trim() + : ""; + }) + .filter(Boolean) + .join(" / "); +} + +/** Parses a tool's JSON payload, extracting name, ok flag, and metadata. */ +export function parseToolPayload(content: string | null): { + name: string | null; + ok: boolean; + metadata: Record | null; +} { + if (!content) { + return { name: null, ok: true, metadata: null }; + } + + try { + const parsed = JSON.parse(content) as { name?: unknown; ok?: unknown; metadata?: unknown }; + return { + name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null, + ok: parsed.ok !== false, + metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null, + }; + } catch { + return { name: null, ok: true, metadata: null }; + } +} + +/** + * Returns structured diff preview lines for successful edit or write tool calls. + * Returns an empty array if the tool is not edit/write or has no diff_preview metadata. + */ +export function getToolDiffPreviewLines(summary: ToolSummary): DiffPreviewLine[] { + if (!summary.ok || !["edit", "write"].includes(summary.name.toLowerCase())) { + return []; + } + const diffPreview = summary.metadata?.diff_preview; + if (typeof diffPreview !== "string" || !diffPreview.trim()) { + return []; + } + return parseDiffPreview(diffPreview); +} + +/** Parses a unified-diff-style preview string into an array of structured diff lines. */ +export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { + return diffPreview + .split("\n") + .filter((line) => line && !line.startsWith("--- ") && !line.startsWith("+++ ") && !line.startsWith("@@ ")) + .map((line) => { + if (line.startsWith("+")) { + return { marker: "+", content: line.slice(1), kind: "added" }; + } + if (line.startsWith("-")) { + return { marker: "-", content: line.slice(1), kind: "removed" }; + } + return { + marker: " ", + content: line.startsWith(" ") ? line.slice(1) : line, + kind: "context", + }; + }); +} + +export function renderMessageToStdout(message: SessionMessage, mode: RawMode): string { + if (!message.visible) { + return ""; + } + + if (message.role === "user") { + const text = message.content || "(no content)"; + return chalk(`> ${text}`); + } + + if (message.role === "assistant") { + const isThinking = Boolean(message.meta?.asThinking); + const content = (message.content || "").trim(); + + if (isThinking) { + const summary = buildThinkingSummary(content, message.messageParams, mode); + return `${chalk("✧")} ${chalk("Thinking")}${summary ? ` ${chalk(summary)}` : ""}`; + } + + return `${chalk("✦")} ${content}`; + } + + if (message.role === "tool") { + const payload = parseToolPayload(message.content); + const metaFunctionName = + message.meta?.function && typeof (message.meta.function as { name?: unknown }).name === "string" + ? (message.meta.function as { name: string }).name + : null; + const name = payload.name || metaFunctionName || "tool"; + const metaParams = typeof message.meta?.paramsMd === "string" ? message.meta.paramsMd.trim() : ""; + const params = name.toLowerCase() === "bash" ? metaParams : truncate(metaParams, 120); + return `${chalk("✧")} ${chalk(formatStatusName(name))}${params ? ` ${chalk(params)}` : ""}`; + } + + if (message.role === "system") { + if (message.meta?.isModelChange) { + return chalk(`> ${message.content}`); + } + if (message.meta?.skill && typeof message.meta.skill === "object") { + const skillName = (message.meta.skill as { name?: unknown }).name; + return chalk(`⚡ Loaded skill: ${typeof skillName === "string" ? skillName : ""}`); + } + if (message.meta?.isSummary) { + return chalk.dim.italic("(conversation summary inserted)"); + } + return ""; + } + + return ""; +} diff --git a/src/ui/compoments/RawModeExitPrompt/index.tsx b/src/ui/compoments/RawModeExitPrompt/index.tsx new file mode 100644 index 0000000..9b1d218 --- /dev/null +++ b/src/ui/compoments/RawModeExitPrompt/index.tsx @@ -0,0 +1,15 @@ +import type React from "react"; +import { useInput } from "ink"; + +export function RawModeExitPrompt({ onExit }: { onExit: () => void }): React.ReactElement | null { + useInput( + (_input, key) => { + if (key.escape) { + onExit(); + } + }, + { isActive: true } + ); + + return null; +} diff --git a/src/ui/compoments/RawModelDropdown/index.tsx b/src/ui/compoments/RawModelDropdown/index.tsx new file mode 100644 index 0000000..3397013 --- /dev/null +++ b/src/ui/compoments/RawModelDropdown/index.tsx @@ -0,0 +1,55 @@ +import React, { useState } from "react"; +import { useInput } from "ink"; +import DropdownMenu from "../../DropdownMenu"; +import type { RawMode } from "../../contexts"; +import { RAW_COMMAND_MODELS, useRawModeContext } from "../../contexts"; + +const RawModelDropdown: React.FC<{ + open: boolean; + screenWidth: number; + onClose?: (value: boolean) => void; + onSelect?: (model: string) => void; +}> = ({ open = false, screenWidth, onSelect, onClose }) => { + const { mode, setMode } = useRawModeContext(); + const [index, setIndex] = useState(0); + useInput( + (input, key) => { + if (key.upArrow) { + setIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setIndex((i) => Math.min(RAW_COMMAND_MODELS.length - 1, i + 1)); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + setMode(RAW_COMMAND_MODELS[index].key as RawMode); + onClose?.(false); + onSelect?.(RAW_COMMAND_MODELS[index].key); + return; + } + if (key.escape) { + onClose?.(false); + return; + } + }, + { isActive: open } + ); + if (!open) { + return null; + } + return ( + ({ ...model, selected: model.key === mode }))} + helpText="Space/Enter select mode · Esc to close" + // onSelect={onSelect} + activeColor="#229ac3" + maxVisible={6} + activeIndex={index} + width={screenWidth} + /> + ); +}; + +export default RawModelDropdown; diff --git a/src/ui/compoments/index.ts b/src/ui/compoments/index.ts new file mode 100644 index 0000000..942d3ed --- /dev/null +++ b/src/ui/compoments/index.ts @@ -0,0 +1,3 @@ +export { default as RawModelDropdown } from "./RawModelDropdown"; +export { MessageView } from "./MessageView"; +export { RawModeExitPrompt } from "./RawModeExitPrompt"; diff --git a/src/ui/contexts/AppContext.tsx b/src/ui/contexts/AppContext.tsx new file mode 100644 index 0000000..34d4589 --- /dev/null +++ b/src/ui/contexts/AppContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; + +export interface AppState { + version: string; +} + +export const AppContext = createContext(null); + +export const useAppContext = () => { + const context = useContext(AppContext); + if (!context) { + throw new Error("useAppContext must be used within an AppProvider"); + } + return context; +}; diff --git a/src/ui/contexts/RawModeContext.tsx b/src/ui/contexts/RawModeContext.tsx new file mode 100644 index 0000000..a7b6090 --- /dev/null +++ b/src/ui/contexts/RawModeContext.tsx @@ -0,0 +1,40 @@ +import React, { createContext, useContext, useState } from "react"; +import type { DropdownMenuItem } from "../DropdownMenu"; + +export enum RawMode { + None = "Normal mode", + Lite = "Lite mode", + Raw = "Raw scrollback mode", +} +export const RAW_COMMAND_MODELS: DropdownMenuItem[] = [ + { + label: "Lite mode", + key: RawMode.Lite, + }, + { + label: "Raw scrollback mode", + key: RawMode.Raw, + }, + { + label: "Normal mode", + key: RawMode.None, + }, +] as const; + +const RawModeContext = createContext<{ mode: RawMode; setMode: React.Dispatch> }>({ + mode: RawMode.Lite, + setMode: () => {}, +}); + +export function useRawModeContext() { + const context = useContext(RawModeContext); + if (!context) { + throw new Error("useRawModeContext must be used within a RawModeProvider"); + } + return context; +} + +export const RawModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [mode, setMode] = useState(RawMode.Lite); + return {children}; +}; diff --git a/src/ui/contexts/index.ts b/src/ui/contexts/index.ts new file mode 100644 index 0000000..37e40cd --- /dev/null +++ b/src/ui/contexts/index.ts @@ -0,0 +1,3 @@ +export { AppContext, useAppContext } from "./AppContext"; +export type { AppState } from "./AppContext"; +export { RawMode, RAW_COMMAND_MODELS, useRawModeContext, RawModeProvider } from "./RawModeContext"; diff --git a/src/ui/index.ts b/src/ui/index.ts index 5b4ff8f..dd99330 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -9,7 +9,8 @@ export { createOpenAIClient, } from "./App"; export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; -export { MessageView, parseDiffPreview } from "./MessageView"; +export { MessageView } from "./compoments"; +export { parseDiffPreview } from "./compoments/MessageView/utils"; export { PromptInput, IMAGE_ATTACHMENT_CLEAR_HINT, @@ -47,7 +48,7 @@ export { } from "./askUserQuestion"; export { readClipboardImage, type ClipboardImage } from "./clipboard"; export { buildLoadingText, type LoadingTextInput } from "./loadingText"; -export { renderMarkdown } from "./markdown"; +export { renderMarkdown } from "./compoments/MessageView/markdown"; export { EMPTY_BUFFER, insertText, diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index 6552ba0..aab06bd 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -1,6 +1,16 @@ import type { SkillInfo } from "../session"; -export type SlashCommandKind = "skill" | "skills" | "model" | "new" | "init" | "resume" | "continue" | "mcp" | "exit"; +export type SlashCommandKind = + | "skill" + | "skills" + | "model" + | "new" + | "init" + | "resume" + | "continue" + | "mcp" + | "raw" + | "exit"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -8,6 +18,7 @@ export type SlashCommandItem = { label: string; description: string; skill?: SkillInfo; + args?: string[]; }; export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ @@ -53,6 +64,13 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/mcp", description: "Show MCP server status and available tools", }, + { + kind: "raw", + name: "raw", + label: "/raw", + args: ["lite", "normal", "raw-scrollback"], + description: "Toggle display mode for viewing or collapsing reasoning content", + }, { kind: "exit", name: "exit", @@ -88,7 +106,7 @@ export function findExactSlashCommand(items: SlashCommandItem[], token: string): return null; } const query = token.slice(1); - const matches = items.filter((item) => item.name === query); + const matches = items.filter((item) => item.name.includes(query)); return matches.find((item) => item.kind !== "skill") ?? matches[0] ?? null; } From 67e2066b73f7e2d1b172e1e053d44009bcb7be0a Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 18 May 2026 20:20:26 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(MessageView):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=8A=B6=E6=80=81=E8=A1=8C=E7=9A=84=20Plan?= =?UTF-8?q?=20Message=20=E9=A2=84=E8=A7=88=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取状态行文本为 statusLine 变量 - 创建 ToolSummary 对象汇总工具信息 - 获取并渲染更新计划的预览行 - 当有计划内容时,追加显示计划标题和内容 - 保持无计划时返回单行状态信息 --- src/ui/compoments/MessageView/utils.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/ui/compoments/MessageView/utils.ts b/src/ui/compoments/MessageView/utils.ts index a6ca4f6..45eb79c 100644 --- a/src/ui/compoments/MessageView/utils.ts +++ b/src/ui/compoments/MessageView/utils.ts @@ -234,7 +234,21 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s const name = payload.name || metaFunctionName || "tool"; const metaParams = typeof message.meta?.paramsMd === "string" ? message.meta.paramsMd.trim() : ""; const params = name.toLowerCase() === "bash" ? metaParams : truncate(metaParams, 120); - return `${chalk("✧")} ${chalk(formatStatusName(name))}${params ? ` ${chalk(params)}` : ""}`; + const statusLine = `${chalk("✧")} ${chalk(formatStatusName(name))}${params ? ` ${chalk(params)}` : ""}`; + + const summary: ToolSummary = { + name, + params, + ok: payload.ok !== false, + metadata: payload.metadata, + }; + const planLines = getUpdatePlanPreviewLines(summary); + if (planLines.length > 0) { + const planText = planLines.map((line) => ` ${line}`).join("\n"); + return `${statusLine}\n${chalk.dim(" └ Plan")}\n${planText}`; + } + + return statusLine; } if (message.role === "system") { From a42d5de1c1c6fbf935e53d0f470cf17c15361d63 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 08:52:57 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8DMessageView?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E4=B8=ADStatusLine=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改StatusLine组件的params传值逻辑 - 当content存在时,params传入空字符串,避免显示错误 - 保持了内容渲染的兼容性和逻辑清晰性 --- src/ui/compoments/MessageView/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/compoments/MessageView/index.tsx b/src/ui/compoments/MessageView/index.tsx index cc0e4df..dd0ddc5 100644 --- a/src/ui/compoments/MessageView/index.tsx +++ b/src/ui/compoments/MessageView/index.tsx @@ -50,7 +50,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps } return ( - + {content ? {renderMarkdown(content)} : null} From 05fed53801c402e78e64464847745ac7e959b119 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 09:04:14 +0800 Subject: [PATCH 04/11] =?UTF-8?q?test(messageView):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=92=8C=E8=A7=A3=E6=9E=90=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E7=9A=84=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 renderMessageToStdout 函数的多场景测试,包括用户、助手、工具和系统消息的渲染行为 - 添加 getUpdatePlanPreviewLines 对 UpdatePlan 工具消息的计划内容提取测试 - 增加 parseToolPayload 函数对空内容、无效 JSON 和有效负载的解析测试 - 引入辅助函数 makeSessionMessage 以简化测试消息实例构造 - 确保各种边界条件和meta字段的渲染正确性验证 --- src/tests/messageView.test.ts | 178 +++++++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 1 deletion(-) diff --git a/src/tests/messageView.test.ts b/src/tests/messageView.test.ts index 0981f91..0cd95da 100644 --- a/src/tests/messageView.test.ts +++ b/src/tests/messageView.test.ts @@ -1,8 +1,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { parseDiffPreview } from "../ui"; -import { buildThinkingSummary } from "../ui/compoments/MessageView/utils"; +import { + buildThinkingSummary, + renderMessageToStdout, + getUpdatePlanPreviewLines, + parseToolPayload, +} from "../ui/compoments/MessageView/utils"; import { RawMode } from "../ui/contexts"; +import type { SessionMessage } from "../session"; +import type { ToolSummary } from "../ui/compoments/MessageView/types"; test("parseDiffPreview removes headers and classifies lines", () => { const lines = parseDiffPreview( @@ -52,3 +59,172 @@ test("MessageView shows full reasoning content in Normal/Raw mode", () => { "hidden chain of thought" ); }); + +// --- renderMessageToStdout tests --- + +function makeSessionMessage(overrides: Partial & Pick): SessionMessage { + const now = new Date().toISOString(); + return { + id: `test-${Math.random().toString(36).slice(2)}`, + sessionId: "test-session", + visible: true, + compacted: false, + createTime: now, + updateTime: now, + contentParams: null, + messageParams: null, + ...overrides, + }; +} + +test("renderMessageToStdout returns empty for invisible messages", () => { + const msg = makeSessionMessage({ role: "user", content: "hello", visible: false }); + assert.equal(renderMessageToStdout(msg, RawMode.Raw), ""); +}); + +test("renderMessageToStdout renders user messages with > prefix", () => { + const msg = makeSessionMessage({ role: "user", content: "fix the bug" }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("> fix the bug")); +}); + +test("renderMessageToStdout shows (no content) for empty user messages", () => { + const msg = makeSessionMessage({ role: "user", content: "" }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("(no content)")); +}); + +test("renderMessageToStdout renders assistant non-thinking messages with ✦", () => { + const msg = makeSessionMessage({ role: "assistant", content: "Here is the fix" }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("✦")); + assert.ok(output.includes("Here is the fix")); +}); + +test("renderMessageToStdout renders assistant thinking messages with ✧ Thinking", () => { + const msg = makeSessionMessage({ + role: "assistant", + content: "Plan:\nAnalyze the code", + meta: { asThinking: true }, + }); + const output = renderMessageToStdout(msg, RawMode.Lite); + assert.ok(output.includes("✧")); + assert.ok(output.includes("Thinking")); + assert.ok(output.includes("Plan: Analyze the code")); +}); + +test("renderMessageToStdout renders tool messages with ✧ and tool name", () => { + const payload = JSON.stringify({ name: "read", ok: true }); + const msg = makeSessionMessage({ role: "tool", content: payload }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("✧")); + assert.ok(output.includes("Read")); +}); + +test("renderMessageToStdout renders UpdatePlan tool messages with Plan preview", () => { + const payload = JSON.stringify({ + name: "UpdatePlan", + ok: true, + metadata: { plan: "Step 1: Analyze\nStep 2: Implement\nStep 3: Test" }, + }); + const msg = makeSessionMessage({ role: "tool", content: payload }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("UpdatePlan")); + assert.ok(output.includes("└ Plan")); + assert.ok(output.includes("Step 1: Analyze")); + assert.ok(output.includes("Step 2: Implement")); +}); + +test("renderMessageToStdout renders system model change messages", () => { + const msg = makeSessionMessage({ + role: "system", + content: "Switched to deepseek-v4-pro", + meta: { isModelChange: true }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("> Switched to deepseek-v4-pro")); +}); + +test("renderMessageToStdout renders system skill load messages", () => { + const msg = makeSessionMessage({ + role: "system", + content: "", + meta: { skill: { name: "code-review" } }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("⚡ Loaded skill: code-review")); +}); + +test("renderMessageToStdout renders system summary messages", () => { + const msg = makeSessionMessage({ + role: "system", + content: "", + meta: { isSummary: true }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("(conversation summary inserted)")); +}); + +test("renderMessageToStdout returns empty for unknown system messages", () => { + const msg = makeSessionMessage({ role: "system", content: "" }); + assert.equal(renderMessageToStdout(msg, RawMode.Raw), ""); +}); + +// --- getUpdatePlanPreviewLines tests --- + +test("getUpdatePlanPreviewLines returns empty for failed tool", () => { + const summary: ToolSummary = { name: "UpdatePlan", params: "", ok: false, metadata: { plan: "Step 1" } }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines returns empty for non-UpdatePlan tool", () => { + const summary: ToolSummary = { name: "edit", params: "", ok: true, metadata: { plan: "Step 1" } }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines returns empty for missing plan metadata", () => { + const summary: ToolSummary = { name: "UpdatePlan", params: "", ok: true, metadata: null }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines returns empty for empty plan string", () => { + const summary: ToolSummary = { name: "UpdatePlan", params: "", ok: true, metadata: { plan: "" } }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines extracts plan lines and filters empty rows", () => { + const summary: ToolSummary = { + name: "UpdatePlan", + params: "", + ok: true, + metadata: { plan: "Step 1: Analyze\n\nStep 2: Implement\n \nStep 3: Test" }, + }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), ["Step 1: Analyze", "Step 2: Implement", "Step 3: Test"]); +}); + +// --- parseToolPayload tests --- + +test("parseToolPayload returns defaults for null content", () => { + const result = parseToolPayload(null); + assert.deepEqual(result, { name: null, ok: true, metadata: null }); +}); + +test("parseToolPayload returns defaults for invalid JSON", () => { + const result = parseToolPayload("not valid json"); + assert.deepEqual(result, { name: null, ok: true, metadata: null }); +}); + +test("parseToolPayload parses valid JSON with name/ok/metadata", () => { + const result = parseToolPayload(JSON.stringify({ name: "read", ok: true, metadata: { file: "src/index.ts" } })); + assert.deepEqual(result, { name: "read", ok: true, metadata: { file: "src/index.ts" } }); +}); + +test("parseToolPayload respects ok: false", () => { + const result = parseToolPayload(JSON.stringify({ name: "bash", ok: false, metadata: null })); + assert.deepEqual(result, { name: "bash", ok: false, metadata: null }); +}); + +test("parseToolPayload trims whitespace from name", () => { + const result = parseToolPayload(JSON.stringify({ name: " read ", ok: true })); + assert.equal(result.name, "read"); +}); From 418294dfd315402e7942a42960239185fa44ef0a Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 10:28:48 +0800 Subject: [PATCH 05/11] =?UTF-8?q?refactor(rawmode):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20RawMode=20=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E4=BA=A4=E4=BA=92=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 RawModeContext 中增加 previousMode 状态用于保存上一个模式 - 修改 setMode 逻辑以更新 previousMode,支持通过函数设置模式 - RawModeExitPrompt 捕获并使用快照 previousMode 作为退出时目标模式 - 调整 App.tsx 处理 RawMode 切换逻辑,避免界面闪烁并重置欢迎屏幕状态 - 处理无激活会话时显示欢迎屏幕,确保状态正确更新 - 优化 Raw 模式消息加载逻辑,避免活跃会话缺失时的错误 - 更新测试用例中消息构建函数支持更多可选属性与默认值设置 - 修改 renderMessageToStdout 测试示例以配合新的消息结构及元信息 --- src/tests/messageView.test.ts | 23 +++++++------ src/ui/App.tsx | 24 +++++++++----- src/ui/compoments/RawModeExitPrompt/index.tsx | 11 +++++-- src/ui/contexts/RawModeContext.tsx | 32 ++++++++++++++++--- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/tests/messageView.test.ts b/src/tests/messageView.test.ts index 0cd95da..b97e125 100644 --- a/src/tests/messageView.test.ts +++ b/src/tests/messageView.test.ts @@ -65,15 +65,18 @@ test("MessageView shows full reasoning content in Normal/Raw mode", () => { function makeSessionMessage(overrides: Partial & Pick): SessionMessage { const now = new Date().toISOString(); return { - id: `test-${Math.random().toString(36).slice(2)}`, - sessionId: "test-session", - visible: true, - compacted: false, - createTime: now, - updateTime: now, - contentParams: null, - messageParams: null, - ...overrides, + id: overrides.id ?? `test-${Math.random().toString(36).slice(2)}`, + sessionId: overrides.sessionId ?? "test-session", + role: overrides.role, + content: overrides.content ?? null, + visible: overrides.visible ?? true, + compacted: overrides.compacted ?? false, + createTime: overrides.createTime ?? now, + updateTime: overrides.updateTime ?? now, + contentParams: overrides.contentParams ?? null, + messageParams: overrides.messageParams ?? null, + meta: overrides.meta, + html: overrides.html, }; } @@ -149,7 +152,7 @@ test("renderMessageToStdout renders system skill load messages", () => { const msg = makeSessionMessage({ role: "system", content: "", - meta: { skill: { name: "code-review" } }, + meta: { skill: { name: "code-review", path: "", description: "" } }, }); const output = renderMessageToStdout(msg, RawMode.Raw); assert.ok(output.includes("⚡ Loaded skill: code-review")); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3d29e32..9189df6 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -374,19 +374,18 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. const handleRawModeChange = useCallback( (nextMode: string) => { const activeSessionId = sessionManager.getActiveSessionId(); - if (!activeSessionId) { - return; - } - setMode(nextMode as RawMode); - + // Reset chat view state synchronously so the transition frame does not + // re-render a stale welcome screen before handleSelectSession runs. + setShowWelcome(false); + setMessages([]); // Clear screen to remove stale formatted text. process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); setTimeout(() => { if (nextMode === RawMode.Raw) { // Write all messages directly to stdout for raw scrollback mode. - const allMessages = loadVisibleMessages(sessionManager, activeSessionId); + const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; for (const msg of allMessages) { process.stdout.write("\n"); process.stdout.write(renderMessageToStdout(msg, nextMode) + "\n\n"); @@ -394,10 +393,19 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. 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")); } - } else { + } else if (activeSessionId) { // Switch to chat view to render messages. handleSelectSession(activeSessionId); + } else { + // No active session: just show the welcome screen once. + setWelcomeNonce((n) => n + 1); + setShowWelcome(true); } }, 200); }, @@ -499,7 +507,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. }, [pendingQuestion]); if (mode === RawMode.Raw) { - return handleRawModeChange(RawMode.None)} />; + return handleRawModeChange(prev)} />; } return ( diff --git a/src/ui/compoments/RawModeExitPrompt/index.tsx b/src/ui/compoments/RawModeExitPrompt/index.tsx index 9b1d218..57ebf07 100644 --- a/src/ui/compoments/RawModeExitPrompt/index.tsx +++ b/src/ui/compoments/RawModeExitPrompt/index.tsx @@ -1,11 +1,16 @@ -import type React from "react"; +import { useRef, type ReactElement } from "react"; import { useInput } from "ink"; +import { useRawModeContext, type RawMode } from "../../contexts"; + +export function RawModeExitPrompt({ onExit }: { onExit: (previousMode: RawMode) => void }): ReactElement | null { + const { previousMode } = useRawModeContext(); + // Snapshot the prior mode at mount so later context updates do not change the ESC target. + const snapshotRef = useRef(previousMode); -export function RawModeExitPrompt({ onExit }: { onExit: () => void }): React.ReactElement | null { useInput( (_input, key) => { if (key.escape) { - onExit(); + onExit(snapshotRef.current); } }, { isActive: true } diff --git a/src/ui/contexts/RawModeContext.tsx b/src/ui/contexts/RawModeContext.tsx index d0c2336..6fbc706 100644 --- a/src/ui/contexts/RawModeContext.tsx +++ b/src/ui/contexts/RawModeContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState } from "react"; +import React, { createContext, useCallback, useContext, useRef, useState } from "react"; import type { DropdownMenuItem } from "../DropdownMenu"; export enum RawMode { @@ -21,9 +21,17 @@ export const RAW_COMMAND_MODELS: DropdownMenuItem[] = [ }, ] as const; -const RawModeContext = createContext<{ mode: RawMode; setMode: React.Dispatch> }>({ +type RawModeContextValue = { + mode: RawMode; + setMode: React.Dispatch>; + // The mode that was active right before the most recent mode transition. + previousMode: RawMode; +}; + +const RawModeContext = createContext({ mode: RawMode.Lite, setMode: () => {}, + previousMode: RawMode.Lite, }); export function useRawModeContext() { @@ -35,6 +43,22 @@ export function useRawModeContext() { } export const RawModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [mode, setMode] = useState(RawMode.Lite); - return {children}; + const [mode, _setMode] = useState(RawMode.Lite); + const previousModeRef = useRef(RawMode.Lite); + + const setMode = useCallback>>((next) => { + _setMode((current) => { + const resolved = typeof next === "function" ? (next as (prev: RawMode) => RawMode)(current) : next; + if (resolved !== current) { + previousModeRef.current = current; + } + return resolved; + }); + }, []); + + return ( + + {children} + + ); }; From 5bfce46d6d17ea88e7f02b56b5f7282b37f9ebd9 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 11:11:40 +0800 Subject: [PATCH 06/11] =?UTF-8?q?docs(readme):=20=E5=A2=9E=E5=BC=BA=20READ?= =?UTF-8?q?ME.md=20=E6=96=87=E4=BB=B6=E5=86=85=E5=AE=B9=E5=92=8C=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 README.md 和 README_en.md 文件中新增居中标题和统一头部样式 - README.md 中添加“[English](./README_en.md) · 中文”语言切换链接 - README_en.md 中添加“English · [中文](./README.md)”语言切换链接 - README.md 新增 `/raw` 命令介绍,补充命令表内容 - 删除冗余的 README_cn.md 文件,简化文档管理 --- README.md | 16 +++++- README_cn.md | 154 --------------------------------------------------- README_en.md | 17 +++++- 3 files changed, 31 insertions(+), 156 deletions(-) delete mode 100644 README_cn.md diff --git a/README.md b/README.md index 69d28c8..00167c1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,17 @@ -# Deep Code CLI +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

+ +[English](./README_en.md) · 中文 + +
+
[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 @@ -53,6 +66,7 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: | `/new` | 开始新对话 | | `/resume` | 选择历史对话继续 | | `/model` | 切换模型、思考模式和推理强度 | +| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯)| | `/init` | 初始化 AGENTS.md 文件 | | `/skills` | 列出可用 skills | | `/mcp` | 查看 MCP 服务器状态和可用工具 | diff --git a/README_cn.md b/README_cn.md deleted file mode 100644 index 69d28c8..0000000 --- a/README_cn.md +++ /dev/null @@ -1,154 +0,0 @@ -# Deep Code CLI - -[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 - -## 安装 - -```bash -npm install -g @vegamo/deepcode-cli -``` - -在任意项目目录下运行 `deepcode` 即可启动。 - -![intro2](resources/intro2.png) - -## 配置 - -创建 `~/.deepcode/settings.json` 文件,内容如下: - -```json -{ - "env": { - "MODEL": "deepseek-v4-pro", - "BASE_URL": "https://api.deepseek.com", - "API_KEY": "sk-..." - }, - "thinkingEnabled": true, - "reasoningEffort": "max" -} -``` - -配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 - -完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 - -## 主要功能 - -### **Skills** -Deep Code CLI 支持 agent skills,允许您扩展助手的能力: - -- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 -- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 - -### **为 DeepSeek 优化** -- 专门为 DeepSeek 模型性能调优。 -- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 -- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 - -## 斜杠命令与按键功能 - -| 斜杠命令 | 操作 | -|-----------------|---------------------------------------------| -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/model` | 切换模型、思考模式和推理强度 | -| `/init` | 初始化 AGENTS.md 文件 | -| `/skills` | 列出可用 skills | -| `/mcp` | 查看 MCP 服务器状态和可用工具 | -| `/exit` | 退出(也可用连续 `Ctrl+D`) | - -| 按键 | 操作 | -|-----------------|---------------------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| 连续 `Ctrl+D` | 退出 | - -## 支持的模型 - -- `deepseek-v4-pro`(推荐使用) -- `deepseek-v4-flash` -- 任何其他 OpenAI 兼容模型 - - -## 常见问题 - -### Deep Code 是否有 VSCode 插件? - -有的。Deep Code 提供功能完整的 VSCode 插件,可在 [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 安装。插件与 CLI 共享 `~/.deepcode/settings.json` 配置文件,可以在终端和编辑器之间无缝切换。 - -### Deep Code 是否支持理解图片? - -Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 deepseek-v4 不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的 Doubao-Seed-2.0-pro 模型,适配效果最好。 - -### 怎样在任务完成后自动给 Slack 发消息? - -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g - -### 怎样启用联网搜索功能? - -Deep Code自带免费的、且大部分情况够用的Web Search工具。如果你希望使用自定义脚本进行联网搜索,可以在 `~/.deepcode/settings.json` 中将 `webSearchTool` 设为脚本的完整路径即可。详细步骤可参考:https://github.com/qorzj/web_search_cli - -### 如何配置 MCP? - -Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 - -详细配置指南:[docs/mcp.md](docs/mcp.md) - - -### 是否支持 Coding Plan? - -支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: - -```json -{ - "env": { - "MODEL": "ark-code-latest", - "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", - "API_KEY": "**************" - }, - "thinkingEnabled": true -} -``` -## 贡献 - -欢迎贡献代码!以下是参与方式: - -```bash -# 克隆仓库 -git clone https://github.com/lessweb/deepcode-cli.git -cd deepcode-cli - -# 安装依赖 -npm install - -# 本地开发(类型检查 + lint + 格式检查 + 构建) -npm run build - -# 运行测试 -npm test - -# 链接到全局(即本地全局安装) -npm link -``` - -- 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) -- 建议在执行构建前,先执行 `npm run format` 自动格式化代码,避免构建报错 - -## 获取帮助 - -- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) - -## 协议 - -- MIT - -## 支持我们 - -如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: - -- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) -- 向我们提交反馈和建议 -- 分享给你的朋友和同事 diff --git a/README_en.md b/README_en.md index ee5a103..4c78cbd 100644 --- a/README_en.md +++ b/README_en.md @@ -1,7 +1,21 @@ -# Deep Code CLI +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

+ +English · [中文](./README.md) + +
+
[Deep Code](https://github.com/lessweb/deepcode-cli) is a terminal AI coding assistant optimized for the `deepseek-v4` model, with support for deep thinking, reasoning effort control, Agent Skills, and MCP (Model Context Protocol) integration. + ## Installation ```bash @@ -53,6 +67,7 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap | `/new` | Start a fresh conversation | | `/resume` | Choose a previous conversation to continue | | `/model` | Switch model, thinking mode, and reasoning effort | +| `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) | | `/init` | Initialize an AGENTS.md file (LLM project instructions) | | `/skills` | List available skills | | `/mcp` | View MCP server status and available tools | From 7f3d1b87dfb2a79b95d94289ef558d3b04c40e40 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 13:17:54 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix(ui):=20=E4=BC=98=E5=8C=96=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E9=92=A9=E5=AD=90=E4=B8=8E=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=A1=8C=E5=AF=BC=E5=85=A5=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 useAppContext 钩子,安全处理无上下文情况,返回默认版本信息 - 更新 cli.tsx 中 AppContainer 的导入方式,改为从统一入口导入 - 在 ui/index.ts 中导出 AppContainer 组件 - 新增 UI 共享常量 ARGS_SEPARATOR,提升分隔符一致性 feat(ui): 优化命令行提示参数分隔符显示 - 在 PromptInput 和 SlashCommandMenu 组件中使用 ARGS_SEPARATOR 替代硬编码分隔符 - 调整 SlashCommandMenu 中命令行长度计算逻辑,兼容新分隔符 fix(ui): 修正 slashCommands 过滤匹配逻辑 - 将命令匹配条件从包含改为完全相等,提高准确性 feat(ui): 扩展消息视图工具信息展示 - 增加对 tool 消息中 meta.resultMd 字段的渲染支持 - 在工具状态行后增加 Result 结果块,配合 Plan 预览一同展示 - 更新 renderMessageToStdout 相关测试,覆盖新展示逻辑 --- src/cli.tsx | 2 +- src/tests/messageView.test.ts | 36 ++++++++++++++++++++++++++ src/ui/PromptInput.tsx | 3 ++- src/ui/SlashCommandMenu.tsx | 7 +++-- src/ui/compoments/MessageView/utils.ts | 7 +++-- src/ui/constants.ts | 4 +++ src/ui/contexts/AppContext.tsx | 5 ++-- src/ui/index.ts | 2 +- src/ui/slashCommands.ts | 2 +- 9 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 src/ui/constants.ts diff --git a/src/cli.tsx b/src/cli.tsx index e8e8659..66ceb7d 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -2,7 +2,7 @@ import React from "react"; import { render } from "ink"; import { setShellIfWindows } from "./common/shell-utils"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck"; -import AppContainer from "./ui/AppContainer"; +import { AppContainer } from "./ui"; const args = process.argv.slice(2); const packageInfo = readPackageInfo(); diff --git a/src/tests/messageView.test.ts b/src/tests/messageView.test.ts index b97e125..990c8ff 100644 --- a/src/tests/messageView.test.ts +++ b/src/tests/messageView.test.ts @@ -124,6 +124,40 @@ test("renderMessageToStdout renders tool messages with ✧ and tool name", () => assert.ok(output.includes("Read")); }); +test("renderMessageToStdout renders tool messages with resultMd output", () => { + const payload = JSON.stringify({ name: "read", ok: true }); + const msg = makeSessionMessage({ + role: "tool", + content: payload, + meta: { resultMd: "File content:\n line 1\n line 2" }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("✧")); + assert.ok(output.includes("Read")); + assert.ok(output.includes("└ Result")); + assert.ok(output.includes("File content:")); + assert.ok(output.includes("line 1")); +}); + +test("renderMessageToStdout renders UpdatePlan tool messages with Plan preview and resultMd", () => { + const payload = JSON.stringify({ + name: "UpdatePlan", + ok: true, + metadata: { plan: "Step 1: Analyze\nStep 2: Implement\nStep 3: Test" }, + }); + const msg = makeSessionMessage({ + role: "tool", + content: payload, + meta: { resultMd: "Plan updated successfully" }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("UpdatePlan")); + assert.ok(output.includes("└ Plan")); + assert.ok(output.includes("Step 1: Analyze")); + assert.ok(output.includes(" Result")); + assert.ok(output.includes("Plan updated successfully")); +}); + test("renderMessageToStdout renders UpdatePlan tool messages with Plan preview", () => { const payload = JSON.stringify({ name: "UpdatePlan", @@ -136,6 +170,8 @@ test("renderMessageToStdout renders UpdatePlan tool messages with Plan preview", assert.ok(output.includes("└ Plan")); assert.ok(output.includes("Step 1: Analyze")); assert.ok(output.includes("Step 2: Implement")); + // Verify resultMd is NOT included when meta.resultMd is absent + assert.ok(!output.includes("└ Result")); }); test("renderMessageToStdout renders system model change messages", () => { diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index d620d24..1096a93 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; +import { ARGS_SEPARATOR } from "./constants"; import { EMPTY_BUFFER, backspace, @@ -887,7 +888,7 @@ export const PromptInput = React.memo(function PromptInput({ ); const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null; - const inlineHint = matchedCommand?.args ? ` ${matchedCommand.args.join("|")}` : ""; + const inlineHint = matchedCommand?.args ? ` ${matchedCommand.args.join(ARGS_SEPARATOR)}` : ""; return ( diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx index 1c050b9..02ff308 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -1,5 +1,6 @@ import { formatSlashCommandDescription, formatSlashCommandLabel } from "./slashCommands"; import type { SlashCommandItem } from "./slashCommands"; +import { ARGS_SEPARATOR } from "./constants"; import React from "react"; import { Box, Text } from "ink"; @@ -21,7 +22,9 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ if (items.length === 0) { return 0; } - const longestLabel = Math.max(...items.map((s) => s.label.length + (s.args ? s.args?.join("|")?.length + 4 : 0))); + const longestLabel = Math.max( + ...items.map((s) => s.label.length + (s.args ? s.args?.join(ARGS_SEPARATOR)?.length + 4 : 0)) + ); const contentWidth = longestLabel + 2; // +2 for prefix "> " or " " const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 return Math.min(contentWidth, maxAllowed); @@ -54,7 +57,7 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ {actualIndex === activeIndex ? "> " : " "} {formatSlashCommandLabel(item)}
- {item.args ? {item.args.join("|")} : null} + {item.args ? {item.args.join(ARGS_SEPARATOR)} : null}
diff --git a/src/ui/compoments/MessageView/utils.ts b/src/ui/compoments/MessageView/utils.ts index 45eb79c..af5391d 100644 --- a/src/ui/compoments/MessageView/utils.ts +++ b/src/ui/compoments/MessageView/utils.ts @@ -236,6 +236,9 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s const params = name.toLowerCase() === "bash" ? metaParams : truncate(metaParams, 120); const statusLine = `${chalk("✧")} ${chalk(formatStatusName(name))}${params ? ` ${chalk(params)}` : ""}`; + const metaResultMd = typeof message.meta?.resultMd === "string" ? message.meta.resultMd.trim() : ""; + const result = metaResultMd ? `\n${chalk.dim(" └ Result")}\n${metaResultMd}` : ""; + const summary: ToolSummary = { name, params, @@ -245,10 +248,10 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s const planLines = getUpdatePlanPreviewLines(summary); if (planLines.length > 0) { const planText = planLines.map((line) => ` ${line}`).join("\n"); - return `${statusLine}\n${chalk.dim(" └ Plan")}\n${planText}`; + return `${statusLine}\n${chalk.dim(" └ Plan")}\n${planText}${result}`; } - return statusLine; + return `${statusLine}${result}`; } if (message.role === "system") { diff --git a/src/ui/constants.ts b/src/ui/constants.ts new file mode 100644 index 0000000..7c74597 --- /dev/null +++ b/src/ui/constants.ts @@ -0,0 +1,4 @@ +// UI-level shared constants used across components. + +/** Separator used when rendering command arguments inline (e.g., `arg1 | arg2 | arg3`). */ +export const ARGS_SEPARATOR = " | "; diff --git a/src/ui/contexts/AppContext.tsx b/src/ui/contexts/AppContext.tsx index 34d4589..41b1d1d 100644 --- a/src/ui/contexts/AppContext.tsx +++ b/src/ui/contexts/AppContext.tsx @@ -6,10 +6,11 @@ export interface AppState { export const AppContext = createContext(null); -export const useAppContext = () => { +export const useAppContext = (): AppState => { const context = useContext(AppContext); if (!context) { - throw new Error("useAppContext must be used within an AppProvider"); + // Safe fallback when App is rendered without AppContainer (e.g., in tests). + return { version: "unknown" }; } return context; }; diff --git a/src/ui/index.ts b/src/ui/index.ts index 3a1ddf4..f2e698c 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,5 +1,4 @@ export { - App, readSettings, readProjectSettings, writeSettings, @@ -8,6 +7,7 @@ export { resolveCurrentSettings, createOpenAIClient, } from "./App"; +export { default as AppContainer } from "./AppContainer"; export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; export { MessageView } from "./compoments"; export { parseDiffPreview } from "./compoments/MessageView/utils"; diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index aab06bd..948a7ab 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -106,7 +106,7 @@ export function findExactSlashCommand(items: SlashCommandItem[], token: string): return null; } const query = token.slice(1); - const matches = items.filter((item) => item.name.includes(query)); + const matches = items.filter((item) => item.name === query); return matches.find((item) => item.kind !== "skill") ?? matches[0] ?? null; } From 379ffc5b45f9973e5738718657411f87c6db26cf Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 14:30:49 +0800 Subject: [PATCH 08/11] =?UTF-8?q?docs(readme):=20=E6=81=A2=E5=A4=8D=20READ?= =?UTF-8?q?ME-zh=5FCN.md=20=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README_en.md => README-en.md | 0 README-zh_CN.md | 168 +++++++++++++++++++++++++++++++++++ README.md | 2 +- 3 files changed, 169 insertions(+), 1 deletion(-) rename README_en.md => README-en.md (100%) create mode 100644 README-zh_CN.md diff --git a/README_en.md b/README-en.md similarity index 100% rename from README_en.md rename to README-en.md diff --git a/README-zh_CN.md b/README-zh_CN.md new file mode 100644 index 0000000..98346b6 --- /dev/null +++ b/README-zh_CN.md @@ -0,0 +1,168 @@ +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

+ +[English](README-en.md) · 中文 + +
+
+ +[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 + +## 安装 + +```bash +npm install -g @vegamo/deepcode-cli +``` + +在任意项目目录下运行 `deepcode` 即可启动。 + +![intro2](resources/intro2.png) + +## 配置 + +创建 `~/.deepcode/settings.json` 文件,内容如下: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 + +完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 + +## 主要功能 + +### **Skills** +Deep Code CLI 支持 agent skills,允许您扩展助手的能力: + +- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 +- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 + +### **为 DeepSeek 优化** +- 专门为 DeepSeek 模型性能调优。 +- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 +- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 + +## 斜杠命令与按键功能 + +| 斜杠命令 | 操作 | +|-----------------|---------------------------------------------| +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯)| +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +|-----------------|---------------------------------------------| +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | + +## 支持的模型 + +- `deepseek-v4-pro`(推荐使用) +- `deepseek-v4-flash` +- 任何其他 OpenAI 兼容模型 + + +## 常见问题 + +### Deep Code 是否有 VSCode 插件? + +有的。Deep Code 提供功能完整的 VSCode 插件,可在 [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 安装。插件与 CLI 共享 `~/.deepcode/settings.json` 配置文件,可以在终端和编辑器之间无缝切换。 + +### Deep Code 是否支持理解图片? + +Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 deepseek-v4 不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的 Doubao-Seed-2.0-pro 模型,适配效果最好。 + +### 怎样在任务完成后自动给 Slack 发消息? + +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g + +### 怎样启用联网搜索功能? + +Deep Code自带免费的、且大部分情况够用的Web Search工具。如果你希望使用自定义脚本进行联网搜索,可以在 `~/.deepcode/settings.json` 中将 `webSearchTool` 设为脚本的完整路径即可。详细步骤可参考:https://github.com/qorzj/web_search_cli + +### 如何配置 MCP? + +Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 + +详细配置指南:[docs/mcp.md](docs/mcp.md) + + +### 是否支持 Coding Plan? + +支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: + +```json +{ + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true +} +``` +## 贡献 + +欢迎贡献代码!以下是参与方式: + +```bash +# 克隆仓库 +git clone https://github.com/lessweb/deepcode-cli.git +cd deepcode-cli + +# 安装依赖 +npm install + +# 本地开发(类型检查 + lint + 格式检查 + 构建) +npm run build + +# 运行测试 +npm test + +# 链接到全局(即本地全局安装) +npm link +``` + +- 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) +- 建议在执行构建前,先执行 `npm run format` 自动格式化代码,避免构建报错 + +## 获取帮助 + +- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) + +## 协议 + +- MIT + +## 支持我们 + +如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: + +- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) +- 向我们提交反馈和建议 +- 分享给你的朋友和同事 diff --git a/README.md b/README.md index 00167c1..98346b6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

Deep Code CLI

-[English](./README_en.md) · 中文 +[English](README-en.md) · 中文
From 7e5eeda26829b14eb3ed503b550db06c1145acf6 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 15:05:00 +0800 Subject: [PATCH 09/11] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20raw=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8B=E6=B6=88=E6=81=AF=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Raw 模式下,使用 process.stdout.write 直接输出所有可见消息 - 清屏并重置光标位置,避免 Ink 组件干扰 - 显示提示信息,指导用户按 ESC 退出 raw 模式 - 优化终端尺寸变化时的重绘逻辑 - 更新依赖,确保 raw 模式变动触发重新渲染 --- src/ui/App.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 9189df6..e39fd03 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -434,8 +434,31 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. } lastRenderedColumnsRef.current = stableColumns; + 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"); + 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")); + } + return; + } + // Force full redraw on terminal resize to avoid stale wrapped rows. writeRef.current("\u001B[2J\u001B[H"); + setMessages([]); setShowWelcome(false); setWelcomeNonce((n) => n + 1); @@ -447,7 +470,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setMessages(nextMessages); setShowWelcome(true); }, 0); - }, [busy, sessionManager, stableColumns, stdout]); + }, [busy, mode, sessionManager, stableColumns, stdout]); + const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]); const promptHistory = useMemo(() => { return messages From faf10c3e087d214bf863e9df14040176e30de821 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 15:12:08 +0800 Subject: [PATCH 10/11] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=AE=BD=E5=BA=A6=E7=9B=B8=E5=85=B3=E7=9A=84?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=92=8C=E5=BC=95=E7=94=A8=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并并调整了关于窗口宽度columns的使用,去除了stableColumns状态 - 引用lastRenderedColumnsRef改为直接使用columns,避免延迟更新 - 将多个相关的useRef(writeRef、rawModeRef、messagesRef、processStdoutRef)移至同一位置声明 - 调整useEffect依赖项,改为监听columns代替stableColumns - 优化RawMode下消息重绘逻辑,确保宽度变化时重新渲染 - 统一了screenWidth的计算逻辑,简化代码结构 --- src/ui/App.tsx | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e39fd03..582abaf 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -55,7 +55,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns } = useWindowSize(); + const { mode, setMode } = useRawModeContext(); const initialPromptSubmittedRef = useRef(false); + const processStdoutRef = useRef>(new Map()); + const rawModeRef = useRef(mode); + const writeRef = useRef(write); + const lastRenderedColumnsRef = useRef(null); + const messagesRef = useRef([]); const [view, setView] = useState("chat"); const [busy, setBusy] = useState(false); const [skills, setSkills] = useState([]); @@ -74,13 +80,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); const [showProcessStdout, setShowProcessStdout] = useState(false); - const processStdoutRef = useRef>(new Map()); - const { mode, setMode } = useRawModeContext(); - const rawModeRef = useRef(mode); rawModeRef.current = mode; - - const messagesRef = useRef([]); messagesRef.current = messages; const sessionManager = useMemo(() => { @@ -172,7 +173,6 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. }; }, [sessionManager]); - const writeRef = useRef(write); writeRef.current = write; const handlePrompt = useCallback( async (submission: PromptSubmission) => { @@ -412,27 +412,21 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. [handleSelectSession, sessionManager, setMode] ); - const [stableColumns, setStableColumns] = useState(columns); - useEffect(() => { - const timer = setTimeout(() => setStableColumns(columns), 100); - return () => clearTimeout(timer); - }, [columns]); - const lastRenderedColumnsRef = useRef(null); useEffect(() => { if (!stdout?.isTTY) { return; } - if (stableColumns <= 0) { + if (columns <= 0) { return; } if (lastRenderedColumnsRef.current === null) { - lastRenderedColumnsRef.current = stableColumns; + lastRenderedColumnsRef.current = columns; return; } - if (lastRenderedColumnsRef.current === stableColumns) { + if (lastRenderedColumnsRef.current === columns) { return; } - lastRenderedColumnsRef.current = stableColumns; + lastRenderedColumnsRef.current = columns; if (mode === RawMode.Raw) { // In raw mode, re-render all messages directly to stdout at the new width. @@ -470,9 +464,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setMessages(nextMessages); setShowWelcome(true); }, 0); - }, [busy, mode, sessionManager, stableColumns, stdout]); + }, [busy, mode, sessionManager, columns, stdout]); - const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]); + const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]); const promptHistory = useMemo(() => { return messages .filter((message) => message.role === "user" && typeof message.content === "string") From 32da2ca695e0ff3e135dcbd591ca156554e97108 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 19 May 2026 15:28:38 +0800 Subject: [PATCH 11/11] =?UTF-8?q?feat(rawmode):=20=E6=B7=BB=E5=8A=A0=20Raw?= =?UTF-8?q?Mode=20=E6=8F=8F=E8=BF=B0=E4=BF=A1=E6=81=AF=E4=BB=A5=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=8F=AF=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/contexts/RawModeContext.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/contexts/RawModeContext.tsx b/src/ui/contexts/RawModeContext.tsx index 6fbc706..3198a3a 100644 --- a/src/ui/contexts/RawModeContext.tsx +++ b/src/ui/contexts/RawModeContext.tsx @@ -10,14 +10,17 @@ export const RAW_COMMAND_MODELS: DropdownMenuItem[] = [ { label: "Lite mode", key: RawMode.Lite, + description: "Collapse chain-of-thought reasoning.", }, { label: "Normal mode", key: RawMode.None, + description: "Show full chain-of-thought reasoning.", }, { label: "Raw scrollback mode", key: RawMode.Raw, + description: "Show scrollback mode for copy-friendly terminal selection.", }, ] as const;